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
13 changes: 0 additions & 13 deletions Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,3 @@ import FlipcashCore

/// What a request card shows, already put into words.
typealias PaymentRequestCardContent = ChatPaymentRequestContent.Card

extension ChatPaymentRequestContent.Card {
/// Words `request` for the viewer: amounts in their currency, the deadline as of `now`.
init(request: GroupPaymentRequest, title: String, copy: PaymentRequestCopy, now: Date) {
self.init(
title: title,
progressLine: copy.progressLine(request),
fraction: request.paidFraction,
statusLine: PaymentRequestCopy.statusLine(request.deadline(at: now)),
isEnded: request.isEnded(at: now)
)
}
}
18 changes: 18 additions & 0 deletions Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy+Deck.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// PaymentRequestCopy+Deck.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashCore

extension PaymentRequestCopy {
/// The deck's VoiceOver label: "<N> requests, <title>, <viewer status>, <paid> of <requested> paid, <status>".
func deckAccessibilityLabel(front: GroupPaymentRequest, title: String, viewerStatus: PaymentRequestViewerStatus, count: Int, now: Date) -> String {
let parts = [title, viewerStatus.accessibilityText, "\(deckAmounts(front)) paid", Self.statusLine(front.deadline(at: now))]
let summary = parts.compactMap { $0 }.joined(separator: ", ")
return count > 1 ? "\(count) requests, \(summary)" : summary
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public final class ChatNotificationClient: Sendable {
private let chatService: ChatService
private let resolverService: ResolverService
private let profileService: ProfileService
private let paymentRequestService: PaymentRequestService
private let coreClient: GRPCClient<AppTransport>
/// The core client's connection loop; retained for its lifetime — dropping it makes the client
/// inert and every RPC hangs.
Expand All @@ -49,6 +50,7 @@ public final class ChatNotificationClient: Sendable {
self.chatService = ChatService(client: coreClient)
self.resolverService = ResolverService(client: coreClient)
self.profileService = ProfileService(client: coreClient)
self.paymentRequestService = PaymentRequestService(client: coreClient)
}

deinit {
Expand Down Expand Up @@ -110,6 +112,52 @@ public final class ChatNotificationClient: Sendable {
}
}

// MARK: - Payment requests -

/// The words a pushed payment request is shown with, or nil when it can't be read in `timeout`
/// (a failed lookup, a request the viewer can't see, or no time left). The banner keeps the
/// server's body then. The title is opened with the chat's seal when it is encrypted, so a title
/// that won't open still yields the banner line, without a card.
public func paymentRequestPreview(
id: PaymentRequestID,
in conversationID: ConversationID,
owner: KeyPair,
selfUserID: UserID,
senderName: String?,
copy: PaymentRequestCopy,
timeout: Duration = .seconds(4),
now: Date = .now
) async -> PaymentRequestPreview? {
let fetched: PaymentRequestService.Fetched
do {
fetched = try await withCheckedThrowingContinuation { continuation in
paymentRequestService.getPaymentRequest(owner: owner, id: id, timeout: timeout) {
continuation.resume(with: $0)
}
}
} catch {
logger.warning("Payment request lookup failed", metadata: ["error": "\(error)"])
return nil
}
// A request that isn't this chat's is not worded into its banner.
guard fetched.request.chatID == conversationID else { return nil }

var seal: ChatSeal?
if case .encrypted = fetched.request.split?.text, let conversation = try? await getChat(owner: owner, conversationID: conversationID) {
if case .seal(let opened) = await keyring(owner: owner, selfUserID: selfUserID).opener(for: conversation) {
seal = opened
}
}
return PaymentRequestPreview(
request: fetched.request,
seal: seal,
viewer: selfUserID,
senderName: senderName,
copy: copy,
now: now
)
}

// MARK: - Encryption -

/// `messages` with those in an encrypted DM decrypted or marked with why they couldn't be. When
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ final class PaymentRequestService: Sendable {
}
}

func getPaymentRequest(owner: KeyPair, id: PaymentRequestID, completion: @Sendable @escaping (Result<Fetched, ErrorGetPaymentRequest>) -> Void) {
func getPaymentRequest(owner: KeyPair, id: PaymentRequestID, timeout: Duration = .seconds(15), completion: @Sendable @escaping (Result<Fetched, ErrorGetPaymentRequest>) -> Void) {
let request = Flipcash_Paymentrequest_V1_GetPaymentRequestRequest.with {
$0.paymentRequestID = id.proto
$0.auth = owner.authFor(message: $0)
}

Task {
do {
let response = try await service.getPaymentRequest(request, options: .unaryDefault)
let response = try await service.getPaymentRequest(request, options: .unaryDefault(timeout: timeout))
let error = ErrorGetPaymentRequest(rawValue: response.result.rawValue) ?? .unknown
guard error == .ok else {
logger.error("Failed to fetch payment request", metadata: ["error": "\(error)"])
Expand Down
7 changes: 5 additions & 2 deletions FlipcashCore/Sources/FlipcashCore/Clients/GRPCTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ extension CallOptions {
/// so they fail fast on a dead connection instead of waiting for the OS TCP
/// timeout. Streaming RPCs deliberately use `.defaults` (no deadline) so
/// long-lived streams aren't killed at the 15s mark.
static var unaryDefault: CallOptions {
static var unaryDefault: CallOptions { unaryDefault(timeout: .seconds(15)) }

/// ``unaryDefault`` with a different deadline, for a caller on a tighter budget.
static func unaryDefault(timeout: Duration) -> CallOptions {
var options = CallOptions.defaults
options.timeout = .seconds(15)
options.timeout = timeout
return options
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,17 @@ extension ChatItem {
/// - authorName: The name to label another member's row with in a group, or nil (or empty)
/// to leave the row unattributed. Never asked about the viewer's own rows.
/// - display: The currency cash rows are shown in, or nil to show each as it moved.
/// - paymentRequestCards: The worded card of each payment request the extension managed to
/// fetch; a request missing here keeps the card-less row.
public static func preview(
from messages: [ConversationMessage],
selfUserID: UserID,
limit: Int = 3,
mintBranding: [PublicKey: MintBrandingInfo] = [:],
counterpartName: String = "",
authorName: (UserID) -> String? = { _ in nil },
display: DisplayCurrency? = nil
display: DisplayCurrency? = nil,
paymentRequestCards: [PaymentRequestID: ChatPaymentRequestContent.Card] = [:]
) -> [ChatItem] {
// Drop tombstones before slicing so the preview keeps the newest `limit` *visible* messages —
// filtering after `.suffix` would let a recent delete crowd out a real message (or blank the
Expand Down Expand Up @@ -97,7 +100,7 @@ extension ChatItem {
case .widget(.shareProfile(let share)):
content = .shareProfile(LinkCard.User(profileOf: share.username))
case .widget(.paymentRequest(let id)):
content = .paymentRequest(ChatPaymentRequestContent(requestID: id.data, card: nil))
content = .paymentRequest(ChatPaymentRequestContent(requestID: id.data, card: paymentRequestCards[id]))
case .widget(.unrecognized):
content = .unavailable(.updateApp)
case .media(let attachments, let caption):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// PaymentRequestCopy.swift
// Flipcash
// FlipcashCore
//
// Copyright © 2026 Code Inc. All rights reserved.
//
Expand All @@ -9,22 +9,22 @@ import Foundation
import FlipcashCore

/// The words a payment request is shown with, its amounts restated in the viewer's display currency.
struct PaymentRequestCopy {
public struct PaymentRequestCopy: Sendable {

let displayCurrency: CurrencyCode
let rates: [CurrencyCode: Rate]
public let displayCurrency: CurrencyCode
public let rates: [CurrencyCode: Rate]

init(displayCurrency: CurrencyCode, rates: [CurrencyCode: Rate]) {
public init(displayCurrency: CurrencyCode, rates: [CurrencyCode: Rate]) {
self.displayCurrency = displayCurrency
self.rates = rates
}

init(_ display: DisplayCurrency) {
public init(_ display: DisplayCurrency) {
self.init(displayCurrency: display.currency, rates: display.rates)
}

/// `native` in `currency`, restated for the viewer; the amount as it is when no rate converts it.
func viewerAmount(_ native: Decimal, currency: String) -> FiatAmount? {
public func viewerAmount(_ native: Decimal, currency: String) -> FiatAmount? {
guard let code = try? CurrencyCode(currencyCode: currency) else { return nil }
let amount = FiatAmount(value: native, currency: code)
return amount.converted(to: displayCurrency, rates: rates) ?? amount
Expand All @@ -36,19 +36,19 @@ struct PaymentRequestCopy {
}

/// "$20 paid, $520 requested".
func progressLine(_ request: GroupPaymentRequest) -> String {
public func progressLine(_ request: GroupPaymentRequest) -> String {
let currency = request.split?.currency ?? ""
return "\(short(request.paidNative, currency: currency)) paid, \(short(request.requestedNative, currency: currency)) requested"
}

/// The deck's amount line: "$180 of $520".
func deckAmounts(_ request: GroupPaymentRequest) -> String {
public func deckAmounts(_ request: GroupPaymentRequest) -> String {
let currency = request.split?.currency ?? ""
return "\(short(request.paidNative, currency: currency)) of \(short(request.requestedNative, currency: currency))"
}

/// A history row's amount line: "✓ $520 paid" once COMPLETED, otherwise the progress line.
func historyAmounts(_ request: GroupPaymentRequest) -> String {
public func historyAmounts(_ request: GroupPaymentRequest) -> String {
switch request.status {
case .completed:
return "✓ \(short(request.requestedNative, currency: request.split?.currency ?? "")) paid"
Expand All @@ -58,25 +58,25 @@ struct PaymentRequestCopy {
}

/// One share's amount at full precision: "$20.00".
func shareAmount(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> FiatAmount? {
public func shareAmount(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> FiatAmount? {
viewerAmount(Decimal(share.nativeAmount), currency: request.split?.currency ?? "")
}

/// A share's amount as a details row and the Send button show it: "$20".
func shareText(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> String {
public func shareText(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> String {
short(Decimal(share.nativeAmount), currency: request.split?.currency ?? "")
}

/// The per-person amount of a request, taken from its first share.
func perPersonAmount(_ request: GroupPaymentRequest) -> FiatAmount? {
public func perPersonAmount(_ request: GroupPaymentRequest) -> FiatAmount? {
guard let share = request.split?.shares.first else { return nil }
return shareAmount(share, of: request)
}

// MARK: - Static copy -

/// The status line shared by the card, the deck and Request details.
static func statusLine(_ deadline: GroupPaymentRequest.Deadline) -> String {
public static func statusLine(_ deadline: GroupPaymentRequest.Deadline) -> String {
switch deadline {
case .daysLeft(let days):
return days == 1 ? "1 day left to pay" : "\(days) days left to pay"
Expand All @@ -94,7 +94,7 @@ struct PaymentRequestCopy {
}

/// The status line without "to pay", as the owed strip shows it: "2 days left".
static func timeLeft(_ deadline: GroupPaymentRequest.Deadline) -> String {
public static func timeLeft(_ deadline: GroupPaymentRequest.Deadline) -> String {
switch deadline {
case .daysLeft(let days):
return days == 1 ? "1 day left" : "\(days) days left"
Expand All @@ -109,20 +109,14 @@ struct PaymentRequestCopy {

/// A share row's status: "✓ Paid", or "Hasn't paid yet" / "Didn't pay" depending on whether the
/// request is still open.
static func shareStatus(_ share: GroupPaymentRequest.Share, requestIsOpen: Bool) -> String {
public static func shareStatus(_ share: GroupPaymentRequest.Share, requestIsOpen: Bool) -> String {
if share.isPaid { return "✓ Paid" }
return requestIsOpen ? "Hasn't paid yet" : "Didn't pay"
}

/// The deck's VoiceOver label: "<N> requests, <title>, <viewer status>, <paid> of <requested> paid, <status>".
func deckAccessibilityLabel(front: GroupPaymentRequest, title: String, viewerStatus: PaymentRequestViewerStatus, count: Int, now: Date) -> String {
let parts = [title, viewerStatus.accessibilityText, "\(deckAmounts(front)) paid", Self.statusLine(front.deadline(at: now))]
let summary = parts.compactMap { $0 }.joined(separator: ", ")
return count > 1 ? "\(count) requests, \(summary)" : summary
}

/// The owed strip's VoiceOver label: "<N> requests, You owe <share> for <title>, <status>".
static func owedAccessibilityLabel(share: String, title: String, deadline: GroupPaymentRequest.Deadline, count: Int) -> String {
public static func owedAccessibilityLabel(share: String, title: String, deadline: GroupPaymentRequest.Deadline, count: Int) -> String {
let summary = "You owe \(share) for \(title), \(statusLine(deadline))"
return count > 1 ? "\(count) requests, \(summary)" : summary
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// PaymentRequestPreview.swift
// FlipcashCore
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation

extension ChatPaymentRequestContent.Card {
/// Words `request` for the viewer: amounts in their currency, the deadline as of `now`.
public init(request: GroupPaymentRequest, title: String, copy: PaymentRequestCopy, now: Date) {
self.init(
title: title,
progressLine: copy.progressLine(request),
fraction: request.paidFraction,
statusLine: PaymentRequestCopy.statusLine(request.deadline(at: now)),
isEnded: request.isEnded(at: now)
)
}
}

/// What a notification shows for a pushed payment request: the banner line and the transcript card.
public struct PaymentRequestPreview: Hashable, Sendable {
/// The banner body, like "Sam sent request for Dinner · $20".
public let body: String
/// The card for the expanded transcript; nil when the request's title can't be opened.
public let card: ChatPaymentRequestContent.Card?

/// Words `request` for `viewer`, opening its title with `seal` when it is encrypted. The chat list's
/// "sent request for <title>" line, with the amount `viewer` owes, else the request's total.
/// `senderName` leads the line when the push's title doesn't already name the sender.
public init(
request: GroupPaymentRequest,
seal: ChatSeal?,
viewer: UserID,
senderName: String?,
copy: PaymentRequestCopy,
now: Date
) {
let currency = request.split?.currency ?? ""
let text = PaymentRequestText(request: request, seal: seal)
let amount: String = request.share(for: viewer).map { copy.shareText($0, of: request) }
?? copy.viewerAmount(request.requestedNative, currency: currency)?.formattedDroppingZeroFraction()
?? ""

let firstName = senderName.flatMap { $0.split(separator: " ").first.map(String.init) }
var line = firstName.map { "\($0) sent request" } ?? "Request"
if let title = text?.title { line += " for \(title)" }
if !amount.isEmpty { line += " · \(amount)" }

self.body = line
self.card = text.map { Card(request: request, title: $0.title, copy: copy, now: now) }
}

private typealias Card = ChatPaymentRequestContent.Card
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// PaymentRequestText.swift
// Flipcash
// FlipcashCore
//
// Copyright © 2026 Code Inc. All rights reserved.
//
Expand All @@ -9,13 +9,13 @@ import Foundation
import FlipcashCore

/// A request's title and description as the viewer reads them.
struct PaymentRequestText: Hashable {
let title: String
let description: String
public struct PaymentRequestText: Hashable, Sendable {
public let title: String
public let description: String

/// `request`'s text, opened with `seal` when it is encrypted. `nil` when the text is missing, its
/// scheme is unknown, or it fails to open: the card then renders as unsupported.
init?(request: GroupPaymentRequest, seal: ChatSeal?) {
public init?(request: GroupPaymentRequest, seal: ChatSeal?) {
switch request.split?.text {
case .plaintext(let title, let description):
self.title = title
Expand Down
Loading
Loading