Damus Purple initial Proof-of-Concept support

This commit includes various code changes necessary to get a basic proof of concept of the feature working.

This is NOT a full working feature yet, only a preliminary prototype/PoC. It includes:
- [X] Basic Storekit configuration
- [X] Basic purchase mechanism
- [X] Basic layout and copywriting
- [X] Basic design
- [X] Manage button (To help user cancel their subscription)
- [X] Thank you confirmation + special welcome view
- [X] Star badge on profile (by checking the Damus Purple API)
- [X] Connection to Damus purple API for fetching account info, registering for an account and sending over the App Store receipt data

The feature sits behind a feature flag which is OFF by default (it can be turned ON via Settings --> Developer settings --> Enable experimental Purple API and restarting the app)

Testing
---------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
damus-api: 59ce44a92cff1c1aaed9886f9befbd5f1053821d
Server: Ubuntu 22.04 (VM)
Setup:
1. On the server, delete the `mdb` database files to start from scratch
2. In iOS, reinstall the app if necessary to make sure there are no in-app purchases
3. Enable subscriptions support via developer settings with localhost test mode and restart app
4. Start server with mock parameters (Run `npm run dev`)

Steps:
1. Open top bar and click on "Purple"
2. Purple screen should appear and show both benefits and the purchase options. PASS
3. Click on "monthly". An Apple screen to confirm purchase should appear. PASS
4. Welcome screen with animation should appear. PASS
5. Click continue and restart app (Due to known issue tracked at https://github.com/damus-io/damus/issues/1814)
6. Post something
7. Gold star should appear beside your name
8. Look at the server logs. There should be some requests to create the account (POST), to send the receipt (POST), and to get account status
9. Go to purple view. There should be some information about the subscription, as well as a "manage" button. PASS
10. Click on "manage" button. An iOS sheet should appear allow the user to unsubscribe or manage their subscription to Damus Purple.

Feature flag testing
--------------------

PASS

Preconditions: Continue from above test
Steps:
1. Disable Damus Purple experiment support on developer settings. Restart the app.
2. Check your post. There should be no star beside your profile name. PASS
3. Check side menu. There should be no "Damus Purple" option. PASS
4. Check server logs. There should be no new requests being done to the server. PASS

Closes: https://github.com/damus-io/damus/issues/1422
This commit is contained in:
Daniel D’Aquino
2023-03-22 07:24:34 -06:00
committed by William Casarin
parent f7e407e030
commit 4703ed80a7
35 changed files with 1170 additions and 19 deletions

View File

@@ -34,6 +34,39 @@ struct DamusState {
let music: MusicController?
let video: VideoController
let ndb: Ndb
var purple: DamusPurple
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [String], replies: ReplyCounter, muted_threads: MutedThreadsManager, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil) {
self.pool = pool
self.keypair = keypair
self.likes = likes
self.boosts = boosts
self.contacts = contacts
self.profiles = profiles
self.dms = dms
self.previews = previews
self.zaps = zaps
self.lnurls = lnurls
self.settings = settings
self.relay_filters = relay_filters
self.relay_model_cache = relay_model_cache
self.drafts = drafts
self.events = events
self.bookmarks = bookmarks
self.postbox = postbox
self.bootstrap_relays = bootstrap_relays
self.replies = replies
self.muted_threads = muted_threads
self.wallet = wallet
self.nav = nav
self.music = music
self.video = video
self.ndb = ndb
self.purple = purple ?? DamusPurple(
environment: settings.purple_api_local_test_mode ? .local_test : .production,
keypair: keypair
)
}
@discardableResult
func add_zap(zap: Zapping) -> Bool {

View File

@@ -0,0 +1,134 @@
//
// DamusPurple.swift
// damus
//
// Created by Daniel DAquino on 2023-12-08.
//
import Foundation
class DamusPurple: StoreObserverDelegate {
let environment: ServerEnvironment
let keypair: Keypair
var starred_profiles_cache: [Pubkey: Bool]
init(environment: ServerEnvironment, keypair: Keypair) {
self.environment = environment
self.keypair = keypair
self.starred_profiles_cache = [:]
}
// MARK: Functions
func is_profile_subscribed_to_purple(pubkey: Pubkey) async -> Bool? {
if let cached_result = self.starred_profiles_cache[pubkey] {
return cached_result
}
guard let data = await self.get_account_data(pubkey: pubkey) else { return nil }
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let active = json["active"] as? Bool {
self.starred_profiles_cache[pubkey] = active
return active
}
return nil
}
func account_exists(pubkey: Pubkey) async -> Bool? {
guard let account_data = await self.get_account_data(pubkey: pubkey) else { return nil }
if let json = try? JSONSerialization.jsonObject(with: account_data, options: []) as? [String: Any],
let id = json["id"] as? String {
return id == pubkey.hex()
}
return false
}
func get_account_data(pubkey: Pubkey) async -> Data? {
let url = environment.get_base_url().appendingPathComponent("accounts/\(pubkey.hex())")
var request = URLRequest(url: url)
request.httpMethod = "GET"
do {
let (data, _) = try await URLSession.shared.data(for: request)
return data
} catch {
print("Failed to fetch data: \(error)")
}
return nil
}
func create_account(pubkey: Pubkey) async throws {
let url = environment.get_base_url().appendingPathComponent("accounts")
var request = URLRequest(url: url)
request.httpMethod = "POST"
let payload: [String: String] = [
"pubkey": pubkey.hex()
]
request.httpBody = try JSONEncoder().encode(payload)
do {
let (_, _) = try await URLSession.shared.data(for: request)
return
} catch {
print("Failed to fetch data: \(error)")
}
return
}
func create_account_if_not_existing(pubkey: Pubkey) async throws {
guard await !(self.account_exists(pubkey: pubkey) ?? false) else { return }
try await self.create_account(pubkey: pubkey)
}
func send_receipt() async {
// Get the receipt if it's available.
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
try? await create_account_if_not_existing(pubkey: keypair.pubkey)
do {
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
print(receiptData)
let url = environment.get_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/app-store-receipt")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = receiptData
do {
let (_, _) = try await URLSession.shared.data(for: request)
print("Sent receipt")
} catch {
print("Failed to fetch data: \(error)")
}
}
catch { print("Couldn't read receipt data with error: " + error.localizedDescription) }
}
}
}
// MARK: Helper structures
extension DamusPurple {
enum ServerEnvironment {
case local_test
case production
func get_base_url() -> URL {
switch self {
case .local_test:
Constants.PURPLE_API_TEST_BASE_URL
case .production:
Constants.PURPLE_API_PRODUCTION_BASE_URL
}
}
}
}

View File

@@ -0,0 +1,33 @@
//
// StoreObserver.swift
// damus
//
// Created by Daniel DAquino on 2023-12-08.
//
import Foundation
import StoreKit
class StoreObserver: NSObject, SKPaymentTransactionObserver {
static let standard = StoreObserver()
var delegate: StoreObserverDelegate?
init(delegate: StoreObserverDelegate? = nil) {
self.delegate = delegate
super.init()
}
//Observe transaction updates.
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
//Handle transaction states here.
Task {
await self.delegate?.send_receipt()
}
}
}
protocol StoreObserverDelegate {
func send_receipt() async
}

View File

@@ -201,6 +201,12 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "send_device_token_to_localhost", default_value: false)
var send_device_token_to_localhost: Bool
@Setting(key: "enable_experimental_purple_api", default_value: false)
var enable_experimental_purple_api: Bool
@Setting(key: "purple_api_local_test_mode", default_value: false)
var purple_api_local_test_mode: Bool
@Setting(key: "emoji_reactions", default_value: default_emoji_reactions)
var emoji_reactions: [String]