Files
damus/damus/Views/Purple/DamusPurpleAccountView.swift
T
Daniel D’Aquino 4a4a58c7b5 iap: handle login and logout
This commit adapts the functionality around login/logout with relation
to Damus Purple In-App purchases (IAP). Due to (apparent) limitations on
Renewable subscription In-app purchases (It seems that there can only be
one active IAP subscription per device or Apple ID), these changes add
support for only one IAP subscription at a time.

To prevent confusion, a customer who logs out and logs into a separate
account will see a message indicating the limitation. Any other Nostr
account won't be able to manage IAP on a device that contains an IAP
registered to a different user.

To make this feature possible, the following changes were made to the
code:

1. IAP purchases are now associated with an account UUID. This account
   UUID is generated by the server. Each npub gets one and only one UUID
   for this purpose.

2. This UUID is used to determine which npub owns the IAP on the device.
   It is used as the source of truth when determining whether a
   particular Purple account is manageable on a device or not

3. `DamusPurple` was changed to adhere to a new IAP flow API design
   changes. Previously, the client would create an (inactive) account,
   and then send the IAP receipt to the server for activation. Now, the
   client fetches the npub's UUID from the server, associates it with an
   IAP during purchase, and sends the IAP receipt to the server. The
   server will then bump the expiry (if it's a renewal) or create a new
   active account (if it's the first time).

4. Several changes were made to the StoreKit handling code to improve
   handling:

  a. The `DamusPurple.StoreKitManager` class now records all purchased
     product updates, and sends them to the delegate each time the
     delegate is updated. This helps ensure we do not miss purchased
     product updates regardless of when and if `DamusPurpleView` is ever
     instantiated.

  b. `DamusPurple.StoreKitManager` is now used by `DamusPurple` in a
     singleton pattern via `DamusPurple.StoreKitManager.standard`. This
     helps maintain the local purchase history consistent (and avoid
     losing such data) after `DamusState` or its `DamusPurple` are
     destroyed and re-initialized.

  c. Added logs (using the logger) to help us debug/troubleshoot
     problems in the future

5. Changed the views around DamusPurple, to only show IAP
   purchase/management options if applicable to a particular account. It
   also shows instructive messages in other scenarios.

Testing
-------

damus: This commit
damus-api: d3956ee004a358a39c8570fdbd481d2f5f6f94ab
Device: iPhone 15 simulator
iOS: 17.2
Setup:

- Xcode (local) StoreKit environment
- All StoreKit transactions deleted before starting
- Running `damus` app target (which contains test StoreKit products)

- Local damus-api server running with `npm run dev` and
  `MOCK_VERIFY=true` to disable real receipt verification

- Damus setup with experimental IAP support enabled, and Purple
  environment set to "Test (local)" (localhost)

- Two `nsecs` readily available for account switching
- Clean DB (Delete db files before starting)

Steps:
1. Open the app and sign in to the first account

2. Go to Damus Purple screen. Marketing screen with buttons to purchase
   products should be visible. PASS

3. Buy a product and monitor server logs as well as the screen.
  a. IAP confirmation dialog should appear. PASS

  b. After confirmation, server logs should show a receipt was sent
     IMMEDIATELY and the response should be an HTTP 200. PASS

  c. The welcome and onboarding screens should appear as normal. PASS

  d. Once the onboarding sheet goes away, the Purple screen should now
     show the account information. PASS

  e. The account information should be correct. PASS

  f. Under the account information, there should be a "manage" button. PASS

4. Click on "manage" and verify that the iOS subscription management
   screen appears. PASS

5. Now log out and sign in to the second account

6. Go to Damus Purple screen.
  a. Marketing screen should be visible. PASS

  b. There should be no purchase buttons. instead, there should be a
     message indicating that there can only be one active subscription
     at a time, and that the app is unable to manage subscription for
     this second acocunt. PASS

7. Log out and sign in to the first account. Go to the Purple screen.
  a. Account info with the manage button should be visible like before. PASS

8. Through Xcode, delete transactions, and restart the app. This will
   simulate the case where the user bought the subscription externally.

9. Go to the Purple screen.
  a. Account info should be visible and correct. PASS
  b. Below the account info, there should be a small note telling the
     user to visit the website to manage their billing. PASS

Closes: https://github.com/damus-io/damus/issues/1815
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2024-02-19 10:38:59 -08:00

154 lines
5.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// DamusPurpleAccountView.swift
// damus
//
// Created by Daniel DAquino on 2024-01-26.
//
import SwiftUI
struct DamusPurpleAccountView: View {
var colorScheme: ColorScheme = .dark
let damus_state: DamusState
let account: DamusPurple.Account
let pfp_size: CGFloat = 90.0
var body: some View {
VStack {
ProfilePicView(pubkey: account.pubkey, size: pfp_size, highlight: .custom(Color.black.opacity(0.4), 1.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
.background(Color.black.opacity(0.4).clipShape(Circle()))
.shadow(color: .black, radius: 10, x: 0.0, y: 5)
profile_name
if account.active {
active_account_badge
}
else {
inactive_account_badge
}
// TODO: Generalize this view instead of setting up dividers and paddings manually
VStack {
HStack {
Text(NSLocalizedString("Expiry date", comment: "Label for Purple subscription expiry date"))
Spacer()
Text(DateFormatter.localizedString(from: account.expiry, dateStyle: .short, timeStyle: .none))
}
.padding(.horizontal)
.padding(.top, 20)
Divider()
.padding(.horizontal)
.padding(.vertical, 10)
HStack {
Text(NSLocalizedString("Account creation", comment: "Label for Purple account creation date"))
Spacer()
Text(DateFormatter.localizedString(from: account.created_at, dateStyle: .short, timeStyle: .none))
}
.padding(.horizontal)
Divider()
.padding(.horizontal)
.padding(.vertical, 10)
HStack {
Text(NSLocalizedString("Subscriber number", comment: "Label for Purple account subscriber number"))
Spacer()
Text(verbatim: "#\(account.subscriber_number)")
}
.padding(.horizontal)
.padding(.bottom, 20)
}
.foregroundColor(.white.opacity(0.8))
.preferredColorScheme(.dark)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
}
}
var profile_name: some View {
let display_name = self.profile_display_name()
return HStack(alignment: .center, spacing: 5) {
Text(display_name)
.font(.title)
.bold()
.foregroundStyle(.white)
SupporterBadge(
percent: nil,
purple_account: account,
style: .full
)
}
}
var active_account_badge: some View {
HStack(spacing: 3) {
Image("check-circle.fill")
.resizable()
.frame(width: 15, height: 15)
Text(NSLocalizedString("Active account", comment: "Badge indicating user has an active Damus Purple account"))
.font(.caption)
.bold()
}
.foregroundColor(Color.white)
.padding(.vertical, 3)
.padding(.horizontal, 8)
.background(PinkGradient)
.cornerRadius(30.0)
}
var inactive_account_badge: some View {
HStack(spacing: 3) {
Image("warning")
.resizable()
.frame(width: 15, height: 15)
Text(NSLocalizedString("Expired account", comment: "Badge indicating user has an expired Damus Purple account"))
.font(.caption)
.bold()
}
.foregroundColor(DamusColors.danger)
.padding(.vertical, 3)
.padding(.horizontal, 8)
.background(DamusColors.dangerTertiary)
.cornerRadius(30.0)
}
func profile_display_name() -> String {
let profile_txn: NdbTxn<ProfileRecord?>? = damus_state.profiles.lookup_with_timestamp(account.pubkey)
let profile: NdbProfile? = profile_txn?.unsafeUnownedValue?.profile
let display_name = parse_display_name(profile: profile, pubkey: account.pubkey).displayName
return display_name
}
}
#Preview("Active") {
DamusPurpleAccountView(
damus_state: test_damus_state,
account: DamusPurple.Account(
pubkey: test_pubkey,
created_at: Date.now,
expiry: Date.init(timeIntervalSinceNow: 60 * 60 * 24 * 30),
subscriber_number: 7,
active: true
)
)
}
#Preview("Expired") {
DamusPurpleAccountView(
damus_state: test_damus_state,
account: DamusPurple.Account(
pubkey: test_pubkey,
created_at: Date.init(timeIntervalSinceNow: -60 * 60 * 24 * 37),
expiry: Date.init(timeIntervalSinceNow: -60 * 60 * 24 * 7),
subscriber_number: 7,
active: false
)
)
}