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:
181
damus/Views/Notifications/DamusAppNotificationView.swift
Normal file
181
damus/Views/Notifications/DamusAppNotificationView.swift
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user