damus

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

commit d694c26b83d4319eda1c487d8102361b028c389c
parent 2525799c8a334fa0267ad07a35205cda2f41baba
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Wed, 14 Feb 2024 21:31:59 +0000

iap: move StoreKit logic out of DamusPurpleView and into a new PurpleStoreKitManager

This commit moves most of StoreKit-specific logic that was embedded into
DamusPurpleView and places it into a new PurpleStoreKitManager struct,
to make code more reusable and readable by separating view concerns from
StoreKit-specific concerns.

Most of the code here should be in feature parity with the previous
behavior. However, a few logical improvements were made alongside this
refactoring:

- Improved StoreKit transaction update monitoring logic: Previously the
  view would stop listening for purchase updates after the first update.
  However, I made the program continuously listen for purchase updates,
  as recommended by Apple's documentation
  (https://developer.apple.com/documentation/storekit/transaction/3851206-updates)

- Improved/simplified logic around getting extra information from the
  products: Information and the handling of product information was
  spread in a few separate places. I incorporated those bits of
  information into central and uniform interfaces on DamusPurpleType, to
  simplify logic and future changes.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/Models/Purple/DamusPurple.swift | 7+++++++
Adamus/Models/Purple/PurpleStoreKitManager.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Purple/DamusPurpleView.swift | 51++++++++++-----------------------------------------
Mdamus/Views/Purple/Detail/IAPProductStateView.swift | 43++++++++++++++++---------------------------
5 files changed, 123 insertions(+), 68 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -442,6 +442,7 @@ D7100C582B76FC8400C59298 /* MarketingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C572B76FC8400C59298 /* MarketingContentView.swift */; }; D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; }; D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5B2B77016700C59298 /* IAPProductStateView.swift */; }; + D7100C5E2B7709ED00C59298 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; }; D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; }; D72341192B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; }; D723411A2B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; }; @@ -1344,6 +1345,7 @@ D7100C572B76FC8400C59298 /* MarketingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingContentView.swift; sourceTree = "<group>"; }; D7100C592B76FD5100C59298 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = "<group>"; }; D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = "<group>"; }; + D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = "<group>"; }; D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = "<group>"; }; D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleEnvironment.swift; sourceTree = "<group>"; }; D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = "<group>"; }; @@ -2670,6 +2672,7 @@ D74F430B2B23FB9B00425B75 /* StoreObserver.swift */, D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */, D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */, + D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */, ); path = Purple; sourceTree = "<group>"; @@ -3370,6 +3373,7 @@ 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */, 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */, 4C5D5C9A2A6AF8F80024563C /* NdbTagIterator.swift in Sources */, + D7100C5E2B7709ED00C59298 /* PurpleStoreKitManager.swift in Sources */, 4CE879502996B2BD00F758CC /* RelayStatusView.swift in Sources */, 4CC7AAF4297F18B400430951 /* ReplyDescription.swift in Sources */, 4C75EFA427FA577B0006080F /* PostView.swift in Sources */, diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift @@ -6,10 +6,12 @@ // import Foundation +import StoreKit class DamusPurple: StoreObserverDelegate { let settings: UserSettingsStore let keypair: Keypair + var storekit_manager: StoreKitManager @MainActor var account_cache: [Pubkey: Account] @@ -18,6 +20,7 @@ class DamusPurple: StoreObserverDelegate { self.settings = settings self.keypair = keypair self.account_cache = [:] + self.storekit_manager = .init() } // MARK: Functions @@ -123,6 +126,10 @@ class DamusPurple: StoreObserverDelegate { try await self.create_account(pubkey: pubkey) } + func make_iap_purchase(product: Product) async throws -> Product.PurchaseResult { + return try await self.storekit_manager.purchase(product: product) + } + func send_receipt() async { // Get the receipt if it's available. if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, diff --git a/damus/Models/Purple/PurpleStoreKitManager.swift b/damus/Models/Purple/PurpleStoreKitManager.swift @@ -0,0 +1,86 @@ +// +// PurpleStoreKitManager.swift +// damus +// +// Created by Daniel D’Aquino on 2024-02-09. +// + +import Foundation +import StoreKit + +extension DamusPurple { + struct StoreKitManager { + var delegate: DamusPurpleStoreKitManagerDelegate? = nil + + struct PurchasedProduct { + let tx: StoreKit.Transaction + let product: Product + } + + init() { + self.start() + } + + func start() { + Task { + try await monitor_updates() + } + } + + func get_products() async throws -> [Product] { + return try await Product.products(for: DamusPurpleType.allCases.map({ $0.rawValue })) + } + + private func monitor_updates() async throws { + for await update in StoreKit.Transaction.updates { + switch update { + case .verified(let tx): + let products = try await self.get_products() + let prod = products.filter({ prod in tx.productID == prod.id }).first + + if let prod, + let expiration = tx.expirationDate, + Date.now < expiration + { + self.delegate?.product_was_purchased(product: PurchasedProduct(tx: tx, product: prod)) + } + case .unverified: + continue + } + } + } + + func purchase(product: Product) async throws -> Product.PurchaseResult { + return try await product.purchase(options: []) + } + } +} + +extension DamusPurple.StoreKitManager { + enum DamusPurpleType: String, CaseIterable { + case yearly = "purpleyearly" + case monthly = "purple" + + func non_discounted_price(product: Product) -> String? { + switch self { + case .yearly: + return (product.price * 1.1984569224).formatted(product.priceFormatStyle) + case .monthly: + return nil + } + } + + func label() -> String { + switch self { + case .yearly: + return NSLocalizedString("Annually", comment: "Annual renewal of purple subscription") + case .monthly: + return NSLocalizedString("Monthly", comment: "Monthly renewal of purple subscription") + } + } + } +} + +protocol DamusPurpleStoreKitManagerDelegate { + func product_was_purchased(product: DamusPurple.StoreKitManager.PurchasedProduct) +} diff --git a/damus/Views/Purple/DamusPurpleView.swift b/damus/Views/Purple/DamusPurpleView.swift @@ -8,8 +8,6 @@ import SwiftUI import StoreKit -fileprivate let damus_products = ["purpleyearly","purple"] - // MARK: - Helper structures enum AccountInfoState { @@ -19,25 +17,16 @@ enum AccountInfoState { case error(message: String) } -func non_discounted_price(_ product: Product) -> String { - return (product.price * 1.1984569224).formatted(product.priceFormatStyle) -} - -enum DamusPurpleType: String { - case yearly = "purpleyearly" - case monthly = "purple" -} - // MARK: - Main view -struct DamusPurpleView: View { +struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { let damus_state: DamusState let keypair: Keypair @State var my_account_info_state: AccountInfoState = .loading @State var products: ProductState @State var purchased: PurchasedProduct? = nil - @State var selection: DamusPurpleType = .yearly + @State var selection: DamusPurple.StoreKitManager.DamusPurpleType = .yearly @State var show_welcome_sheet: Bool = false @State var show_manage_subscriptions = false @State private var shouldDismissView = false @@ -48,6 +37,7 @@ struct DamusPurpleView: View { self._products = State(wrappedValue: .loading) self.damus_state = damus_state self.keypair = damus_state.keypair + damus_state.purple.storekit_manager.delegate = self } // MARK: - Top level view @@ -159,30 +149,10 @@ struct DamusPurpleView: View { } } - func handle_transactions(products: [Product]) async { - for await update in StoreKit.Transaction.updates { - switch update { - case .verified(let tx): - let prod = products.filter({ prod in tx.productID == prod.id }).first - - if let prod, - let expiration = tx.expirationDate, - Date.now < expiration - { - self.purchased = PurchasedProduct(tx: tx, product: prod) - break - } - case .unverified: - continue - } - } - } - func load_products() async { do { - let products = try await Product.products(for: damus_products) + let products = try await self.damus_state.purple.storekit_manager.get_products() self.products = .loaded(products) - await handle_transactions(products: products) print("loaded products", products) } catch { @@ -191,8 +161,13 @@ struct DamusPurpleView: View { } } + // For DamusPurple.StoreKitManager.Delegate conformance. This gets called by the StoreKitManager when a new product was purchased + func product_was_purchased(product: DamusPurple.StoreKitManager.PurchasedProduct) { + self.purchased = product + } + func subscribe(_ product: Product) async throws { - let result = try await product.purchase() + let result = try await self.damus_state.purple.make_iap_purchase(product: product) switch result { case .success(.verified(let tx)): print("success \(tx.debugDescription)") @@ -219,12 +194,6 @@ struct DamusPurpleView: View { break } } - - var product: Product? { - return self.products.products?.filter({ - prod in prod.id == selection.rawValue - }).first - } } struct DamusPurpleView_Previews: PreviewProvider { diff --git a/damus/Views/Purple/Detail/IAPProductStateView.swift b/damus/Views/Purple/Detail/IAPProductStateView.swift @@ -11,6 +11,8 @@ import StoreKit // MARK: - IAPProductStateView extension DamusPurpleView { + typealias PurchasedProduct = DamusPurple.StoreKitManager.PurchasedProduct + struct IAPProductStateView: View { let products: ProductState let purchased: PurchasedProduct? @@ -82,28 +84,20 @@ extension DamusPurpleView { } func price_description(product: Product) -> some View { - if product.id == "purpleyearly" { - return ( - AnyView( - HStack(spacing: 10) { - Text(NSLocalizedString("Annually", comment: "Annual renewal of purple subscription")) - Spacer() - Text(verbatim: non_discounted_price(product)).strikethrough().foregroundColor(DamusColors.white.opacity(0.5)) - Text(verbatim: product.displayPrice).fontWeight(.bold) - } - ) - ) - } else { - return ( - AnyView( - HStack(spacing: 10) { - Text(NSLocalizedString("Monthly", comment: "Monthly renewal of purple subscription")) - Spacer() - Text(verbatim: product.displayPrice).fontWeight(.bold) - } - ) - ) - } + let purple_type = DamusPurple.StoreKitManager.DamusPurpleType(rawValue: product.id) + return ( + HStack(spacing: 10) { + Text(purple_type?.label() ?? product.displayName) + Spacer() + if let non_discounted_price = purple_type?.non_discounted_price(product: product) { + Text(verbatim: non_discounted_price) + .strikethrough() + .foregroundColor(DamusColors.white.opacity(0.5)) + } + Text(verbatim: product.displayPrice) + .fontWeight(.bold) + } + ) } } } @@ -127,11 +121,6 @@ extension DamusPurpleView { } } } - - struct PurchasedProduct { - let tx: StoreKit.Transaction - let product: Product - } } #Preview {