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: 12 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ struct DeepLinkAction {
static func routeChat(
_ conversationID: ConversationID,
landing: MessageLanding? = nil,
request: PaymentRequestID? = nil,
origin: DeepLinkOrigin,
in container: SessionContainer
) async {
Expand All @@ -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)",
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
16 changes: 16 additions & 0 deletions Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 18 additions & 3 deletions Flipcash/Core/Screens/PaymentRequest/PaymentRequestDeck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
47 changes: 44 additions & 3 deletions Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions FlipcashCore/Sources/FlipcashCore/Models/Activity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Loading
Loading