Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
1006543
feat(group-requests): ask a private group for money and pay a share
bmc08gt Oct 25, 2026
316c042
chore(shared-core): bump to 0.16.0 for the payment request text cipher
bmc08gt Oct 25, 2026
59f0d7e
fix(group-requests): match Android and the spec on deck, details and …
bmc08gt Oct 25, 2026
18c4b6f
fix(group-requests): present request flow and details full screen, li…
bmc08gt Oct 25, 2026
4f6e0bc
fix(group-requests): full-screen group send, concept B deck, pushed h…
bmc08gt Oct 25, 2026
449fa72
fix(group-requests): push a Name screen from New Request
bmc08gt Oct 25, 2026
4e13577
fix(group-requests): green $ disc inside the request ring
bmc08gt Oct 25, 2026
30bc1b5
fix(group-requests): money sheet as Add Money cards, ring drawn to th…
bmc08gt Oct 25, 2026
773f64a
fix(group-requests): member rows in request details no longer look di…
bmc08gt Oct 25, 2026
1369392
fix(group-requests): soft scroll edge on request details
bmc08gt Oct 25, 2026
47d10d6
fix(group-requests): Name screen title reads "What is the request for?"
bmc08gt Oct 25, 2026
6822473
fix(group-requests): deck expand and collapse stay fluid
bmc08gt Oct 25, 2026
fb564e6
fix(group-requests): pill text no longer jumps when the deck settles
bmc08gt Oct 25, 2026
f686fd4
feat(group-requests): fade the request pill in and out of the app bar
bmc08gt Oct 25, 2026
4270231
feat(group-requests): blur the chat behind the expanded deck like the…
bmc08gt Oct 26, 2026
925cd19
fix(group-requests): keep the deck expanded under request details, an…
bmc08gt Oct 26, 2026
164b86b
fix(group-requests): minimize the deck behind request details opened …
bmc08gt Oct 26, 2026
f7fdf04
fix(group-requests): stop the pill re-measuring itself forever in the…
bmc08gt Oct 26, 2026
7b292cf
fix(group-requests): open sealed request messages so the chat list ca…
bmc08gt Oct 26, 2026
bcd58c7
fix(group-requests): preview a request in the chat list by its title
bmc08gt Oct 26, 2026
8995d5e
fix(group-requests): swipe the minimized deck like a stack of cards
bmc08gt Oct 26, 2026
7d54ba7
feat(group-requests): show the deck as a strip while the viewer owes
bmc08gt Oct 26, 2026
74dfffe
fix(group-requests): keep the chat title clear of the back button beh…
bmc08gt Oct 26, 2026
aabb812
fix(group-requests): shrink the owed strip into the grey pill, and dr…
bmc08gt Oct 26, 2026
8e00a06
fix(group-requests): show Send on request details opened from the deck
bmc08gt Oct 26, 2026
10bf983
fix(group-requests): retry the chat seal so encrypted requests stop s…
bmc08gt Oct 26, 2026
17e7481
fix(group-requests): keep the expanded deck still while request detai…
bmc08gt Oct 26, 2026
3c76f9e
fix(group-requests): retry a failed request read within seconds, not …
bmc08gt Oct 26, 2026
d1c1f71
fix(group-requests): shrink the owed strip into the pill in step with…
bmc08gt Oct 26, 2026
dfff01a
fix(group-requests): move the whole strip stack into the pill, and se…
bmc08gt Oct 26, 2026
419dd58
fix(group-requests): show the payee's handle once in the pay sheet
bmc08gt Oct 26, 2026
977cc98
fix(group-requests): load the group roster for the pay sheet opened f…
bmc08gt Oct 26, 2026
de25602
fix(group-requests): keep the owed strip pinned in the pill once it h…
bmc08gt Oct 26, 2026
2c28ab0
feat(group-requests): show the viewer's paid status on every deck card
bmc08gt Oct 26, 2026
caeee0c
fix(group-requests): put the viewer status in the expanded card's footer
bmc08gt Oct 26, 2026
e073659
feat(group-requests): pick the deadline from one Time To Pay sheet
bmc08gt Oct 26, 2026
6ecb2ff
fix(group-requests): quote a payment request in the reaction alert
bmc08gt Oct 26, 2026
adb6deb
feat(group-requests): show the deadline as a range with Starts and Cl…
bmc08gt Oct 26, 2026
0b63296
fix(group-requests): steady the Time To Pay sheet and animate its sel…
bmc08gt Oct 26, 2026
6524463
fix(group-requests): slide one solid selection in the Time To Pay sheet
bmc08gt Oct 26, 2026
26069c1
fix(group-requests): draw the Time To Pay band as one shape per week
bmc08gt Oct 26, 2026
fbcc49e
fix(group-requests): move the Time To Pay selection without animating…
bmc08gt Oct 26, 2026
5303de5
fix(group-requests): centre the Time To Pay month label with its day …
bmc08gt Oct 26, 2026
cf37486
fix(group-requests): keep the band's ends square under the circles wh…
bmc08gt Oct 26, 2026
4ba1acd
Merge remote-tracking branch 'origin/main' into feat/group-requests
bmc08gt Oct 26, 2026
a86f264
fix(group-requests): build on Xcode 26.3
bmc08gt Oct 26, 2026
1325dda
Merge branch 'main' into feat/group-requests
bmc08gt Oct 26, 2026
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion CrossPlatformVectors/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ let sharedCore: Package.Dependency = {
if let sharedCoreLocalRoot {
return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm")
}
return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.15.0"))
return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.16.0"))
}()

// Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs
Expand Down
87 changes: 85 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1614,7 +1614,7 @@ final class ConversationController {
/// bump per feed apply costs a single window re-read for the open chat.
private static let messageWriteOperations: Set<String> = [
"upsert-messages", "apply-chat-events", "delta-batch", "load-messages", "load-older",
"send-message", "send-media-message", "reset-resync", "edit-message", "delete-message",
"send-message", "send-media-message", "send-payment-request", "reset-resync", "edit-message", "delete-message",
"upsert-conversation", "replace-feed", "replace-group-feed",
]

Expand All @@ -1638,11 +1638,13 @@ final class ConversationController {
/// whoever wrote it. `currencyName` resolves a mint to its display name; nil —
/// or the reserve, whose formatted amount already names itself — drops the
/// "of …" suffix. `knownAuthors` names a group sender the chat's roster subset leaves out.
/// `viewerAmount` restates a payment in the display currency.
/// `viewerAmount` restates a payment in the display currency. `requestTitle` is a payment
/// request's title as the viewer reads it, nil while it isn't loaded or can't be opened.
func lastMessagePreview(
for conversation: Conversation,
knownAuthors: KnownAuthorDirectory.Snapshot = .empty,
viewerAmount: (ExchangedFiat) -> FiatAmount = { $0.nativeAmount },
requestTitle: (PaymentRequestID) -> String? = { _ in nil },
currencyName: (PublicKey) -> String?
) -> String? {
if isCounterpartTyping(in: conversation.id) {
Expand Down Expand Up @@ -1706,6 +1708,14 @@ final class ConversationController {
// The server sends it; no user is the sender to name.
return "Shared a profile"

case .widget(.paymentRequest(let id)):
// A title that hasn't loaded or won't open drops the "for …" rather than blanking the line.
let subject = requestTitle(id).map { " for \($0)" } ?? ""
if isFromSelf { return "You sent request\(subject)" }
guard let senderName else { return nil }
let firstName = senderName.split(separator: " ").first.map(String.init) ?? senderName
return "\(firstName) sent request\(subject)"

case .widget(.unrecognized):
return nil

Expand Down Expand Up @@ -2229,6 +2239,9 @@ final class ConversationController {
_ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, linkPreview: pending.linkPreview, to: conversationID)
case .media(_, let caption):
await retryMedia(clientMessageID: clientMessageID, caption: caption, repliedTo: pending.repliedTo, in: conversationID)
case .widget(.paymentRequest(let id)):
store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID)
_ = await deliverPaymentRequest(id, clientMessageID: clientMessageID, to: conversationID, attempts: 1)
case .encrypted, .cash, .deleted, .widget, .system:
if case .failure(let error) = Result(catching: { try pending.content.asProto() }) {
logger.error("Cannot retry a send this client has no path to re-send", metadata: [
Expand Down Expand Up @@ -2295,6 +2308,70 @@ final class ConversationController {
}
}

/// Sends the widget for the payment request `id` into `conversationID` as a pending row, retrying a
/// failed send with the same request; returns whether it was delivered. A final failure leaves the
/// row failed in the transcript, where a tap retries it like any message.
@discardableResult
func sendPaymentRequest(_ id: PaymentRequestID, in conversationID: ConversationID, attempts: Int = 3) async -> Bool {
let clientMessageID = UUID()
let pending = ConversationMessage(
id: .unassigned,
senderID: selfUserID,
content: .widget(.paymentRequest(id)),
date: .now,
unreadSeq: 0,
status: .sending,
clientMessageID: clientMessageID
)
let anchor = (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 }?.value ?? 0
store.insertPending(pending, anchoredTo: anchor, into: conversationID)
receiptSettle.hold(clientMessageID.uuidString)
return await deliverPaymentRequest(id, clientMessageID: clientMessageID, to: conversationID, attempts: attempts)
}

private func deliverPaymentRequest(_ id: PaymentRequestID, clientMessageID: UUID, to conversationID: ConversationID, attempts: Int) async -> Bool {
let chatType = conversation(withID: conversationID)?.type
var lastError: Error = ErrorSendMessage.unknown
for attempt in 0..<attempts {
if attempt > 0 {
try? await Task.sleep(for: .seconds(attempt))
}
do {
var confirmed = try await messaging.sendPaymentRequestMessage(
owner: owner,
conversationID: conversationID,
paymentRequestID: id,
clientMessageID: clientMessageID
)
confirmed.clientMessageID = clientMessageID
if persist(operation: "send-payment-request", { try database.upsertConversationMessages([confirmed], conversationID: conversationID) }) {
store.dropPending(clientMessageID: clientMessageID, confirmedAt: confirmed.id, in: conversationID)
} else {
scheduleGapCatchUp(conversationID)
}
store.advanceLastActivity(to: confirmed.date, in: conversationID)
if advanceReadPointer(to: confirmed.id, in: conversationID) {
confirmedOwnSendCounts[conversationID, default: 0] += 1
}
refreshFeedPreview(for: conversationID)
persistConversation(conversationID)
Analytics.sentMessage(chatType: chatType)
return true
} catch {
lastError = error
logger.warning("Failed to send payment request message", metadata: [
"conversationID": "\(conversationID)",
"attempt": "\(attempt)",
"error": "\(error)",
])
}
}
store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID)
ErrorReporting.captureError(lastError, reason: "Failed to send payment request message")
Analytics.sentMessage(chatType: chatType, error: lastError)
return false
}

/// Sends each staged chip as its own message, in chip order, with `caption` under the last and
/// `repliedTo` on the first; returns whether every one was delivered.
///
Expand All @@ -2308,6 +2385,12 @@ final class ConversationController {
try await messaging.photoSeal(owner: owner, conversationID: conversationID)
}

/// The seal that opens `conversationID`'s payment-request text, nil while the chat's key is
/// unknown or the chat doesn't encrypt.
func openingSeal(for conversationID: ConversationID) async -> ChatSeal? {
await messaging.openingSeal(owner: owner, conversationID: conversationID)
}

/// Decrypts `conversationID`'s end-to-end encrypted photo blobs, or nil while the chat's key is
/// unknown or the chat doesn't encrypt.
func mediaBlobDecrypt(for conversationID: ConversationID) async -> (@Sendable (Data, BlobID, SealedBlob) throws -> Data)? {
Expand Down
10 changes: 10 additions & 0 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@ extension EncryptedChatClient: ConversationMessaging {
}
}

/// Requests exist only in private groups, which always encrypt, so a chat without a seal fails.
func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, clientMessageID: UUID) async throws -> ConversationMessage {
try await refetchingOnRefusal(conversationID) {
guard let seal = try await sealForSending(conversationID, owner: owner) else {
throw ErrorSendMessage.encryptionRequired
}
return try await client.sendPaymentRequestMessage(owner: owner, conversationID: conversationID, paymentRequestID: id, seal: seal, clientMessageID: clientMessageID)
}
}

func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? {
try await sealForSending(conversationID, owner: owner)
}
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ protocol ConversationMessaging: AnyObject, Sendable {
/// Sends the finalized `photo` as one media message, with `caption` under it; a sealed photo
/// goes out inside `EncryptedContent`.
func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage
/// Sends a widget naming the payment request `id`; it always goes out inside `EncryptedContent`.
func sendPaymentRequestMessage(owner: KeyPair, conversationID: ConversationID, paymentRequestID id: PaymentRequestID, clientMessageID: UUID) async throws -> ConversationMessage
/// The seal a photo sent into `conversationID` is encrypted with, nil when it goes out in
/// plaintext. Throws when the chat encrypts but its seal can't be built.
func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal?
Expand Down
1 change: 1 addition & 0 deletions Flipcash/Core/Controllers/ReactionAlertController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ final class ReactionAlertController {
case .media(_, let caption): ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption))
case .deleted: ChatQuote.deletedSnippet
case .widget(.shareProfile): ChatQuote.sharedProfileSnippet
case .widget(.paymentRequest): ChatQuote.paymentRequestSnippet
case .encrypted, .widget(.unrecognized), .system: ChatQuote.unavailableSnippet
}
}
Expand Down
34 changes: 23 additions & 11 deletions Flipcash/Core/Navigation/AppRouter+NestedSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,39 @@ private struct AppRouterNestedSheetModifier: ViewModifier {
@Environment(\.nestedSheetDepth) private var depth

func body(content: Content) -> some View {
let myDepth = depth + 1
return content
.background(
Color.clear.sheet(item: binding(at: myDepth, fullScreen: false), onDismiss: router.sheetDidFinishDismissing) { nested in
NestedSheetRootView(sheet: nested)
.environment(\.nestedSheetDepth, myDepth)
}
)
.background(
Color.clear.fullScreenCover(item: binding(at: myDepth, fullScreen: true), onDismiss: router.sheetDidFinishDismissing) { nested in
NestedSheetRootView(sheet: nested)
.environment(\.nestedSheetDepth, myDepth)
}
)
}

/// The presentation at `depth`, when it is the kind this host shows.
private func binding(at depth: Int, fullScreen: Bool) -> Binding<AppRouter.SheetPresentation?> {
// SwiftUI also calls the setter with nil after a programmatic dismiss
// completes; without the in-bounds guard that re-entry would pop the
// parent sheet too.
let myDepth = depth + 1
let binding = Binding<AppRouter.SheetPresentation?>(
Binding(
get: {
guard router.presentedSheets.indices.contains(myDepth) else { return nil }
return router.presentedSheets[myDepth]
guard router.presentedSheets.indices.contains(depth) else { return nil }
let sheet = router.presentedSheets[depth]
return sheet.coversFullScreen == fullScreen ? sheet : nil
},
set: { newValue in
guard newValue == nil else { return }
guard router.presentedSheets.indices.contains(myDepth) else { return }
guard router.presentedSheets.indices.contains(depth) else { return }
router.dismissSheet()
}
)
return content.background(
Color.clear.sheet(item: binding, onDismiss: router.sheetDidFinishDismissing) { nested in
NestedSheetRootView(sheet: nested)
.environment(\.nestedSheetDepth, myDepth)
}
)
}
}

Expand Down
11 changes: 11 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ extension AppRouter {

var id: Self { self }

/// Whether this presentation covers the screen and closes only from its close button, as the
/// share screen does.
var coversFullScreen: Bool {
switch self {
case .sendAmount(.privateGroup):
true
case .give, .buy, .addMoney, .downloadApp, .sendAmount, .tips, .switchAccount:
false
}
}

/// The stack hosted inside this sheet. Inverse of `Stack.sheet`.
/// Used by `dismissSheet` to clear the dismissed stack's path so a
/// re-presentation starts at root rather than restoring the stale leaf.
Expand Down
25 changes: 17 additions & 8 deletions Flipcash/Core/Navigation/RootSheetHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ struct RootSheetHostModifier: ViewModifier {
session.transactionNotificationPrompt.pending && !router.isSheetDismissing
}

/// The root sheet when it presents the way `fullScreen` asks for, else nil; writing nil dismisses it.
private func presentedRootSheet(fullScreen: Bool) -> Binding<AppRouter.SheetPresentation?> {
Binding(
get: { router.rootSheet.flatMap { $0.coversFullScreen == fullScreen ? $0 : nil } },
set: { newValue in
if newValue == nil {
router.dismissSheet()
}
}
)
}

func body(content: Content) -> some View {
content
// Resume a tip held for profile creation the moment the profile
Expand All @@ -108,14 +120,11 @@ struct RootSheetHostModifier: ViewModifier {
// write through here. Bound to `rootSheet` (bottom of the sheet
// stack) — nested sheets mount inside this root sheet's content
// via `.appRouterNestedSheet`.
.sheet(item: Binding(
get: { router.rootSheet },
set: { newValue in
if newValue == nil {
router.dismissSheet()
}
}
), onDismiss: router.sheetDidFinishDismissing) { sheet in
.sheet(item: presentedRootSheet(fullScreen: false), onDismiss: router.sheetDidFinishDismissing) { sheet in
RoutedSheet(sheet: sheet)
.appRouterNestedSheet()
}
.fullScreenCover(item: presentedRootSheet(fullScreen: true), onDismiss: router.sheetDidFinishDismissing) { sheet in
RoutedSheet(sheet: sheet)
.appRouterNestedSheet()
}
Expand Down
17 changes: 17 additions & 0 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ extension ChatItem {
/// defaults to no card. Takes the detected links rather than the text so the detector runs
/// once per message, here.
linkCard: ([DetectedLink]) -> LinkCard? = { _ in nil },
/// How a payment-request widget draws; resolved by the caller from the request store so
/// the mapper stays pure. Defaults to the loading card.
paymentRequest: (ConversationMessage) -> PaymentRequestCardState = { _ in .loading },
/// Where the viewer's unread messages began at open; the divider heads the first message
/// after it that someone else sent.
unreadBoundary: UnreadBoundary = .none,
Expand Down Expand Up @@ -310,6 +313,12 @@ extension ChatItem {
)
case .widget(.shareProfile(let share)):
content = .shareProfile(LinkCard.User(profileOf: share.username))
case .widget(.paymentRequest(let id)):
switch paymentRequest(message) {
case .loading: content = .paymentRequest(ChatPaymentRequestContent(requestID: id.data, card: nil))
case .card(let card): content = .paymentRequest(ChatPaymentRequestContent(requestID: id.data, card: card))
case .unsupported: content = .unavailable(.updateApp)
}
case .widget(.unrecognized):
// A widget variant this build doesn't draw; a newer one will.
content = .unavailable(.updateApp)
Expand Down Expand Up @@ -504,6 +513,14 @@ extension ChatItem {
kind: .text,
authorID: original.senderID
)
case .widget(.paymentRequest):
return ChatQuote(
stableID: original.stableID,
authorName: authorName,
snippet: ChatQuote.paymentRequestSnippet,
kind: .text,
authorID: original.senderID
)
case .widget(.unrecognized):
return ChatQuote(
stableID: nil,
Expand Down
Loading
Loading