commit 94f7e4d1e1c9ebd3fdb3e4cf60b11753e251de6c
parent 1214f1839d78d2d96e7be32532e294570f9682d9
Author: William Casarin <jb55@jb55.com>
Date: Mon, 26 Feb 2024 10:22:26 -0800
Merge branch 'iap-improvements'
Pull a few patches from v1.7-rc1
purple: show welcome sheet after ln payment
iap: add loading spinner to purchase actions
Diffstat:
4 files changed, 162 insertions(+), 16 deletions(-)
diff --git a/damus/ContentView.swift b/damus/ContentView.swift
@@ -29,6 +29,7 @@ enum Sheets: Identifiable {
case user_status
case onboardingSuggestions
case purple(DamusPurpleURL)
+ case purple_onboarding
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
return .zap(ZapSheet(target: target, lnurl: lnurl))
@@ -50,6 +51,7 @@ enum Sheets: Identifiable {
case .filter: return "filter"
case .onboardingSuggestions: return "onboarding-suggestions"
case .purple(let purple_url): return "purple" + purple_url.url_string()
+ case .purple_onboarding: return "purple_onboarding"
}
}
}
@@ -334,6 +336,8 @@ struct ContentView: View {
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
+ case .purple_onboarding:
+ DamusPurpleNewUserOnboardingView(damus_state: damus_state)
}
}
.onOpenURL { url in
@@ -343,12 +347,26 @@ struct ContentView: View {
}
switch res {
- case .filter(let filt): self.open_search(filt: filt)
- case .profile(let pk): self.open_profile(pubkey: pk)
- case .event(let ev): self.open_event(ev: ev)
- case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
- case .script(let data): self.open_script(data)
- case .purple(let purple_url): self.active_sheet = .purple(purple_url)
+ case .filter(let filt): self.open_search(filt: filt)
+ case .profile(let pk): self.open_profile(pubkey: pk)
+ case .event(let ev): self.open_event(ev: ev)
+ case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
+ case .script(let data): self.open_script(data)
+ case .purple(let purple_url):
+ if case let .welcome(checkout_id) = purple_url.variant {
+ // If this is a welcome link, do the following before showing the onboarding screen:
+ // 1. Check if this is legitimate and good to go.
+ // 2. Mark as complete if this is good to go.
+ Task {
+ let is_good_to_go = try? await damus_state.purple.check_and_mark_ln_checkout_is_good_to_go(checkout_id: checkout_id)
+ if is_good_to_go == true {
+ self.active_sheet = .purple(purple_url)
+ }
+ }
+ }
+ else {
+ self.active_sheet = .purple(purple_url)
+ }
}
}
}
@@ -468,6 +486,21 @@ struct ContentView: View {
} else {
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
}
+ if damus_state.purple.checkout_ids_in_progress.count > 0 {
+ // For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ Task {
+ // TODO: Improve UX for renewals (#2013)
+ let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
+ let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
+ let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
+ if there_is_a_completed_checkout == true && account_info?.active == true {
+ // Show welcome sheet
+ self.active_sheet = .purple_onboarding
+ }
+ }
+ }
+ }
}
.onChange(of: scenePhase) { (phase: ScenePhase) in
guard let damus_state else { return }
diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift
@@ -12,6 +12,7 @@ class DamusPurple: StoreObserverDelegate {
let settings: UserSettingsStore
let keypair: Keypair
var storekit_manager: StoreKitManager
+ var checkout_ids_in_progress: Set<String> = []
@MainActor
var account_cache: [Pubkey: Account]
@@ -243,6 +244,65 @@ class DamusPurple: StoreObserverDelegate {
}
+ @MainActor
+ func fetch_ln_checkout_object(checkout_id: String) async throws -> LNCheckoutInfo? {
+ let url = environment.api_base_url().appendingPathComponent("ln-checkout/\(checkout_id)")
+
+ let (data, response) = try await make_nip98_authenticated_request(
+ method: .get,
+ url: url,
+ payload: nil,
+ payload_type: nil,
+ auth_keypair: self.keypair
+ )
+
+ if let httpResponse = response as? HTTPURLResponse {
+ switch httpResponse.statusCode {
+ case 200:
+ return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
+ case 404:
+ return nil
+ default:
+ throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
+ }
+ }
+ throw PurpleError.error_processing_response
+ }
+
+ @MainActor
+ /// This function checks the status of all checkout objects in progress with the server, and it does two things:
+ /// - It returns the ones that were freshly completed
+ /// - It internally marks them as "completed"
+ /// Important note: If you call this function, you must use the result, as those checkouts will not be returned the next time you call this function
+ ///
+ /// - Returns: An array of checkout objects that have been successfully completed.
+ func check_status_of_checkouts_in_progress() async throws -> [String] {
+ var freshly_completed_checkouts: [String] = []
+ for checkout_id in self.checkout_ids_in_progress {
+ let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
+ if checkout_info?.is_all_good() == true {
+ freshly_completed_checkouts.append(checkout_id)
+ }
+ if checkout_info?.completed == true {
+ self.checkout_ids_in_progress.remove(checkout_id)
+ }
+ }
+ return freshly_completed_checkouts
+ }
+
+ @MainActor
+ /// This function checks the status of a specific checkout id with the server
+ /// You should use this result immediately, since it will internally be marked as handled
+ ///
+ /// - Returns: true if this checkout is all good to go. false if not. nil if checkout was not found.
+ func check_and_mark_ln_checkout_is_good_to_go(checkout_id: String) async throws -> Bool? {
+ let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
+ if checkout_info?.completed == true {
+ self.checkout_ids_in_progress.remove(checkout_id) // Remove if from the list of checkouts in progress
+ }
+ return checkout_info?.is_all_good()
+ }
+
struct Account {
let pubkey: Pubkey
let created_at: Date
@@ -293,6 +353,44 @@ extension DamusPurple {
let active: Bool
}
+ struct LNCheckoutInfo: Codable {
+ // Note: Swift will decode a JSON full of extra fields into a Struct with only a subset of them, but not the other way around
+ // Therefore, to avoid compatibility concerns and complexity, we should only use the fields we need
+ // The ones we do not need yet will be left commented out until we need them.
+ let id: UUID
+ /*
+ let product_template_name: String
+ let verified_pubkey: String?
+ */
+ let invoice: Invoice?
+ let completed: Bool
+
+
+ struct Invoice: Codable {
+ /*
+ let bolt11: String
+ let label: String
+ let connection_params: ConnectionParams
+ */
+ let paid: Bool?
+
+ /*
+ struct ConnectionParams: Codable {
+ let nodeid: String
+ let address: String
+ let rune: String
+ }
+ */
+ }
+
+ /// Indicates whether this checkout is all good to go.
+ /// The checkout is good to go if it is marked as complete and the invoice has been successfully paid
+ /// - Returns: true if this checkout is all good to go. false otherwise
+ func is_all_good() -> Bool {
+ return self.completed == true && self.invoice?.paid == true
+ }
+ }
+
fileprivate struct AccountUUIDInfo: Codable {
let account_uuid: UUID
}
diff --git a/damus/Views/Purple/DamusPurpleVerifyNpubView.swift b/damus/Views/Purple/DamusPurpleVerifyNpubView.swift
@@ -47,6 +47,7 @@ struct DamusPurpleVerifyNpubView: View {
Button(action: {
Task {
try await damus_state.purple.verify_npub_for_checkout(checkout_id: checkout_id)
+ damus_state.purple.checkout_ids_in_progress.insert(checkout_id)
verified = true
}
}, label: {
diff --git a/damus/Views/Purple/Detail/IAPProductStateView.swift b/damus/Views/Purple/Detail/IAPProductStateView.swift
@@ -21,20 +21,32 @@ extension DamusPurpleView {
let subscribe: (Product) async throws -> Void
@State var show_manage_subscriptions = false
+ @State var subscription_purchase_loading = false
var body: some View {
- switch self.products {
- case .failed:
- PurpleViewPrimitives.ProductLoadErrorView()
- case .loaded(let products):
- if let purchased {
- PurchasedView(purchased)
- } else {
- ProductsView(products)
- }
- case .loading:
+ if subscription_purchase_loading {
+ HStack(spacing: 10) {
+ Text(NSLocalizedString("Purchasing", comment: "Loading label indicating the purchase action is in progress"))
+ .foregroundStyle(.white)
ProgressView()
.progressViewStyle(.circular)
+ .tint(.white)
+ }
+ }
+ else {
+ switch self.products {
+ case .failed:
+ PurpleViewPrimitives.ProductLoadErrorView()
+ case .loaded(let products):
+ if let purchased {
+ PurchasedView(purchased)
+ } else {
+ ProductsView(products)
+ }
+ case .loading:
+ ProgressView()
+ .progressViewStyle(.circular)
+ }
}
}
@@ -107,7 +119,9 @@ extension DamusPurpleView {
Button(action: {
Task { @MainActor in
do {
+ subscription_purchase_loading = true
try await subscribe(product)
+ subscription_purchase_loading = false
} catch {
print(error.localizedDescription)
}