damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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:
Mdamus/ContentView.swift | 45+++++++++++++++++++++++++++++++++++++++------
Mdamus/Models/Purple/DamusPurple.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Purple/DamusPurpleVerifyNpubView.swift | 1+
Mdamus/Views/Purple/Detail/IAPProductStateView.swift | 34++++++++++++++++++++++++----------
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) }