diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift index b2db951ab..95de8b0dd 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCard.swift @@ -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) - ) - } -} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy+Deck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy+Deck.swift new file mode 100644 index 000000000..b3fe3f3d2 --- /dev/null +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy+Deck.swift @@ -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: " requests, , <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 + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift index 3d92a79d9..0570720fb 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -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. @@ -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 { @@ -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 diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift index 23100b434..58f12737a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift @@ -65,7 +65,7 @@ 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) @@ -73,7 +73,7 @@ final class PaymentRequestService: Sendable { 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)"]) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/GRPCTransport.swift b/FlipcashCore/Sources/FlipcashCore/Clients/GRPCTransport.swift index 9e7d35000..cf8e37d71 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/GRPCTransport.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/GRPCTransport.swift @@ -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 } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index 82a1d6113..5897d576a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -39,6 +39,8 @@ 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, @@ -46,7 +48,8 @@ extension ChatItem { 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 @@ -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): diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestCopy.swift similarity index 70% rename from Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift rename to FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestCopy.swift index bb455f20d..e8a8b02a1 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCopy.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestCopy.swift @@ -1,6 +1,6 @@ // // PaymentRequestCopy.swift -// Flipcash +// FlipcashCore // // Copyright © 2026 Code Inc. All rights reserved. // @@ -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 @@ -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" @@ -58,17 +58,17 @@ 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) } @@ -76,7 +76,7 @@ struct PaymentRequestCopy { // 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" @@ -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" @@ -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 } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestPreview.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestPreview.swift new file mode 100644 index 000000000..0cb06d8a3 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestPreview.swift @@ -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 +} diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestText.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestText.swift similarity index 82% rename from Flipcash/Core/Screens/PaymentRequest/PaymentRequestText.swift rename to FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestText.swift index 632b8e70f..ef62ca29c 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestText.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/PaymentRequestText.swift @@ -1,6 +1,6 @@ // // PaymentRequestText.swift -// Flipcash +// FlipcashCore // // Copyright © 2026 Code Inc. All rights reserved. // @@ -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 diff --git a/FlipcashCore/Tests/FlipcashCoreTests/PaymentRequestPreviewTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/PaymentRequestPreviewTests.swift new file mode 100644 index 000000000..f4f5b91e3 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/PaymentRequestPreviewTests.swift @@ -0,0 +1,102 @@ +// +// PaymentRequestPreviewTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("Notification preview of a payment request") +struct PaymentRequestPreviewTests { + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + private let creator = UUID() + private let viewer = UUID() + private let requestID = PaymentRequestID(data: Data(repeating: 7, count: 32)) + private let copy = PaymentRequestCopy(displayCurrency: .usd, rates: [:]) + + private func request(text: GroupPaymentRequest.Text?) -> GroupPaymentRequest { + GroupPaymentRequest( + id: requestID, + creator: creator, + createdAt: now.addingTimeInterval(-60), + status: .open, + closedAt: nil, + version: 1, + chatID: ConversationID(data: Data(repeating: 1, count: 32)), + split: .init( + text: text, + currency: "usd", + endsAt: now.addingTimeInterval(3 * 24 * 3600), + shares: [ + .init(userID: creator, nativeAmount: 20, settlement: .creator), + .init(userID: viewer, nativeAmount: 20), + .init(userID: UUID(), nativeAmount: 20), + ] + ) + ) + } + + private func preview(_ request: GroupPaymentRequest, viewer: UUID? = nil, sender: String? = "Sam Rivera") -> PaymentRequestPreview { + PaymentRequestPreview(request: request, seal: nil, viewer: viewer ?? self.viewer, senderName: sender, copy: copy, now: now) + } + + @Test("A titled request words the banner with the viewer's share and fills the card") + func titleAndAmount() { + let result = preview(request(text: .plaintext(title: "Dinner", description: ""))) + + #expect(result.body == "Sam sent request for Dinner · $20") + #expect(result.card?.title == "Dinner") + #expect(result.card?.progressLine == "$20 paid, $60 requested") + } + + @Test("Someone without a share sees the request's total") + func totalForNonParticipant() { + let result = preview(request(text: .plaintext(title: "Dinner", description: "")), viewer: UUID()) + + #expect(result.body == "Sam sent request for Dinner · $60") + } + + @Test("A title that can't be opened keeps the amount and leaves the card empty") + func noTitle() { + let sealed = GroupPaymentRequest.Text.encrypted(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + let result = preview(request(text: sealed)) + + #expect(result.body == "Sam sent request · $20") + #expect(result.card == nil) + } + + @Test("Without a sender name the line opens on the request") + func noSender() { + let result = preview(request(text: .plaintext(title: "Dinner", description: "")), sender: nil) + + #expect(result.body == "Request for Dinner · $20") + } + + @Test("The transcript row carries the fetched card, and stays card-less without one") + func transcriptCard() throws { + let fetched = try #require(preview(request(text: .plaintext(title: "Dinner", description: ""))).card) + let message = ConversationMessage( + id: MessageID(value: 1), + senderID: creator, + content: .widget(.paymentRequest(requestID)), + date: now, + unreadSeq: 1 + ) + + func card(_ cards: [PaymentRequestID: ChatPaymentRequestContent.Card]) -> ChatPaymentRequestContent.Card?? { + let items = ChatItem.preview(from: [message], selfUserID: viewer, paymentRequestCards: cards) + for case .message(let row) in items { + if case .paymentRequest(let content) = row.content { return .some(content.card) } + } + return nil + } + + #expect(card([requestID: fetched]) == .some(.some(fetched))) + // A failed lookup leaves the map empty: today's card-less row. + #expect(card([:]) == .some(nil)) + } +} diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index 2ab5e6d27..449866ade 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -112,7 +112,7 @@ private struct NotificationMessageRow: View { // The extension has no profile lookup; the handle is what the widget names. NotificationTextBubble(text: profile.linkedHandle ?? "Shared a profile", isFromSelf: message.sender == .me) case .paymentRequest(let request): - // The extension has no request lookup; a title already resolved is all it can name. + // The card is there when the push's request lookup succeeded; the title is all it can name. NotificationTextBubble(text: request.card?.title ?? "Payment request", isFromSelf: message.sender == .me) case .media(let media): // The extension draws no photos; a text line stands in, like the chat list's preview. diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 19ad5060b..1fa8c7eb7 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -458,6 +458,23 @@ final class NotificationService: UNNotificationServiceExtension { ) { replaceBody(body) } + let display = ExtensionStore.displayCurrency(owner: account.keyAccount.ownerPublicKey) + // A pushed payment request names only its id: the lookup words the banner and fills the + // card. Any failure leaves the server's body and the card-less row. + var requestCards: [PaymentRequestID: ChatPaymentRequestContent.Card] = [:] + if let pushed = embedded ?? messages.first(where: { $0.id == messageID }), + case .widget(.paymentRequest(let requestID)) = pushed.content, + let preview = await client.paymentRequestPreview( + id: requestID, + in: conversationID, + owner: owner, + selfUserID: account.userID, + senderName: pushed.senderID.flatMap(senderName), + copy: display.map(PaymentRequestCopy.init) ?? PaymentRequestCopy(displayCurrency: .usd, rates: [:]) + ) { + replaceBody(preview.body) + requestCards[requestID] = preview.card + } if classifyForArchive { let pushed = embedded ?? messages.first { $0.id == messageID } let signals = Self.archiveSignals( @@ -479,7 +496,6 @@ final class NotificationService: UNNotificationServiceExtension { await persist(merge(fetched: [], embedded: embedded), for: conversationID, account: account) return deliver() } - let display = ExtensionStore.displayCurrency(owner: account.keyAccount.ownerPublicKey) func items(_ branding: [PublicKey: MintBrandingInfo]) -> [ChatItem] { ChatItem.preview( from: messages, @@ -487,7 +503,8 @@ final class NotificationService: UNNotificationServiceExtension { limit: NotificationPreviewCache.previewLimit, mintBranding: branding, authorName: memberName, - display: display + display: display, + paymentRequestCards: requestCards ) } // Cache the messages (currency-code fallback) and release the banner before the branding