damus

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

commit 75d87fee9d1371d75bb4652aba48fab3ed768eed
parent 55000e9d4d363bfe7d3cadfce7249b6bf8f2e5d3
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 29 Feb 2024 03:10:49 -0800

Merge tag 'v1.7-rc2'

v1.7 Madeira release RC2

55c26d22cb0b v1.7 (11)
4c8134908cfc Enable IAP feature for release
3ac7d75235f3 Add UI error message when IAP succeeds but receipt verification fails
b49a5f4d2937 Purple: Improve UX on Damus Purple renewals
3569919eafe1 Add Damus Purple impending expiry notification support

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 22++++++++++++++++++++++
Mdamus/ContentView.swift | 16+++++++++++++---
Mdamus/Models/HomeModel.swift | 9+++++++++
Mdamus/Models/NewEventsBits.swift | 3++-
Mdamus/Models/NotificationsModel.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/Purple/DamusPurple.swift | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Adamus/Models/Purple/Extensions/DamusPurpleNotificationManagement.swift | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/Purple/StoreObserver.swift | 4++--
Adamus/Views/Notifications/DamusAppNotificationView.swift | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Notifications/NotificationItemView.swift | 7+++++++
Mdamus/Views/Purple/DamusPurpleNewUserOnboardingView.swift | 2++
Mdamus/Views/Purple/DamusPurpleView.swift | 6++++++
AdamusTests/DamusPurpleImpendingExpirationTests.swift | 37+++++++++++++++++++++++++++++++++++++
13 files changed, 501 insertions(+), 39 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -492,6 +492,7 @@ D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; }; + D78CD5982B8990300014D539 /* DamusAppNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */; }; D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; }; D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; }; @@ -545,6 +546,8 @@ D7CB5D5D2B1176B200AD4105 /* MediaUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5B2B1176B200AD4105 /* MediaUploader.swift */; }; D7CB5D5F2B11770C00AD4105 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5E2B11770C00AD4105 /* FollowState.swift */; }; D7CB5D602B11770C00AD4105 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5E2B11770C00AD4105 /* FollowState.swift */; }; + D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */; }; + D7CBD1D62B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */; }; D7CCFC072B05833200323D86 /* NdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90548A2A6AEDEE00811EEC /* NdbNote.swift */; }; D7CCFC082B05834500323D86 /* NoteId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC14FF42A740BB7007AEB17 /* NoteId.swift */; }; D7CCFC0B2B0585EA00323D86 /* nostrdb.c in Sources */ = {isa = PBXBuildFile; fileRef = 4CE9FBB82A6B3B26007E485C /* nostrdb.c */; settings = {COMPILER_FLAGS = "-w"; }; }; @@ -1396,6 +1399,7 @@ D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; }; + D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; }; D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; }; D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; }; D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; }; @@ -1414,6 +1418,8 @@ D7CB5D502B1174D100AD4105 /* FriendFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendFilter.swift; sourceTree = "<group>"; }; D7CB5D5B2B1176B200AD4105 /* MediaUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploader.swift; sourceTree = "<group>"; }; D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; }; + D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = "<group>"; }; + D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = "<group>"; }; D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; }; D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = "<group>"; }; D7EDED1D2B11797D0018B19C /* LongformEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformEvent.swift; sourceTree = "<group>"; }; @@ -1732,6 +1738,7 @@ 4C30AC7329A5680900E2BD5A /* EventGroupView.swift */, 4C30AC7529A5770900E2BD5A /* NotificationItemView.swift */, 4C30AC7F29A6A53F00E2BD5A /* ProfilePicturesView.swift */, + D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */, ); path = Notifications; sourceTree = "<group>"; @@ -2535,6 +2542,7 @@ B501062C2B363036003874F5 /* AuthIntegrationTests.swift */, E0E024102B7C19C20075735D /* TranslationTests.swift */, E06336A92B75832100A88E6B /* ImageMetadataTest.swift */, + D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */, ); path = damusTests; sourceTree = "<group>"; @@ -2705,6 +2713,7 @@ D74F43082B23F09300425B75 /* Purple */ = { isa = PBXGroup; children = ( + D7CBD1D22B8D21C100BFD889 /* Extensions */, D74F43092B23F0BE00425B75 /* DamusPurple.swift */, D74F430B2B23FB9B00425B75 /* StoreObserver.swift */, D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */, @@ -2734,6 +2743,14 @@ path = Assets; sourceTree = "<group>"; }; + D7CBD1D22B8D21C100BFD889 /* Extensions */ = { + isa = PBXGroup; + children = ( + D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */, + ); + path = Extensions; + sourceTree = "<group>"; + }; F71694E82A66221E001F4053 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -3031,6 +3048,7 @@ 4CDD1AE22A6B3074001CD4DF /* NdbTagsIterator.swift in Sources */, 4C216F34286F5ACD00040376 /* DMView.swift in Sources */, D7CB5D512B1174D100AD4105 /* FriendFilter.swift in Sources */, + D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */, 4C32B9572A9AD44700DC3548 /* Root.swift in Sources */, 4C3EA64428FF558100C48A62 /* sha256.c in Sources */, 504323A72A34915F006AE6DC /* RelayModel.swift in Sources */, @@ -3305,6 +3323,7 @@ 4CB88396296F7F8B00DC99E7 /* ReactionView.swift in Sources */, 50A16FFD2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift in Sources */, 4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */, + D78CD5982B8990300014D539 /* DamusAppNotificationView.swift in Sources */, D724D8272B64B40B00ABE789 /* DamusPurpleAccountView.swift in Sources */, 4C8682872814DE470026224F /* ProfileView.swift in Sources */, 5C0707D12A1ECB38004E7B51 /* DamusLogoGradient.swift in Sources */, @@ -3475,6 +3494,7 @@ 3A3040ED29A5CB86008A0F29 /* ReplyDescriptionTests.swift in Sources */, D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */, 3AAC7A022A60FE72002B50DF /* LocalizationUtilTests.swift in Sources */, + D7CBD1D62B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift in Sources */, D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */, 4C8D00D429E3C5D40036AF10 /* NIP19Tests.swift in Sources */, 3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */, @@ -3931,6 +3951,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; @@ -3980,6 +4001,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -490,17 +490,24 @@ struct ContentView: View { // 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 + if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() { + // Show welcome sheet + self.active_sheet = .purple_onboarding + } + else { + self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing)) + } } } } } + Task { + await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification) + } } .onChange(of: scenePhase) { (phase: ScenePhase) in guard let damus_state else { return } @@ -724,6 +731,9 @@ struct ContentView: View { if let damus_state, damus_state.purple.enable_purple { // Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases StoreObserver.standard.delegate = damus_state.purple + Task { + await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification) + } } else { // Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -276,6 +276,15 @@ class HomeModel { } + @MainActor + func handle_damus_app_notification(_ notification: DamusAppNotification) async { + if self.notifications.insert_app_notification(notification: notification) { + // If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits + // This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification + self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue) + } + } + func filter_events() { events.filter { ev in !damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil)) diff --git a/damus/Models/NewEventsBits.swift b/damus/Models/NewEventsBits.swift @@ -17,7 +17,8 @@ struct NewEventsBits: OptionSet { static let likes = NewEventsBits(rawValue: 1 << 4) static let search = NewEventsBits(rawValue: 1 << 5) static let dms = NewEventsBits(rawValue: 1 << 6) + static let damus_app_notifications = NewEventsBits(rawValue: 1 << 7) static let all = NewEventsBits(rawValue: 0xFFFFFFFF) - static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions] + static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions, .damus_app_notifications] } diff --git a/damus/Models/NotificationsModel.swift b/damus/Models/NotificationsModel.swift @@ -13,6 +13,7 @@ enum NotificationItem { case profile_zap(ZapGroup) case event_zap(NoteId, ZapGroup) case reply(NostrEvent) + case damus_app_notification(DamusAppNotification) var is_reply: NostrEvent? { if case .reply(let ev) = self { @@ -33,6 +34,8 @@ enum NotificationItem { return nil case .repost: return nil + case .damus_app_notification(_): + return nil } } @@ -48,6 +51,8 @@ enum NotificationItem { return zapgrp.last_event_at case .reply(let reply): return reply.created_at + case .damus_app_notification(let notification): + return notification.last_event_at } } @@ -63,6 +68,8 @@ enum NotificationItem { return zapgrp.would_filter(isIncluded) case .reply(let ev): return !isIncluded(ev) + case .damus_app_notification(_): + return true } } @@ -79,6 +86,8 @@ enum NotificationItem { case .reply(let ev): if isIncluded(ev) { return .reply(ev) } return nil + case .damus_app_notification(_): + return self } } } @@ -94,6 +103,9 @@ class NotificationsModel: ObservableObject, ScrollQueue { var reactions: [NoteId: EventGroup] = [:] var reposts: [NoteId: EventGroup] = [:] var replies: [NostrEvent] = [] + var incoming_app_notifications: [DamusAppNotification] = [] + var app_notifications: [DamusAppNotification] = [] + var has_app_notification = Set<DamusAppNotification.Content>() var has_reply = Set<NoteId>() var has_ev = Set<NoteId>() @@ -160,6 +172,10 @@ class NotificationsModel: ObservableObject, ScrollQueue { notifs.append(.reply(reply)) } + for app_notification in app_notifications { + notifs.append(.damus_app_notification(app_notification)) + } + notifs.sort { $0.last_event_at > $1.last_event_at } return notifs } @@ -254,6 +270,33 @@ class NotificationsModel: ObservableObject, ScrollQueue { return false } + func insert_app_notification(notification: DamusAppNotification) -> Bool { + if has_app_notification.contains(notification.content) { + return false + } + + if should_queue { + incoming_app_notifications.append(notification) + return true + } + + if insert_app_notification_immediate(notification: notification) { + self.notifications = build_notifications() + return true + } + + return false + } + + func insert_app_notification_immediate(notification: DamusAppNotification) -> Bool { + if has_app_notification.contains(notification.content) { + return false + } + self.app_notifications.append(notification) + has_app_notification.insert(notification.content) + return true + } + func insert_zap(_ zap: Zapping) -> Bool { if should_queue { return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap) @@ -319,6 +362,10 @@ class NotificationsModel: ObservableObject, ScrollQueue { inserted = insert_event_immediate(event, cache: damus_state.events) || inserted } + for incoming_app_notification in incoming_app_notifications { + inserted = insert_app_notification_immediate(notification: incoming_app_notification) || inserted + } + if inserted { self.notifications = build_notifications() } @@ -326,3 +373,19 @@ class NotificationsModel: ObservableObject, ScrollQueue { return inserted } } + +struct DamusAppNotification { + let notification_timestamp: Date + var last_event_at: UInt32 { UInt32(notification_timestamp.timeIntervalSince1970) } + let content: Content + + init(content: Content, timestamp: Date) { + self.notification_timestamp = timestamp + self.content = content + } + + enum Content: Hashable, Equatable { + case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64) + case purple_expired(expiry_date: UInt64) + } +} diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift @@ -13,6 +13,7 @@ class DamusPurple: StoreObserverDelegate { let keypair: Keypair var storekit_manager: StoreKitManager var checkout_ids_in_progress: Set<String> = [] + var onboarding_status: OnboardingStatus @MainActor var account_cache: [Pubkey: Account] @@ -25,6 +26,16 @@ class DamusPurple: StoreObserverDelegate { self.account_cache = [:] self.account_uuid_cache = [:] self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data + self.onboarding_status = OnboardingStatus() + Task { + let account: Account? = try await self.fetch_account(pubkey: self.keypair.pubkey) + if account == nil { + self.onboarding_status.account_existed_at_the_start = false + } + else { + self.onboarding_status.account_existed_at_the_start = true + } + } } // MARK: Functions @@ -45,7 +56,8 @@ class DamusPurple: StoreObserverDelegate { // Whether to enable Apple In-app purchase support var enable_purple_iap_support: Bool { // TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag) - return self.settings.enable_experimental_purple_iap_support + // return self.settings.enable_experimental_purple_iap_support + return true } func account_exists(pubkey: Pubkey) async -> Bool? { @@ -109,7 +121,7 @@ class DamusPurple: StoreObserverDelegate { // During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted. self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product)) // Send the receipt to the server - await self.send_receipt() + try await self.send_receipt() default: // Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error. throw PurpleError.iap_purchase_error(result: result) @@ -150,42 +162,37 @@ class DamusPurple: StoreObserverDelegate { return account_uuid_info.account_uuid } - func send_receipt() async { + func send_receipt() async throws { // Get the receipt if it's available. if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { - do { - let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) - let receipt_base64_string = receiptData.base64EncodedString() - let account_uuid = try await self.get_maybe_cached_uuid_for_account() - let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString] - let json_data = try JSONSerialization.data(withJSONObject: json_text) - - let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt") - - Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple) - - let (data, response) = try await make_nip98_authenticated_request( - method: .post, - url: url, - payload: json_data, - payload_type: .json, - auth_keypair: self.keypair - ) - - if let httpResponse = response as? HTTPURLResponse { - switch httpResponse.statusCode { - case 200: - Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple) - default: - Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") - } + let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) + let receipt_base64_string = receiptData.base64EncodedString() + let account_uuid = try await self.get_maybe_cached_uuid_for_account() + let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString] + let json_data = try JSONSerialization.data(withJSONObject: json_text) + + let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt") + + Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple) + + let (data, response) = try await make_nip98_authenticated_request( + method: .post, + url: url, + payload: json_data, + payload_type: .json, + auth_keypair: self.keypair + ) + + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple) + default: + Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") + throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data) } - - } - catch { - Log.error("Couldn't read receipt data with error: %s", for: .damus_purple, error.localizedDescription) } } } @@ -270,6 +277,44 @@ class DamusPurple: StoreObserverDelegate { } @MainActor + func new_ln_checkout(product_template_name: String) async throws -> LNCheckoutInfo? { + let url = environment.api_base_url().appendingPathComponent("ln-checkout") + + let json_text: [String: String] = ["product_template_name": product_template_name] + let json_data = try JSONSerialization.data(withJSONObject: json_text) + + let (data, response) = try await make_nip98_authenticated_request( + method: .post, + url: url, + payload: json_data, + payload_type: .json, + 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 + func generate_verified_ln_checkout_link(product_template_name: String) async throws -> URL { + let checkout = try await self.new_ln_checkout(product_template_name: product_template_name) + guard let checkout_id = checkout?.id.uuidString.lowercased() else { throw PurpleError.error_processing_response } + try await self.verify_npub_for_checkout(checkout_id: checkout_id) + return self.environment.purple_landing_page_url() + .appendingPathComponent("checkout") + .appending(queryItems: [URLQueryItem(name: "id", value: checkout_id)]) + } + + @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" @@ -404,6 +449,7 @@ extension DamusPurple { case http_response_error(status_code: Int, response: Data) case error_processing_response case iap_purchase_error(result: Product.PurchaseResult) + case iap_receipt_verification_error(status: Int, response: Data) case translation_no_response case checkout_npub_verification_error } @@ -411,4 +457,22 @@ extension DamusPurple { struct TranslationResult: Codable { let text: String } + + struct OnboardingStatus { + var account_existed_at_the_start: Bool? = nil + var onboarding_was_shown: Bool = false + + init() { + + } + + init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) { + self.account_existed_at_the_start = account_active_at_the_start + self.onboarding_was_shown = onboarding_was_shown + } + + func user_has_never_seen_the_onboarding_before() -> Bool { + return onboarding_was_shown == false && account_existed_at_the_start == false + } + } } diff --git a/damus/Models/Purple/Extensions/DamusPurpleNotificationManagement.swift b/damus/Models/Purple/Extensions/DamusPurpleNotificationManagement.swift @@ -0,0 +1,60 @@ +// +// DamusPurpleNotificationManagement.swift +// damus +// +// Created by Daniel D’Aquino on 2024-02-26. +// + +import Foundation + +/// A definition of how many days in advance to notify the user of impending expiration. (e.g. 3 days before expiration AND 2 days before expiration AND 1 day before expiration) +fileprivate let PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE: Set<Int> = [7, 3, 1] +fileprivate let ONE_DAY: TimeInterval = 60 * 60 * 24 + +extension DamusPurple { + typealias NotificationHandlerFunction = (DamusAppNotification) async -> Void + + func check_and_send_app_notifications_if_needed(handler: NotificationHandlerFunction) async { + await self.check_and_send_purple_expiration_notifications_if_needed(handler: handler) + } + + /// Checks if we need to send a DamusPurple impending expiration notification to the user, and sends them if needed. + /// + /// **Note:** To keep things simple at this point, this function uses a "best effort" strategy, and silently fails if something is wrong, as it is not an essential component of the app — to avoid adding more error handling complexity to the app + private func check_and_send_purple_expiration_notifications_if_needed(handler: NotificationHandlerFunction) async { + if self.storekit_manager.recorded_purchased_products.count > 0 { + // If user has a recurring IAP purchase, there no need to notify them of impending expiration + return + } + guard let purple_expiration_date: Date = try? await self.get_maybe_cached_account(pubkey: self.keypair.pubkey)?.expiry else { + return // If there are no expiry dates (e.g. The user is not a Purple user) or we cannot get it for some reason (e.g. server is temporarily down and we have no cache), don't bother sending notifications + } + + let days_to_expiry: Int = round_days_to_date(purple_expiration_date, from: Date.now) + + let applicable_impending_expiry_notification_schedule_items: [Int] = PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE.filter({ $0 >= days_to_expiry }) + + for applicable_impending_expiry_notification_schedule_item in applicable_impending_expiry_notification_schedule_items { + // Send notifications predicted by the schedule + // Note: The `insert_app_notification` has built-in logic to prevent us from sending two identical notifications, so we need not worry about it here. + await handler(.init( + content: .purple_impending_expiration( + days_remaining: applicable_impending_expiry_notification_schedule_item, + expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970) + ), + timestamp: purple_expiration_date.addingTimeInterval(-Double(applicable_impending_expiry_notification_schedule_item) * ONE_DAY)) + ) + } + + if days_to_expiry < 0 { + await handler(.init( + content: .purple_expired(expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970)), + timestamp: purple_expiration_date) + ) + } + } +} + +fileprivate func round_days_to_date(_ target_date: Date, from from_date: Date) -> Int { + return Int(round(target_date.timeIntervalSince(from_date) / ONE_DAY)) +} diff --git a/damus/Models/Purple/StoreObserver.swift b/damus/Models/Purple/StoreObserver.swift @@ -23,11 +23,11 @@ class StoreObserver: NSObject, SKPaymentTransactionObserver { //Handle transaction states here. Task { - await self.delegate?.send_receipt() + try await self.delegate?.send_receipt() } } } protocol StoreObserverDelegate { - func send_receipt() async + func send_receipt() async throws } diff --git a/damus/Views/Notifications/DamusAppNotificationView.swift b/damus/Views/Notifications/DamusAppNotificationView.swift @@ -0,0 +1,181 @@ +// +// DamusAppNotificationView.swift +// damus +// +// Created by Daniel D’Aquino on 2024-02-23. +// + +import SwiftUI + +fileprivate let DEEP_WEBSITE_LINK = false + +// TODO: Load products in a more dynamic way (if we move forward with checkout deep linking) +fileprivate let PURPLE_ONE_MONTH = "purple_one_month" +fileprivate let PURPLE_ONE_YEAR = "purple_one_year" + +struct DamusAppNotificationView: View { + let damus_state: DamusState + let notification: DamusAppNotification + var relative_date: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + if abs(notification.notification_timestamp.timeIntervalSinceNow) > 60 { + return formatter.localizedString(for: notification.notification_timestamp, relativeTo: Date.now) + } + else { + return NSLocalizedString("now", comment: "Relative time label that indicates a notification happened now") + } + } + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 15) { + AppIcon() + .frame(width: 50, height: 50) + .clipShape(.rect(cornerSize: CGSize(width: 10.0, height: 10.0))) + .shadow(radius: 5, y: 5) + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .center, spacing: 3) { + Text(NSLocalizedString("Damus", comment: "Name of the app for the title of an internal notification")) + .font(.body.weight(.bold)) + Text("·") + .foregroundStyle(.secondary) + Text(relative_date) + .font(.system(size: 16)) + .foregroundColor(.gray) + } + HStack(spacing: 3) { + Image("check-circle.fill") + .resizable() + .frame(width: 15, height: 15) + Text(NSLocalizedString("Internal app notification", comment: "Badge indicating that a notification is an official internal app notification")) + .font(.caption2) + .bold() + } + .foregroundColor(Color.white) + .padding(.vertical, 3) + .padding(.horizontal, 8) + .background(PinkGradient) + .cornerRadius(30.0) + } + Spacer() + } + .padding(.bottom, 2) + switch notification.content { + case .purple_impending_expiration(let days_remaining, _): + PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: days_remaining, expired: false) + case .purple_expired(expiry_date: _): + PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: 0, expired: true) + } + } + .padding(.horizontal) + .padding(.top, 5) + .padding(.bottom, 15) + + ThiccDivider() + } + } + + struct PurpleExpiryNotificationView: View { + let damus_state: DamusState + let days_remaining: Int + let expired: Bool + + func try_to_open_verified_checkout(product_template_name: String) { + Task { + do { + let url = try await damus_state.purple.generate_verified_ln_checkout_link(product_template_name: product_template_name) + await self.open_url(url: url) + } + catch { + await self.open_url(url: damus_state.purple.environment.purple_landing_page_url().appendingPathComponent("checkout")) + } + } + } + + @MainActor + func open_url(url: URL) { + UIApplication.shared.open(url) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(self.message()) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size)) + if DEEP_WEBSITE_LINK { + // TODO: It might be better to fetch products from the server instead of hardcoding them here. As of writing this is disabled, so not a big concern. + HStack { + Button(action: { + self.try_to_open_verified_checkout(product_template_name: "purple_one_month") + }, label: { + Text("Renew (1 mo)", comment: "Button to take user to renew subscription for one month") + }) + .buttonStyle(GradientButtonStyle()) + Button(action: { + self.try_to_open_verified_checkout(product_template_name: "purple_one_year") + }, label: { + Text("Renew (1 yr)", comment: "Button to take user to renew subscription for one year") + }) + .buttonStyle(GradientButtonStyle()) + } + } + else { + NavigationLink(destination: DamusPurpleView(damus_state: damus_state), label: { + HStack { + Text("Manage subscription", comment: "Button to take user to manage Damus Purple subscription") + .font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size)) + Image("arrow-right") + .font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size)) + } + }) + } + } + } + + func message() -> String { + if expired == true { + return NSLocalizedString("Your Purple subscription has expired. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription has expired, prompting them to renew.") + } + if days_remaining == 1 { + return NSLocalizedString("Your Purple subscription expires in 1 day. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription is expiring in one day, prompting them to renew.") + } + let message_format = NSLocalizedString("Your Purple subscription expires in %@ days. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription is expiring soon, prompting them to renew.") + return String(format: message_format, String(days_remaining)) + } + } +} + +// `AppIcon` code from: https://stackoverflow.com/a/65153628 and licensed with CC BY-SA 4.0 with the following modifications: +// - Made image resizable using `.resizable()` +extension Bundle { + var iconFileName: String? { + guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any], + let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], + let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], + let iconFileName = iconFiles.last + else { return nil } + return iconFileName + } +} + +fileprivate struct AppIcon: View { + var body: some View { + Bundle.main.iconFileName + .flatMap { UIImage(named: $0) } + .map { Image(uiImage: $0).resizable() } + } +} + +#Preview { + VStack { + ThiccDivider() + DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_impending_expiration(days_remaining: 3, expiry_date: 1709156602), timestamp: Date.now)) + } +} + +#Preview { + DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_expired(expiry_date: 1709156602), timestamp: Date.now)) +} diff --git a/damus/Views/Notifications/NotificationItemView.swift b/damus/Views/Notifications/NotificationItemView.swift @@ -10,6 +10,7 @@ import SwiftUI enum ShowItem { case show(NostrEvent?) case dontshow(NostrEvent?) + case show_damus_app_notification(DamusAppNotification) } func notification_item_event(events: EventCache, notif: NotificationItem) -> ShowItem { @@ -24,6 +25,8 @@ func notification_item_event(events: EventCache, notif: NotificationItem) -> Sho return .dontshow(events.lookup(evid)) case .profile_zap: return .show(nil) + case .damus_app_notification(let app_notification): + return .show_damus_app_notification(app_notification) } } @@ -63,6 +66,8 @@ struct NotificationItemView: View { EventView(damus: state, event: ev, options: options) } .buttonStyle(.plain) + case .damus_app_notification(let notification): + DamusAppNotificationView(damus_state: state, notification: notification) } ThiccDivider() @@ -79,6 +84,8 @@ struct NotificationItemView: View { if let ev { Item(ev) } + case .show_damus_app_notification(let notification): + DamusAppNotificationView(damus_state: state, notification: notification) } } } diff --git a/damus/Views/Purple/DamusPurpleNewUserOnboardingView.swift b/damus/Views/Purple/DamusPurpleNewUserOnboardingView.swift @@ -35,6 +35,8 @@ struct DamusPurpleNewUserOnboardingView: View { guard let account = try? await damus_state.purple.fetch_account(pubkey: damus_state.pubkey), account.active else { return } + // Let's mark onboarding as "shown" + damus_state.purple.onboarding_status.onboarding_was_shown = true // Let's notify other views across SwiftUI to update our user's Purple status. notify(.purple_account_update(account)) } diff --git a/damus/Views/Purple/DamusPurpleView.swift b/damus/Views/Purple/DamusPurpleView.swift @@ -144,6 +144,12 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate { // If account is no longer active or was purchased via IAP, then show IAP purchase/manage options if let account_uuid { DamusPurpleView.IAPProductStateView(products: products, purchased: purchased, account_uuid: account_uuid, subscribe: subscribe) + if let iap_error { + Text(String(format: NSLocalizedString("There has been an unexpected error with the in-app purchase. Please try again later or contact support@damus.io. Error: %@", comment: "In-app purchase error message for the user"), iap_error)) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } } else { ProgressView() diff --git a/damusTests/DamusPurpleImpendingExpirationTests.swift b/damusTests/DamusPurpleImpendingExpirationTests.swift @@ -0,0 +1,37 @@ +// +// DamusPurpleImpendingExpirationTests.swift +// damusTests +// +// Created by Daniel D’Aquino on 2024-02-26. +// + +import XCTest +@testable import damus + +final class DamusPurpleImpendingExpirationTests : XCTestCase { + func testNotificationContentSetDoesNotAllowRepetition() { + var notification_contents: Set<DamusAppNotification.Content> = [] + let expiry_date = UInt64(Date.now.timeIntervalSince1970) + let now = Date.now + let notification_1 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 3, expiry_date: expiry_date), timestamp: now) + notification_contents.insert(notification_1.content) + let notification_2 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 3, expiry_date: expiry_date), timestamp: now) + notification_contents.insert(notification_2.content) + let notification_3 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 2, expiry_date: expiry_date), timestamp: now) + notification_contents.insert(notification_3.content) + let notification_4 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 2, expiry_date: expiry_date), timestamp: now) + notification_contents.insert(notification_4.content) + let notification_5 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 1, expiry_date: expiry_date), timestamp: now) + notification_contents.insert(notification_5.content) + let notification_6 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 1, expiry_date: expiry_date), timestamp: now) + notification_contents.insert(notification_6.content) + XCTAssertEqual(notification_contents.count, 3) + XCTAssertTrue(notification_contents.contains(notification_1.content)) + XCTAssertTrue(notification_contents.contains(notification_2.content)) + XCTAssertTrue(notification_contents.contains(notification_3.content)) + XCTAssertTrue(notification_contents.contains(notification_4.content)) + XCTAssertTrue(notification_contents.contains(notification_5.content)) + XCTAssertTrue(notification_contents.contains(notification_6.content)) + } +} +