From 3569919eafe184ad512ea67d54e8ceba2ee282e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Thu, 29 Feb 2024 07:16:34 +0000 Subject: [PATCH 1/5] Add Damus Purple impending expiry notification support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds Damus Purple expiry notification support. How it works: Whenever the app initiates or enters the foreground, it checks the user's account expiry, and calculates what notifications to display (It is functional, not imperative, to better match how the notifications view works) The notification handlers work the same as every other notification handler for Nostr events. However, local iOS notifications were not implemented to maintain these reminders more discreet. Current limitations: - Notifications cannot be dismissed - Notifications are dismissed only when Damus Purple is extended - After making a purchase, notifications are not dismissed right away - Bell icon with purple badge shows up on every app restart if user's account is expired Testing ------- Device: iPhone 13 Mini iOS: 17.3.1 Damus: This commit damus-api: d3801376fa204433661be6de8b7974f12b0ad25f Setup: - Local servers Setup - Debug endpoints enabled for changing expiry date on the fly Coverage: 1. Expired account 1. Starting the app on home screen shows bell icon with purple badge. PASS 2. 4 notifications appear on notifications view (7,3,1,0 days to expiry). PASS 3. Notifications appear in correct chronological order. PASS 4. Notifications look consistent in appearance. PASS 5. Expiry notifications' text size follows text size settings. PASS 6. Clicking on notification CTA takes user to account info page. PASS 2. Non-expired account (set expiry, restart app) 1. No expiry notifications, no bell icon. PASS 3. Expiry in 6 days (set expiry, restart app) 1. Starting the app on home screen shows bell icon with purple badge. PASS 2. Starting the app on the notification screen renders notifications the same way. PASS 3. Only one notification (7 days remaining) appears. PASS 4. Expiry in 2 days. PASS 5. General 1. Clicking bell icon clears away "new notifications" badge. PASS 2. Performance of notifications view does not seem affected. PASS 3. Performance of app on startup does not seem affected. PASS 6. IAP 1. Active IAP + expiry date in 2 days does not trigger reminder notification (Because it is auto-renewed). PASS Closes: https://github.com/damus-io/damus/issues/1973 Changelog-Added: Notification reminders for Damus Purple impending expiration Signed-off-by: Daniel D’Aquino --- damus.xcodeproj/project.pbxproj | 20 ++ damus/ContentView.swift | 6 + damus/Models/HomeModel.swift | 9 + damus/Models/NewEventsBits.swift | 3 +- damus/Models/NotificationsModel.swift | 63 ++++++ damus/Models/Purple/DamusPurple.swift | 38 ++++ .../DamusPurpleNotificationManagement.swift | 60 ++++++ .../DamusAppNotificationView.swift | 181 ++++++++++++++++++ .../Notifications/NotificationItemView.swift | 7 + .../DamusPurpleImpendingExpirationTests.swift | 37 ++++ 10 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 damus/Models/Purple/Extensions/DamusPurpleNotificationManagement.swift create mode 100644 damus/Views/Notifications/DamusAppNotificationView.swift create mode 100644 damusTests/DamusPurpleImpendingExpirationTests.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index a992819b..3396fadf 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -482,6 +482,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 */; }; @@ -536,6 +537,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"; }; }; @@ -1376,6 +1379,7 @@ D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = ""; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = ""; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = ""; }; + D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = ""; }; D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = ""; }; D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = ""; }; D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = ""; }; @@ -1394,6 +1398,8 @@ D7CB5D502B1174D100AD4105 /* FriendFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendFilter.swift; sourceTree = ""; }; D7CB5D5B2B1176B200AD4105 /* MediaUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploader.swift; sourceTree = ""; }; D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = ""; }; + D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = ""; }; + D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = ""; }; D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = ""; }; D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = ""; }; D7EDED1D2B11797D0018B19C /* LongformEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformEvent.swift; sourceTree = ""; }; @@ -1707,6 +1713,7 @@ 4C30AC7329A5680900E2BD5A /* EventGroupView.swift */, 4C30AC7529A5770900E2BD5A /* NotificationItemView.swift */, 4C30AC7F29A6A53F00E2BD5A /* ProfilePicturesView.swift */, + D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */, ); path = Notifications; sourceTree = ""; @@ -2507,6 +2514,7 @@ D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */, B501062C2B363036003874F5 /* AuthIntegrationTests.swift */, E0E024102B7C19C20075735D /* TranslationTests.swift */, + D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */, ); path = damusTests; sourceTree = ""; @@ -2674,6 +2682,7 @@ D74F43082B23F09300425B75 /* Purple */ = { isa = PBXGroup; children = ( + D7CBD1D22B8D21C100BFD889 /* Extensions */, D74F43092B23F0BE00425B75 /* DamusPurple.swift */, D74F430B2B23FB9B00425B75 /* StoreObserver.swift */, D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */, @@ -2695,6 +2704,14 @@ path = DamusNotificationService; sourceTree = ""; }; + D7CBD1D22B8D21C100BFD889 /* Extensions */ = { + isa = PBXGroup; + children = ( + D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */, + ); + path = Extensions; + sourceTree = ""; + }; F71694E82A66221E001F4053 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -2989,6 +3006,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 */, @@ -3260,6 +3278,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 */, @@ -3428,6 +3447,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 */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index c88f10b6..fa1fa395 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -501,6 +501,9 @@ struct ContentView: View { } } } + 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 +727,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 index 4210ce3e..7d04081d 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -274,6 +274,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.contacts.is_muted(ev.pubkey) diff --git a/damus/Models/NewEventsBits.swift b/damus/Models/NewEventsBits.swift index 858eb30d..c8369be8 100644 --- 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 index 335a9452..125f359c 100644 --- 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() var has_reply = Set() var has_ev = Set() @@ -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 index 95496375..2ffddd54 100644 --- a/damus/Models/Purple/DamusPurple.swift +++ b/damus/Models/Purple/DamusPurple.swift @@ -269,6 +269,44 @@ class DamusPurple: StoreObserverDelegate { throw PurpleError.error_processing_response } + @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 diff --git a/damus/Models/Purple/Extensions/DamusPurpleNotificationManagement.swift b/damus/Models/Purple/Extensions/DamusPurpleNotificationManagement.swift new file mode 100644 index 00000000..6c69b4b9 --- /dev/null +++ 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 = [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/Views/Notifications/DamusAppNotificationView.swift b/damus/Views/Notifications/DamusAppNotificationView.swift new file mode 100644 index 00000000..e73d43e6 --- /dev/null +++ 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 index 7a52578a..423225ff 100644 --- 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/damusTests/DamusPurpleImpendingExpirationTests.swift b/damusTests/DamusPurpleImpendingExpirationTests.swift new file mode 100644 index 00000000..694dd005 --- /dev/null +++ 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 = [] + 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)) + } +} + From b49a5f4d2937cf9b95cfc98b1d646da03c8142d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Thu, 29 Feb 2024 07:16:41 +0000 Subject: [PATCH 2/5] Purple: Improve UX on Damus Purple renewals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit changes when the Damus Purple onboarding gets triggered. Now it only shows the onboarding if it is the first time they are purchasing Damus Purple for a Nostr account. If it is not the first they see the onboarding, they are directed to a sheet that displays their new account info (e.g. a renewal) However, if the website links to `damus:purple:welcome` links, the app will still show the onboarding. This will be addressed on the website-side by taking them to `damus:purple:landing` instead when it is a renewal. Testing --------- PASS Device: iPhone 13 mini (Physical device) iOS: 17.3.1 Damus: This commit damus-api: d3801376fa204433661be6de8b7974f12b0ad25f damus-website: 6bb425e324c318ca474417cbd2b2f8bb74f9505f Setup: - iOS configured to use a local test environment - Local damus-api server and local damus-website server setup and properly configured - Fresh db (i.e. Delete mdb files before starting) Coverage: 1. LN flow with switching to the app instead of clicking "continue". PASS a. Ensure that first purchase will result in onboarding being shown. PASS b. Ensure that second purchase will result in onboarding NOT being shown (User should be taken to the account info screen). PASS c. Restart app completely. Ensure third purchase will result in onboarding NOT being shown (only account info screen). PASS 2. LN flow with clicking continue (Since website changes are not ready, we will simulate them by manually generally appropriate "continue" URL QR codes) a. Note: Clear DB again before starting this portion, and completely restart app. b. Ensure that first purchase will result in onboarding being shown. PASS c. Ensure that second purchase (with continue URL manually hard-coded to `damus:purple:landing`) results in account info being shown with updated info. PASS d. Restart app completely and repeat test 2(c). PASS Signed-off-by: Daniel D’Aquino --- damus/ContentView.swift | 10 +++++-- damus/Models/Purple/DamusPurple.swift | 29 +++++++++++++++++++ .../DamusPurpleNewUserOnboardingView.swift | 2 ++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index fa1fa395..ddbc36fb 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -490,13 +490,17 @@ 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)) + } } } } diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift index 2ffddd54..c5a2ba9c 100644 --- 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 = [] + 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 @@ -449,4 +460,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/Views/Purple/DamusPurpleNewUserOnboardingView.swift b/damus/Views/Purple/DamusPurpleNewUserOnboardingView.swift index a9435e1e..c478e271 100644 --- 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)) } From 3ac7d75235f3b88a8dcaf8f3d45b4c8acb2c25ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Thu, 29 Feb 2024 07:16:47 +0000 Subject: [PATCH 3/5] Add UI error message when IAP succeeds but receipt verification fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This should not be visible to end-users on normal circumstances, but we should regardless show an error message if something goes wrong with the IAP receipt verification, to prompt them to contact support. Testing ------- PASS Device: iPhone simulator iOS: 17.2 Damus: This commit damus-api: d3801376fa204433661be6de8b7974f12b0ad25f Setup: - Local Testing server - Xcode StoreKit environment Steps: 1. Set MOCK_VERIFY to false on the server (that means receipt verification will fail on Xcode environment) 2. Try to make IAP purchase. Error should appear on UI 3. Set MOCK_VERIFY to true on the server and restart StoreKit environment (Receipt validation will always work) 5. Try to make IAP purchase. Onboarding flow should go as normal with no error messages. PASS Signed-off-by: Daniel D’Aquino --- damus/Models/Purple/DamusPurple.swift | 60 +++++++++++------------- damus/Models/Purple/StoreObserver.swift | 4 +- damus/Views/Purple/DamusPurpleView.swift | 6 +++ 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift index c5a2ba9c..679ca8c7 100644 --- a/damus/Models/Purple/DamusPurple.swift +++ b/damus/Models/Purple/DamusPurple.swift @@ -120,7 +120,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) @@ -161,42 +161,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) } } } @@ -453,6 +448,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 } diff --git a/damus/Models/Purple/StoreObserver.swift b/damus/Models/Purple/StoreObserver.swift index 4a898b8f..a78a585e 100644 --- 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/Purple/DamusPurpleView.swift b/damus/Views/Purple/DamusPurpleView.swift index 11644033..32364faf 100644 --- 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() From 4c8134908cfcb889580c9a9660d992880469decf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Thu, 29 Feb 2024 07:16:54 +0000 Subject: [PATCH 4/5] Enable IAP feature for release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog-Changed: Add support for Apple In-App purchases Signed-off-by: Daniel D’Aquino --- damus/Models/Purple/DamusPurple.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/damus/Models/Purple/DamusPurple.swift b/damus/Models/Purple/DamusPurple.swift index 679ca8c7..1cc9d55c 100644 --- a/damus/Models/Purple/DamusPurple.swift +++ b/damus/Models/Purple/DamusPurple.swift @@ -56,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? { From 55c26d22cb0b34f15c4ac2c9cc7343a735c680a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Wed, 28 Feb 2024 23:41:56 -0800 Subject: [PATCH 5/5] v1.7 (11) --- damus.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 3396fadf..0aebf16b 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -3901,6 +3901,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; @@ -3950,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;