mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 02:26:21 +08:00
feat(ios): sync paywall with external purchased items (#13681)
This pull request introduces significant improvements to the integration between the paywall feature and the web context within the iOS app. The main focus is on enabling synchronization of subscription states between the app and the embedded web view, refactoring how purchased items are managed, and enhancing the paywall presentation logic. Additionally, some debug-only code has been removed for cleaner production builds. **Paywall and Web Context Integration** * Added support for binding a `WKWebView` context to the paywall, allowing the paywall to communicate with the web view for subscription state updates and retrievals (`Paywall.presentWall` now accepts a `bindWebContext` parameter, and `ViewModel` supports binding and using the web context). [[1]](diffhunk://#diff-bce0a21a4e7695b7bf2430cd6b8a85fbc84124cc3be83f3288119992b7abb6cdR10-R32) [[2]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0R54-R57) [[3]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0L26-R38) [[4]](diffhunk://#diff-1854d318d8fd8736d078f5960373ed440836263649a8193c8ee33e72a99424edL30-R36) * On paywall dismissal, the app now triggers a JavaScript call to update the subscription state in the web view, ensuring consistency between the app and the web context. **Purchased Items Refactor** * Refactored `ViewModel` to distinguish between store-purchased items and externally-purchased items (from the web context), and unified them in a computed `purchasedItems` property. This improves clarity and extensibility for handling entitlements from multiple sources. * Added logic to fetch external entitlements by executing JavaScript in the web view and decoding the subscription information, mapping external plans to internal product identifiers. [[1]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL99-R137) [[2]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbR169-R209) **Codebase Cleanup** * Removed debug-only code for shake gesture and debug menu from `AFFiNEViewController`, streamlining the production build. **API and Model Enhancements** * Made `SKUnitCategory` and its extensions public to allow broader usage across modules, and introduced a configuration struct for the paywall. [[1]](diffhunk://#diff-742ccf0c6bafd2db6cb9795382d556fbab90b8855ff38dc340aa39318541517dL10-R17) [[2]](diffhunk://#diff-bce0a21a4e7695b7bf2430cd6b8a85fbc84124cc3be83f3288119992b7abb6cdR10-R32) **Other Minor Improvements** * Improved constructor formatting for `PayWallPlugin` for readability. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Paywall now binds to the in-app web view so web-based subscriptions are recognized alongside App Store purchases. - Bug Fixes - Entitlements combine App Store and web subscription state for more accurate display. - Dismissing the paywall immediately updates subscription status to reduce stale states. - Improved reliability when presenting the paywall. - Chores - Removed debug shake menu and debug paywall options from iOS builds. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -74,29 +74,4 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
super.viewDidDisappear(animated)
|
||||
intelligentsButtonTimer?.invalidate()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||
if motion == .motionShake {
|
||||
showDebugMenu()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import AffinePaywall
|
||||
extension AFFiNEViewController {
|
||||
@objc private func showDebugMenu() {
|
||||
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "Pro")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "AI")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,7 +6,9 @@ import UIKit
|
||||
|
||||
@objc(PayWallPlugin)
|
||||
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
init(associatedController: UIViewController? = nil) {
|
||||
init(
|
||||
associatedController: UIViewController?
|
||||
) {
|
||||
controller = associatedController
|
||||
super.init()
|
||||
}
|
||||
@@ -27,7 +29,11 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// TODO: GET TO KNOW THE PAYWALL TYPE
|
||||
print("[*] showing paywall of type: \(type)")
|
||||
DispatchQueue.main.async {
|
||||
Paywall.presentWall(toController: controller, type: type)
|
||||
Paywall.presentWall(
|
||||
toController: controller,
|
||||
bindWebContext: self.webView,
|
||||
type: type
|
||||
)
|
||||
}
|
||||
|
||||
call.resolve(["success": true, "type": type])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -7,14 +7,14 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
||||
var id: Int { rawValue }
|
||||
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
||||
public var id: Int { rawValue }
|
||||
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
extension SKUnitCategory {
|
||||
public extension SKUnitCategory {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
|
||||
+75
-1
@@ -75,6 +75,21 @@ extension ViewModel {
|
||||
|
||||
func dismiss() {
|
||||
print(#function)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("updateSubscriptionState success")
|
||||
} catch {
|
||||
print("updateSubscriptionState error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
associatedController?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
@@ -96,12 +111,30 @@ nonisolated extension ViewModel {
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
let purchase = try await store.fetchEntitlements()
|
||||
await MainActor.run { self.purchasedItems = purchase }
|
||||
await MainActor.run { self.storePurchasedItems = purchase }
|
||||
} catch {
|
||||
print("fetchEntitlements error:", error)
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
do {
|
||||
guard let webView = await associatedWebContext else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let purchased = decodeWebContextSubscriptionInformation(result)
|
||||
print("fetched external purchased items:", purchased)
|
||||
await MainActor.run { self.externalPurchasedItems = purchased }
|
||||
} catch {
|
||||
print("fetchExternalEntitlements error:", error.localizedDescription)
|
||||
}
|
||||
|
||||
// select the package under purchased items if any
|
||||
let availablePackages = await availablePackageOptions
|
||||
let purchase = await purchasedItems
|
||||
@@ -133,4 +166,45 @@ nonisolated extension ViewModel {
|
||||
|
||||
await MainActor.run { self.updating = false }
|
||||
}
|
||||
|
||||
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
|
||||
var ans: Set<String> = []
|
||||
|
||||
guard let dict = input as? [String: Any] else {
|
||||
assertionFailure()
|
||||
return ans
|
||||
}
|
||||
|
||||
let pro = dict["pro"] as? [String: Any]
|
||||
let ai = dict["ai"] as? [String: Any]
|
||||
|
||||
if let proPlan = pro?["recurring"] as? String {
|
||||
switch proPlan.lowercased() {
|
||||
case "lifetime":
|
||||
// user actually purchased believer plan
|
||||
// but we map it to yearly plan just for easier handling
|
||||
// do not purchase any of this plan if already purchased
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
case "monthly":
|
||||
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
default:
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
if let aiPlan = ai?["recurring"] as? String {
|
||||
switch aiPlan.lowercased() {
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
|
||||
default:
|
||||
// ai plan can only be purchased as yearly plan
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -7,6 +7,7 @@
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
@@ -23,10 +24,18 @@ class ViewModel: ObservableObject {
|
||||
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var purchasedItems: Set<String> = []
|
||||
@Published var storePurchasedItems: Set<String> = []
|
||||
@Published var externalPurchasedItems: Set<String> = []
|
||||
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
|
||||
|
||||
var purchasedItems: Set<String> {
|
||||
Set<String>()
|
||||
.union(storePurchasedItems)
|
||||
.union(externalPurchasedItems)
|
||||
}
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
private(set) weak var associatedWebContext: WKWebView?
|
||||
|
||||
init() {
|
||||
updateAppStoreStatus(initial: true)
|
||||
@@ -42,6 +51,10 @@ class ViewModel: ObservableObject {
|
||||
associatedController = controller
|
||||
}
|
||||
|
||||
func bind(context: WKWebView) {
|
||||
associatedWebContext = context
|
||||
}
|
||||
|
||||
func select(category: SKUnitCategory) {
|
||||
self.category = category
|
||||
let units = SKUnit.units(for: category)
|
||||
|
||||
+3
@@ -7,14 +7,17 @@
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public enum Paywall {
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
bindWebContext context: WKWebView?,
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
if let context { viewModel.bind(context: context) }
|
||||
switch type.lowercased() {
|
||||
case "pro":
|
||||
viewModel.select(category: .pro)
|
||||
|
||||
Reference in New Issue
Block a user