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)) + } +} +