Add Damus Purple impending expiry notification support

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 <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino 2024-02-29 07:16:34 +00:00
parent bdc811aa82
commit 3569919eaf
10 changed files with 423 additions and 1 deletions

View File

@ -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 = "<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>"; };
@ -1394,6 +1398,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>"; };
@ -1707,6 +1713,7 @@
4C30AC7329A5680900E2BD5A /* EventGroupView.swift */,
4C30AC7529A5770900E2BD5A /* NotificationItemView.swift */,
4C30AC7F29A6A53F00E2BD5A /* ProfilePicturesView.swift */,
D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */,
);
path = Notifications;
sourceTree = "<group>";
@ -2507,6 +2514,7 @@
D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */,
B501062C2B363036003874F5 /* AuthIntegrationTests.swift */,
E0E024102B7C19C20075735D /* TranslationTests.swift */,
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */,
);
path = damusTests;
sourceTree = "<group>";
@ -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 = "<group>";
};
D7CBD1D22B8D21C100BFD889 /* Extensions */ = {
isa = PBXGroup;
children = (
D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
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 */,

View File

@ -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

View File

@ -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)

View File

@ -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]
}

View File

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

View File

@ -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

View File

@ -0,0 +1,60 @@
//
// DamusPurpleNotificationManagement.swift
// damus
//
// Created by Daniel DAquino 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))
}

View File

@ -0,0 +1,181 @@
//
// DamusAppNotificationView.swift
// damus
//
// Created by Daniel DAquino 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))
}

View File

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

View File

@ -0,0 +1,37 @@
//
// DamusPurpleImpendingExpirationTests.swift
// damusTests
//
// Created by Daniel DAquino 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))
}
}