diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 899034cfe..cf5094065 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -418,6 +418,9 @@ final class ConversationController { @ObservationIgnored var hostedLobbies: HostedLobbies? /// Keeps the private-group rosters the user has opened live. Set by the session after init. @ObservationIgnored var groupRosters: GroupRosters? + /// The session's payment request store, which live request updates apply to. Weak: the store reads + /// this controller for seals. Set by the session after init. + @ObservationIgnored weak var paymentRequests: PaymentRequests? /// Keeps a photo send running for the time iOS allows after the app leaves the screen. private let backgroundTasks: any BackgroundTaskAsserting @@ -576,6 +579,7 @@ final class ConversationController { self.mentionSearch?.apply(event) self.hostedLobbies?.apply(event) self.groupRosters?.apply(event) + self.applyPaymentRequests(event) if case .needsCatchUp(let conversationID, _) = gap { self.scheduleGapCatchUp(conversationID) } @@ -879,6 +883,13 @@ final class ConversationController { typing.apply(notifications, in: conversationID) } + private func applyPaymentRequests(_ event: ConversationStreamEvent) { + guard case .paymentRequestsChanged(_, let updates) = event, let paymentRequests else { return } + let stale = paymentRequests.apply(updates) + guard !stale.isEmpty else { return } + Task { await paymentRequests.refetch(stale) } + } + private func applyReactions(_ event: ConversationStreamEvent) { guard case .reactionsChanged(let conversationID, let updates) = event else { return } reactions.apply(updates, in: conversationID) @@ -1523,7 +1534,7 @@ final class ConversationController { // edited rules rather than the stale ones. persistConversation(conversationID) case .paymentRequestsChanged: - // TODO: apply to payment request state once a consumer holds it; nothing to persist yet. + // Request state is session-only and refetched; nothing to persist. break case .descriptionChanged: // Description isn't persisted, so there is nothing to write. Fold this into the diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 3c3ed72c9..b589dbb1f 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -240,6 +240,7 @@ struct DeepLinkAction { static func routeChat( _ conversationID: ConversationID, landing: MessageLanding? = nil, + request: PaymentRequestID? = nil, origin: DeepLinkOrigin, in container: SessionContainer ) async { @@ -252,7 +253,8 @@ struct DeepLinkAction { for: conversation?.type, isPrivateOutsider: isPrivateOutsider, conversationID: conversationID, - landing: landing + landing: landing, + request: request ) else { logger.info("Ignoring non-tip chat deeplink", metadata: [ "conversationID": "\(conversationID)", @@ -287,7 +289,8 @@ struct DeepLinkAction { for type: ConversationType?, isPrivateOutsider: Bool = false, conversationID: ConversationID, - landing: MessageLanding? = nil + landing: MessageLanding? = nil, + request: PaymentRequestID? = nil ) -> AppRouter.Destination? { switch type { case .group where isPrivateOutsider: @@ -296,6 +299,9 @@ struct DeepLinkAction { if let landing { return .chatMessage(conversationID, landing) } + if let request { + return .chatRequest(conversationID, request) + } return .tipConversation(conversationID) case .contactDm, nil: return nil diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ed419f5f7..d78f01a9e 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -127,6 +127,9 @@ extension AppRouter { /// Same screen as `tipConversation`, opened on one message from a message link. A sibling /// case for the same reason as `tipConversationWithKeyboard`: the ordinary opens stay untouched. case chatMessage(ConversationID, MessageLanding) + /// Same screen as `tipConversation`, opened with one payment request's details presented over + /// it, as when the request is tapped in the chat. + case chatRequest(ConversationID, PaymentRequestID) /// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face; /// hosts the Block action. `origin` decides whether it offers a way into the DM. case userProfile(UserID, origin: UserProfileOrigin) @@ -183,7 +186,7 @@ extension AppRouter { .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw: return .you case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, - .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, + .tipConversation, .tipConversationWithKeyboard, .chatMessage, .chatRequest, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .editGroupCover, .editGroupDescription, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers, .groupInvite: return .tips @@ -244,6 +247,7 @@ extension AppRouter { case .tipConversation: "tipConversation" case .tipConversationWithKeyboard: "tipConversationWithKeyboard" case .chatMessage: "chatMessage" + case .chatRequest: "chatRequest" case .userProfile: "userProfile" case .chatProfile: "chatProfile" case .editGroup: "editGroup" @@ -285,6 +289,7 @@ extension AppRouter { case .tipConversation(let conversationID), .tipConversationWithKeyboard(let conversationID), .chatMessage(let conversationID, _), + .chatRequest(let conversationID, _), .chatProfile(let conversationID, _), .editGroup(let conversationID), .editGroupName(let conversationID), diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 91f1000e5..ca077a746 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -274,6 +274,11 @@ struct DestinationView: View { ConversationScreen(context: .existing(conversationID), landing: landing) .id(conversationID) + case .chatRequest(let conversationID, let requestID): + // The chat with that request's details presented over it. + ConversationScreen(context: .existing(conversationID), openRequest: requestID) + .id(conversationID) + case .userProfile(let userID, let origin): UserProfileScreen(userID: userID, origin: origin) .id(userID) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index ada09f6f2..5a427a905 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -45,6 +45,9 @@ struct ConversationScreen: View { /// The message a message link opened this chat on, or nil for every other entry point. var landing: MessageLanding? = nil + /// The payment request whose details open over this chat on arrival, or nil. + var openRequest: PaymentRequestID? = nil + @Environment(ConversationController.self) private var conversationController @Environment(ContactSyncController.self) private var contactSyncController @Environment(AppRouter.self) private var router @@ -56,6 +59,7 @@ struct ConversationScreen: View { @Environment(\.scenePhase) private var scenePhase @State private var didInitialRead = false + @State private var didOpenRequest = false /// The chat whose opening load has landed, for a coordinator built after it. @State private var loadedConversationID: ConversationID? /// The chat whose stored draft has been put back, which is also what permits saving: an empty @@ -660,6 +664,12 @@ struct ConversationScreen: View { if let conversationID { await sessionContainer.groupRosters.refresh(conversationID) } } } + .task(id: didInitialRead) { + // Once, when the chat has loaded, so the details come up over the chat rather than the push. + guard didInitialRead, let openRequest, !didOpenRequest else { return } + didOpenRequest = true + paymentRequestDetails = PaymentRequestSelection(id: openRequest) + } .fullScreenCover(item: $paymentRequestDetails) { selection in PaymentRequestDetailsScreen(requestID: selection.id) } diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index 4d00fd2b6..335b0bea6 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -88,6 +88,11 @@ struct TransactionDetailsScreen: View { viewMessage(boost) } .buttonStyle(.filled05) + case .viewRequest(let payment): + Button("View Request") { + viewRequest(payment) + } + .buttonStyle(.filled05) case .viewCounterparty(let userID): let dmID = sessionContainer.conversationController.tipDMID(withUserID: userID) Button(dmID == nil ? "View Profile" : "View in Chat") { @@ -321,6 +326,17 @@ struct TransactionDetailsScreen: View { /// Opens the boosted message through the deep link's own hydration, so a chat the viewer has /// not synced is fetched first and a private group they are not in lands on its profile. + private func viewRequest(_ payment: Activity.RequestedPayment) { + Task { + await DeepLinkAction.routeChat( + payment.conversationID, + request: payment.paymentRequestID, + origin: .activity, + in: sessionContainer + ) + } + } + private func viewMessage(_ boost: Activity.BoostedReaction) { Task { await DeepLinkAction.routeChat( diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift index a4731e002..307047622 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift @@ -411,6 +411,7 @@ struct PaymentRequestPill: View { var pay: ((PaymentRequestID) -> Void)? = nil @Environment(\.dynamicTypeSize) private var typeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// The finger's downward travel, not the front card's: the card rubber-bands past one pill height. @State private var drag: CGFloat = 0 @State private var frontHeight: CGFloat = 43 @@ -420,6 +421,8 @@ struct PaymentRequestPill: View { private static let depthScale: CGFloat = 0.08 private static let depthDim: CGFloat = 0.25 private static let settle = Animation.spring(duration: 0.35, bounce: 0.15) + /// How long the ended look takes to fade in, which a re-sort waits out. + private static let endedFadeDuration = 0.3 var body: some View { // The front request at rest sets the pill's size and is what the deck measures; the cards @@ -485,6 +488,13 @@ struct PaymentRequestPill: View { // Only the front card's Pay button takes a tap; the rest peek. .allowsHitTesting(index == 0) .zIndex(Double(items.count - index)) + // A move that no gesture drives, such as a request ending and sorting down, settles after + // the ended look has faded. Cycling sets its own animation and keeps it. + .transaction(value: index) { transaction in + if transaction.animation == nil, !reduceMotion { + transaction.animation = Self.settle.delay(Self.endedFadeDuration) + } + } } /// How far the front card has moved: with the finger at first, then rubber-banding so it never @@ -565,10 +575,15 @@ struct PaymentRequestPillFace: View { @Environment(\.paymentRequestStripCollapse) private var stripCollapse var body: some View { - switch style { - case .pill: pill - case .strip: strip + Group { + switch style { + case .pill: pill + case .strip: strip + } } + // The switch to the ended look fades, and so does the paid badge arriving. + .requestMotion(value: item.card.isEnded) + .requestMotion(value: item.viewerStatus.showsPaidBadge) } private var ring: some View { diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index c01bf194d..63308d225 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -66,6 +66,14 @@ struct PaymentRequestDetailsScreen: View { TimelineView(.periodic(from: .now, by: 60)) { context in if let content = content(at: context.date) { loaded(content) + } else if sessionContainer.paymentRequests.unavailable.contains(requestID) { + // DENIED or NOT_FOUND, for example after the viewer left the group. + Text("This request is no longer available.") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -177,6 +185,7 @@ struct PaymentRequestDetailsScreen: View { Text(row.status) .font(.appTextSmall) .foregroundStyle(row.isPaid ? Color.textSuccess : Color.textSecondary) + .contentTransition(.opacity) } Spacer(minLength: 8) Text(row.amount) @@ -187,6 +196,7 @@ struct PaymentRequestDetailsScreen: View { .frame(minHeight: 64) .background(Color.white.opacity(0.06), in: .rect(cornerRadius: 12)) .contentShape(.rect) + .requestMotion(value: row.isPaid) } /// The viewer's reading of the request, or nil until it and its title are loaded. diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 0340feb13..6db6dea94 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -13,9 +13,11 @@ nonisolated private let logger = Logger(label: "flipcash.payment-requests") /// The group payment requests read this session, merged by version. /// -/// No event carries a request change, so every copy arrives from a fetch: a chat's sections, one -/// request, or the response to the viewer's own create, close or mark-paid. Each goes through -/// ``ingest(_:)``, which keeps the greater version and, at an equal version, EXPIRED over OPEN. +/// A copy arrives from a fetch (a chat's sections, one request, or the response to the viewer's own +/// create, close or mark-paid) or from a live chat-stream update. Fetched copies go through +/// ``ingest(_:)``, which keeps the greater version and, at an equal version, EXPIRED over OPEN; live +/// updates go through ``apply(_:)``, which follows the same version rule. Updates are best effort, so +/// the polls stay as reconciliation. @MainActor @Observable final class PaymentRequests { @@ -160,6 +162,45 @@ final class PaymentRequests { } } + /// Applies live updates from the chat stream to the copies held, and returns the requests that need + /// a refetch (see ``refetch(_:)``). + /// + /// `.created` merges like a fetched copy. `.updates` apply by version against the held copy; with no + /// held copy they are dropped, since the request is fetched when shown. + @discardableResult + func apply(_ updates: [DecodedPaymentRequestUpdate]) -> [PaymentRequestID] { + var refetch: [PaymentRequestID] = [] + for update in updates { + switch update.change { + case .created(let request): + ingest([request]) + case .updates(let version, let changes): + guard let held = requests[update.id] else { continue } + switch held.applying(version: version, updates: changes) { + case .ignored: + break + case .applied(let applied): + requests[update.id] = applied + case .refetch: + if !refetch.contains(update.id) { refetch.append(update.id) } + } + } + } + return refetch + } + + /// Reads each of `ids` to reconcile a live update that couldn't be applied. A failed read keeps the + /// held copy; the next poll retries. + func refetch(_ ids: [PaymentRequestID]) async { + for id in ids { + do { + try await refresh(id) + } catch { + logger.warning("Failed to refetch a payment request after a live update", metadata: ["error": "\(error)"]) + } + } + } + /// Reads `conversationID`'s OPEN and RESOLVED first pages, and its seal if not held yet. A failed /// read keeps what was held. func refreshChat(_ conversationID: ConversationID) async { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index f53429e13..585a8e8e0 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -1032,6 +1032,7 @@ final class SessionContainer { ) } ) + conversationController.paymentRequests = self.paymentRequests self.rosterSearch = mentionSearch // The blocklist drives which conversations the feed hides: it supplies the diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift index 0c5058a32..5c37ba122 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift @@ -31,8 +31,8 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { /// The reaction this payment boosted, when it was a boost. public let boostedReaction: BoostedReaction? - /// The payment request this payment paid a share of, when it was one. Not persisted by the - /// activity store yet, so it is `nil` on activities read back from the database. + /// The payment request this payment paid a share of, when it was one. A snapshot from payment + /// time: the request may since have closed, or the viewer may no longer be able to read it. public let requestedPayment: RequestedPayment? public var cancellableCashLinkMetadata: CashLinkMetadata? { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index bede89f01..11706d0df 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -397,9 +397,8 @@ public struct ConversationStore: Sendable { applyRulesChanged(rules, in: conversationID) return .none case .paymentRequestsChanged: - // TODO: apply to payment request state (created: keep greater version; updates at V vs - // local L: V<=L ignore, V==L+1 apply in order then set version, V>L+1 or an unknown - // update refetch). No consumer holds request state in the conversation store yet. + // Request state lives in the app's `PaymentRequests` store, not here; the controller + // hands the event to it. return .none } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift index 3f4c14488..a977e2fc5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -82,6 +82,68 @@ extension GroupPaymentRequest { } } +// MARK: - Live updates - + +extension GroupPaymentRequest { + + /// What a batch of live updates did to a held copy. + public enum UpdateOutcome: Hashable, Sendable { + /// The batch is at or below the held version; the copy is unchanged. + case ignored + /// The batch applied; this is the new copy, at the batch's version. + case applied(GroupPaymentRequest) + /// The batch can't be trusted against this copy (a missed version, or an update it can't apply); + /// nothing was applied and the request needs a refetch. + case refetch + } + + /// Applies one `updates` batch at `version` to this copy, by `PaymentRequestUpdate`'s rules. + /// + /// V <= L is ignored; V == L + 1 applies every update in order and sets the version to V; V > L + 1 + /// asks for a refetch. A batch holding an update this copy can't apply (an unknown kind, a share + /// without a user or that the copy doesn't have, a status other than COMPLETED or CLOSED) applies + /// none of it and asks for a refetch. + public func applying(version: UInt64, updates: [DecodedPaymentRequestUpdate.Change.Update]) -> UpdateOutcome { + guard version > self.version else { return .ignored } + guard version == self.version + 1, let split else { return .refetch } + + var shares = split.shares + var status = self.status + var closedAt = self.closedAt + for update in updates { + switch update { + case .sharePaid(let share): + guard let userID = share.userID, let index = shares.firstIndex(where: { $0.userID == userID }) else { + return .refetch + } + shares[index] = share + case .statusChanged(let newStatus, let newClosedAt): + switch newStatus { + case .completed, .closed: + status = newStatus + // An update that leaves closedAt unset keeps the held copy's. + closedAt = newClosedAt ?? closedAt + case .open, .expired, .unknown: + return .refetch + } + case .unknown: + return .refetch + } + } + + return .applied(GroupPaymentRequest( + id: id, + creator: creator, + createdAt: createdAt, + status: status, + closedAt: closedAt, + version: version, + chatID: chatID, + split: Split(text: split.text, currency: split.currency, endsAt: split.endsAt, shares: shares) + )) + } +} + // MARK: - Deadline - extension GroupPaymentRequest { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index 8f06c4df3..9632ba511 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -139,6 +139,8 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { public enum ChatAction: Sendable, Equatable, Hashable { /// Opens the message a boost was for. case viewMessage(Activity.BoostedReaction) + /// Opens the chat the request was made in, with the request's details over it. + case viewRequest(Activity.RequestedPayment) /// Opens the counterparty's DM, or their profile until the DM exists. case viewCounterparty(UserID) } @@ -212,9 +214,12 @@ extension TransactionDetails { // identifies one — a phone-number counterparty has no chat or profile. // The display name is not required: the profile fetches its own. self.canViewInChat = activity.counterparty?.userID != nil - // A boost wins over the counterparty: the message it was for is the more specific place. + // A boost wins over a request, and both win over the counterparty: the message or request it was for is the more specific place. if let boost = activity.boostedReaction { self.chatAction = .viewMessage(boost) + } else if let payment = activity.requestedPayment { + // The request is the more specific place, ahead of the counterparty. + self.chatAction = .viewRequest(payment) } else if let userID = activity.counterparty?.userID { self.chatAction = .viewCounterparty(userID) } else { diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift index 6cd378c0a..8b4e2ce76 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift @@ -100,13 +100,17 @@ nonisolated extension Database { b.conversationID AS boostConversationID, b.messageID AS boostMessageID, - b.emoji AS boostEmoji + b.emoji AS boostEmoji, + + r.conversationID AS requestConversationID, + r.paymentRequestID AS requestPaymentRequestID FROM activity a LEFT JOIN cashLinkMetadata c ON c.id = a.id LEFT JOIN swapMetadata s ON s.id = a.id LEFT JOIN boostedReaction b ON b.id = a.id + LEFT JOIN requestedPayment r ON r.id = a.id WHERE a.mint = ? @@ -152,13 +156,17 @@ nonisolated extension Database { b.conversationID AS boostConversationID, b.messageID AS boostMessageID, - b.emoji AS boostEmoji + b.emoji AS boostEmoji, + + r.conversationID AS requestConversationID, + r.paymentRequestID AS requestPaymentRequestID FROM activity a LEFT JOIN cashLinkMetadata c ON c.id = a.id LEFT JOIN swapMetadata s ON s.id = a.id LEFT JOIN boostedReaction b ON b.id = a.id + LEFT JOIN requestedPayment r ON r.id = a.id ORDER BY a.date DESC LIMIT ?; @@ -199,7 +207,24 @@ nonisolated extension Database { date: row[a.date], metadata: metadata(for: kind, row: row), counterparty: Activity.Counterparty(userID: row[a.counterpartyUserID], phone: row[a.counterpartyPhone]), - boostedReaction: boostedReaction(from: row) + boostedReaction: boostedReaction(from: row), + requestedPayment: requestedPayment(from: row) + ) + } + + /// Reads the joined `requestedPayment` columns, aliased in the SELECT. Both are NULL for a payment + /// that paid no request. + private func requestedPayment(from row: RowIterator.Element) -> Activity.RequestedPayment? { + guard + let conversationID = row[Expression("requestConversationID")], + let paymentRequestID = row[Expression("requestPaymentRequestID")] + else { + return nil + } + + return Activity.RequestedPayment( + conversationID: ConversationID(data: conversationID), + paymentRequestID: PaymentRequestID(data: paymentRequestID) ) } @@ -336,6 +361,21 @@ nonisolated extension Database { } else { try writer.run(boostTable.table.filter(boostTable.id == activity.id).delete()) } + + let requestTable = RequestedPaymentTable() + if let payment = activity.requestedPayment { + try writer.run( + requestTable.table.upsert( + requestTable.id <- activity.id, + requestTable.conversationID <- payment.conversationID.data, + requestTable.paymentRequestID <- payment.paymentRequestID.data, + + onConflictOf: requestTable.id, + ) + ) + } else { + try writer.run(requestTable.table.filter(requestTable.id == activity.id).delete()) + } } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 74ad6307c..b1f2f1593 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 60 + public static let schemaVersion = 61 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 830941386..49b67f702 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -128,6 +128,20 @@ nonisolated public struct BoostedReactionTable: Sendable { public let emoji = Expression ("emoji") } +// Side table for payments that paid a share of a payment request: the chat and request paid. Joined +// 1:1 to `activity` by id; a row exists only for such a payment. A snapshot from payment time, so the +// request may since have closed and the viewer may no longer be able to read it. +nonisolated public struct RequestedPaymentTable: Sendable { + public static let name = "requestedPayment" + + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let conversationID = Expression ("conversationID") + public let paymentRequestID = Expression ("paymentRequestID") +} + nonisolated public struct LimitsTable: Sendable { public static let name = "limits" @@ -525,6 +539,18 @@ nonisolated extension Database { }) } + let requestedPaymentTable = RequestedPaymentTable() + + try writer.transaction(.immediate) { + try writer.run(requestedPaymentTable.table.create(ifNotExists: true, withoutRowid: true) { t in + t.column(requestedPaymentTable.id, primaryKey: true) + t.column(requestedPaymentTable.conversationID) + t.column(requestedPaymentTable.paymentRequestID) + + t.foreignKey(requestedPaymentTable.id, references: activityTable.table, activityTable.id, delete: .cascade) + }) + } + let limitsTable = LimitsTable() try writer.transaction(.immediate) { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift index 94584c273..93de84943 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -179,6 +179,41 @@ struct TransactionDetailsTests { #expect(alone.chatAction == nil) } + @Test("D4: a requested payment opens the request ahead of the counterparty") + func requestedPaymentViewsRequest() { + let payment = Activity.RequestedPayment( + conversationID: ConversationID(data: Data(repeating: 1, count: 16)), + paymentRequestID: PaymentRequestID(data: Data([7])) + ) + let details = TransactionDetails( + activity: Activity( + id: .jeffy, state: .completed, kind: .gave, title: "Sent", + exchangedFiat: Self.usd(1), date: .now, metadata: nil, + counterparty: counterparty, requestedPayment: payment + ) + ) + + #expect(details.chatAction == .viewRequest(payment)) + } + + @Test("D5: a boost wins over a requested payment") + func boostBeatsRequest() { + let boost = boost() + let payment = Activity.RequestedPayment( + conversationID: ConversationID(data: Data(repeating: 1, count: 16)), + paymentRequestID: PaymentRequestID(data: Data([7])) + ) + let details = TransactionDetails( + activity: Activity( + id: .jeffy, state: .completed, kind: .gave, title: "Sent", + exchangedFiat: Self.usd(1), date: .now, metadata: nil, + counterparty: counterparty, boostedReaction: boost, requestedPayment: payment + ) + ) + + #expect(details.chatAction == .viewMessage(boost)) + } + // MARK: - Boost header private func boostDetails( diff --git a/FlipcashTests/Database/Database+RequestedPaymentTests.swift b/FlipcashTests/Database/Database+RequestedPaymentTests.swift new file mode 100644 index 000000000..fd5b4299f --- /dev/null +++ b/FlipcashTests/Database/Database+RequestedPaymentTests.swift @@ -0,0 +1,64 @@ +// +// Database+RequestedPaymentTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Database+RequestedPayment") +struct DatabaseRequestedPaymentTests { + + private static let payment = Activity.RequestedPayment( + conversationID: ConversationID(data: Data((0..<16).map(UInt8.init))), + paymentRequestID: PaymentRequestID(data: Data((16..<32).map(UInt8.init))) + ) + + private static func activity(payment: Activity.RequestedPayment?) -> Activity { + Activity( + id: .jeffy, + state: .completed, + kind: .gave, + title: "Sent", + exchangedFiat: .mockOne, + date: .now, + metadata: nil, + requestedPayment: payment + ) + } + + @Test("A payment for a request reads back with the same chat and request, per mint and recent") + func roundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: Self.payment)]) + + #expect(try database.getRecentActivities(limit: 10).first?.requestedPayment == Self.payment) + #expect(try database.getActivities(mint: ExchangedFiat.mockOne.mint).first?.requestedPayment == Self.payment) + } + + @Test("A payment that paid no request reads back nil") + func noRequest() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: nil)]) + + #expect(try database.getRecentActivities(limit: 10).first?.requestedPayment == nil) + } + + @Test("Re-inserting an activity without its request removes the stored link") + func resyncDropsLink() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: Self.payment)]) + try database.insertActivities(activities: [Self.activity(payment: nil)]) + + #expect(try database.getRecentActivities(limit: 10).first?.requestedPayment == nil) + } +} diff --git a/FlipcashTests/DeepLinkControllerTests.swift b/FlipcashTests/DeepLinkControllerTests.swift index 4d4bdec42..03edcadc2 100644 --- a/FlipcashTests/DeepLinkControllerTests.swift +++ b/FlipcashTests/DeepLinkControllerTests.swift @@ -247,4 +247,18 @@ struct DeepLinkControllerTests { == .chatProfile(Self.chatID, origin: .featuredGroup) ) } + + @Test("A requested payment opens its chat with the request, and a private outsider gets the profile") + func chatDestination_withRequest() { + let request = PaymentRequestID(data: Data([7])) + #expect( + DeepLinkAction.chatDestination(for: .group, conversationID: Self.chatID, request: request) + == .chatRequest(Self.chatID, request) + ) + #expect( + DeepLinkAction.chatDestination(for: .group, isPrivateOutsider: true, conversationID: Self.chatID, request: request) + == .chatProfile(Self.chatID, origin: .featuredGroup) + ) + #expect(DeepLinkAction.chatDestination(for: .contactDm, conversationID: Self.chatID, request: request) == nil) + } } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift new file mode 100644 index 000000000..1bd6b5ab4 --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestsLiveUpdateTests.swift @@ -0,0 +1,221 @@ +// +// PaymentRequestsLiveUpdateTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("PaymentRequests live updates") +struct PaymentRequestsLiveUpdateTests { + + private let chat = ConversationID.test(1) + private let creator = UUID() + private let payer = UUID() + private let other = UUID() + private let id = PaymentRequestID(data: Data([1])) + private let closedDate = Date(timeIntervalSince1970: 1_800_000_000) + + private func request( + version: UInt64 = 1, + status: GroupPaymentRequest.Status = .open, + paid: Bool = false + ) -> GroupPaymentRequest { + GroupPaymentRequest( + id: id, + creator: creator, + createdAt: closedDate.addingTimeInterval(-60), + status: status, + closedAt: nil, + version: version, + chatID: chat, + split: .init( + text: .plaintext(title: "Dinner", description: ""), + currency: "usd", + endsAt: closedDate.addingTimeInterval(86_400), + shares: [ + .init(userID: creator, nativeAmount: 10, settlement: .creator), + .init(userID: payer, nativeAmount: 10, settlement: paid ? .manual : nil), + .init(userID: other, nativeAmount: 10), + ] + ) + ) + } + + private func paidShare(_ user: UUID) -> GroupPaymentRequest.Share { + .init(userID: user, nativeAmount: 10, paidAt: closedDate, settlement: .intent(Data([9]))) + } + + private func updates(_ version: UInt64, _ list: [DecodedPaymentRequestUpdate.Change.Update]) -> DecodedPaymentRequestUpdate { + .init(id: id, change: .updates(version: version, list)) + } + + private func store(holding held: GroupPaymentRequest? = nil) -> PaymentRequests { + let store = PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in throw ErrorGetPaymentRequest.unknown }) + if let held { store.ingest([held]) } + return store + } + + // MARK: created + + @Test("created with a greater version replaces the held copy") + func createdGreater() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([.init(id: id, change: .created(request(version: 3, status: .closed)))]) + #expect(refetch.isEmpty) + #expect(store.request(id)?.version == 3) + #expect(store.request(id)?.status == .closed) + } + + @Test("created with a lesser version keeps the held copy") + func createdLesser() { + let store = store(holding: request(version: 3, status: .closed)) + store.apply([.init(id: id, change: .created(request(version: 1)))]) + #expect(store.request(id)?.version == 3) + } + + @Test("created at an equal version keeps EXPIRED over OPEN") + func createdEqualExpired() { + let store = store(holding: request(version: 2, status: .expired)) + store.apply([.init(id: id, change: .created(request(version: 2, status: .open)))]) + #expect(store.request(id)?.status == .expired) + } + + @Test("created with no held copy stores it") + func createdNoCopy() { + let store = store() + store.apply([.init(id: id, change: .created(request(version: 1)))]) + #expect(store.request(id) != nil) + } + + // MARK: updates + + @Test("V <= L is ignored, at equal and lesser versions") + func staleIgnored() { + let store = store(holding: request(version: 3)) + let refetch = store.apply([ + updates(3, [.sharePaid(paidShare(payer))]), + updates(2, [.sharePaid(paidShare(payer))]), + ]) + #expect(refetch.isEmpty) + #expect(store.request(id)?.version == 3) + #expect(store.request(id)?.share(for: payer)?.isPaid == false) + } + + @Test("V == L + 1 applies every change in order and sets the version") + func nextVersionApplies() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [ + .sharePaid(paidShare(payer)), + .sharePaid(paidShare(other)), + .statusChanged(.completed, closedAt: closedDate), + ])]) + #expect(refetch.isEmpty) + let applied = store.request(id) + #expect(applied?.version == 2) + #expect(applied?.share(for: payer)?.settlement == .intent(Data([9]))) + #expect(applied?.share(for: other)?.isPaid == true) + #expect(applied?.status == .completed) + #expect(applied?.closedAt == closedDate) + } + + @Test("sharePaid replaces the share with the same user and leaves the others") + func sharePaidReplaces() { + let store = store(holding: request(version: 1)) + store.apply([updates(2, [.sharePaid(paidShare(payer))])]) + let applied = store.request(id) + #expect(applied?.share(for: payer)?.paidAt == closedDate) + #expect(applied?.share(for: other)?.isPaid == false) + #expect(applied?.split?.shares.count == 3) + #expect(applied?.status == .open) + } + + @Test("a close sets CLOSED and closedAt") + func closeApplies() { + let store = store(holding: request(version: 1)) + store.apply([updates(2, [.statusChanged(.closed, closedAt: closedDate)])]) + #expect(store.request(id)?.status == .closed) + #expect(store.request(id)?.closedAt == closedDate) + } + + @Test("a status update with no closedAt keeps the held closedAt") + func statusKeepsClosedAt() { + let held = GroupPaymentRequest( + id: id, creator: creator, createdAt: nil, status: .open, closedAt: closedDate, + version: 1, chatID: chat, split: request().split + ) + let store = store(holding: held) + store.apply([updates(2, [.statusChanged(.closed, closedAt: nil)])]) + #expect(store.request(id)?.status == .closed) + #expect(store.request(id)?.closedAt == closedDate) + } + + @Test("V > L + 1 refetches and leaves the copy as held") + func gapRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(3, [.sharePaid(paidShare(payer))])]) + #expect(refetch == [id]) + #expect(store.request(id)?.version == 1) + #expect(store.request(id)?.share(for: payer)?.isPaid == false) + } + + @Test("an unknown update applies none of the batch and refetches") + func unknownRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [.sharePaid(paidShare(payer)), .unknown])]) + #expect(refetch == [id]) + #expect(store.request(id)?.version == 1) + #expect(store.request(id)?.share(for: payer)?.isPaid == false) + } + + @Test("a share the copy does not have refetches without applying the batch") + func unknownShareRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [.sharePaid(paidShare(UUID()))])]) + #expect(refetch == [id]) + #expect(store.request(id)?.version == 1) + } + + @Test("a status other than COMPLETED or CLOSED refetches") + func unsupportedStatusRefetches() { + let store = store(holding: request(version: 1)) + let refetch = store.apply([updates(2, [.statusChanged(.expired, closedAt: closedDate)])]) + #expect(refetch == [id]) + #expect(store.request(id)?.status == .open) + } + + @Test("updates with no held copy are ignored and nothing is fetched") + func noCopyIgnored() { + let store = store() + let refetch = store.apply([updates(2, [.sharePaid(paidShare(payer))])]) + #expect(refetch.isEmpty) + #expect(store.request(id) == nil) + } + + @Test("the same batch twice, or out of order, converges") + func duplicateAndOutOfOrder() { + let store = store(holding: request(version: 1)) + let first = updates(2, [.sharePaid(paidShare(payer))]) + let second = updates(3, [.statusChanged(.closed, closedAt: closedDate)]) + // v3 first is a gap; then v2 applies; then v3 applies; then v2 again is stale. + #expect(store.apply([second]) == [id]) + #expect(store.apply([first, second, first]).isEmpty) + #expect(store.request(id)?.version == 3) + #expect(store.request(id)?.status == .closed) + #expect(store.request(id)?.share(for: payer)?.isPaid == true) + } + + @Test("a refetch reads the request and merges the fetched copy") + func refetchMerges() async { + let fetched = request(version: 5, status: .closed) + let store = PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in (fetched, nil) }) + store.ingest([request(version: 1)]) + await store.refetch(store.apply([updates(4, [.unknown])])) + #expect(store.request(id)?.version == 5) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift index b32332b5e..1b5311085 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestCardView.swift @@ -42,6 +42,7 @@ public struct PaymentRequestCard: View { .frame(maxWidth: .infinity, alignment: .leading) .redacted(reason: content == nil ? .placeholder : []) .opacity(content?.isEnded == true ? 0.5 : 1) + .requestMotion(value: content?.isEnded) .accessibilityElement(children: .combine) } } @@ -67,7 +68,8 @@ public struct PaymentRequestProgressBar: View { } } .frame(height: 6) - .animation(.easeOut(duration: 0.3), value: fraction) + .requestMotion(value: fraction) + .requestMotion(value: isEnded) .accessibilityHidden(true) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift index 58375df8d..044bb40b1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/PaymentRequestRing.swift @@ -49,7 +49,31 @@ public struct PaymentRequestRing: View { .foregroundStyle(Color.black) } .frame(width: diameter, height: diameter) - .animation(.easeOut(duration: 0.3), value: fraction) + .requestMotion(value: fraction) + .requestMotion(value: isEnded) + .requestMotion(value: isClosed) .accessibilityHidden(true) } } + +private struct RequestMotion: ViewModifier { + + let animation: Animation + let value: Value + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content.animation(reduceMotion ? nil : animation, value: value) + } +} + +public extension View { + + /// Animates a change to `value` on a payment request already on screen: ring sweeps, the switch + /// to the ended look, a share turning paid. `animation(_:value:)` skips the first appearance, so + /// nothing plays on load. With Reduce Motion on, the change is applied at once. + func requestMotion(_ animation: Animation = .easeOut(duration: 0.3), value: Value) -> some View { + modifier(RequestMotion(animation: animation, value: value)) + } +}