Merge tag 'v1.7-rc2'
v1.7 Madeira release RC255c26d22cbv1.7 (11)4c8134908cEnable IAP feature for release3ac7d75235Add UI error message when IAP succeeds but receipt verification failsb49a5f4d29Purple: Improve UX on Damus Purple renewals3569919eafAdd Damus Purple impending expiry notification support
This commit is contained in:
@@ -276,6 +276,15 @@ class HomeModel {
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handle_damus_app_notification(_ notification: DamusAppNotification) async {
|
||||
if self.notifications.insert_app_notification(notification: notification) {
|
||||
// If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits
|
||||
// This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification
|
||||
self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func filter_events() {
|
||||
events.filter { ev in
|
||||
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil))
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class DamusPurple: StoreObserverDelegate {
|
||||
let keypair: Keypair
|
||||
var storekit_manager: StoreKitManager
|
||||
var checkout_ids_in_progress: Set<String> = []
|
||||
var onboarding_status: OnboardingStatus
|
||||
|
||||
@MainActor
|
||||
var account_cache: [Pubkey: Account]
|
||||
@@ -25,6 +26,16 @@ class DamusPurple: StoreObserverDelegate {
|
||||
self.account_cache = [:]
|
||||
self.account_uuid_cache = [:]
|
||||
self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data
|
||||
self.onboarding_status = OnboardingStatus()
|
||||
Task {
|
||||
let account: Account? = try await self.fetch_account(pubkey: self.keypair.pubkey)
|
||||
if account == nil {
|
||||
self.onboarding_status.account_existed_at_the_start = false
|
||||
}
|
||||
else {
|
||||
self.onboarding_status.account_existed_at_the_start = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
@@ -45,7 +56,8 @@ class DamusPurple: StoreObserverDelegate {
|
||||
// Whether to enable Apple In-app purchase support
|
||||
var enable_purple_iap_support: Bool {
|
||||
// TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag)
|
||||
return self.settings.enable_experimental_purple_iap_support
|
||||
// return self.settings.enable_experimental_purple_iap_support
|
||||
return true
|
||||
}
|
||||
|
||||
func account_exists(pubkey: Pubkey) async -> Bool? {
|
||||
@@ -109,7 +121,7 @@ class DamusPurple: StoreObserverDelegate {
|
||||
// During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
|
||||
self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
|
||||
// Send the receipt to the server
|
||||
await self.send_receipt()
|
||||
try await self.send_receipt()
|
||||
default:
|
||||
// Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
|
||||
throw PurpleError.iap_purchase_error(result: result)
|
||||
@@ -150,42 +162,37 @@ class DamusPurple: StoreObserverDelegate {
|
||||
return account_uuid_info.account_uuid
|
||||
}
|
||||
|
||||
func send_receipt() async {
|
||||
func send_receipt() async throws {
|
||||
// Get the receipt if it's available.
|
||||
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
|
||||
|
||||
do {
|
||||
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
|
||||
let receipt_base64_string = receiptData.base64EncodedString()
|
||||
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
|
||||
let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString]
|
||||
let json_data = try JSONSerialization.data(withJSONObject: json_text)
|
||||
|
||||
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt")
|
||||
|
||||
Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple)
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
url: url,
|
||||
payload: json_data,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
|
||||
default:
|
||||
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
}
|
||||
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
|
||||
let receipt_base64_string = receiptData.base64EncodedString()
|
||||
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
|
||||
let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString]
|
||||
let json_data = try JSONSerialization.data(withJSONObject: json_text)
|
||||
|
||||
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt")
|
||||
|
||||
Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple)
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
url: url,
|
||||
payload: json_data,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
|
||||
default:
|
||||
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
|
||||
}
|
||||
|
||||
}
|
||||
catch {
|
||||
Log.error("Couldn't read receipt data with error: %s", for: .damus_purple, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,6 +276,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
|
||||
@@ -404,6 +449,7 @@ extension DamusPurple {
|
||||
case http_response_error(status_code: Int, response: Data)
|
||||
case error_processing_response
|
||||
case iap_purchase_error(result: Product.PurchaseResult)
|
||||
case iap_receipt_verification_error(status: Int, response: Data)
|
||||
case translation_no_response
|
||||
case checkout_npub_verification_error
|
||||
}
|
||||
@@ -411,4 +457,22 @@ extension DamusPurple {
|
||||
struct TranslationResult: Codable {
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct OnboardingStatus {
|
||||
var account_existed_at_the_start: Bool? = nil
|
||||
var onboarding_was_shown: Bool = false
|
||||
|
||||
init() {
|
||||
|
||||
}
|
||||
|
||||
init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) {
|
||||
self.account_existed_at_the_start = account_active_at_the_start
|
||||
self.onboarding_was_shown = onboarding_was_shown
|
||||
}
|
||||
|
||||
func user_has_never_seen_the_onboarding_before() -> Bool {
|
||||
return onboarding_was_shown == false && account_existed_at_the_start == false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// DamusPurpleNotificationManagement.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-02-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A definition of how many days in advance to notify the user of impending expiration. (e.g. 3 days before expiration AND 2 days before expiration AND 1 day before expiration)
|
||||
fileprivate let PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE: Set<Int> = [7, 3, 1]
|
||||
fileprivate let ONE_DAY: TimeInterval = 60 * 60 * 24
|
||||
|
||||
extension DamusPurple {
|
||||
typealias NotificationHandlerFunction = (DamusAppNotification) async -> Void
|
||||
|
||||
func check_and_send_app_notifications_if_needed(handler: NotificationHandlerFunction) async {
|
||||
await self.check_and_send_purple_expiration_notifications_if_needed(handler: handler)
|
||||
}
|
||||
|
||||
/// Checks if we need to send a DamusPurple impending expiration notification to the user, and sends them if needed.
|
||||
///
|
||||
/// **Note:** To keep things simple at this point, this function uses a "best effort" strategy, and silently fails if something is wrong, as it is not an essential component of the app — to avoid adding more error handling complexity to the app
|
||||
private func check_and_send_purple_expiration_notifications_if_needed(handler: NotificationHandlerFunction) async {
|
||||
if self.storekit_manager.recorded_purchased_products.count > 0 {
|
||||
// If user has a recurring IAP purchase, there no need to notify them of impending expiration
|
||||
return
|
||||
}
|
||||
guard let purple_expiration_date: Date = try? await self.get_maybe_cached_account(pubkey: self.keypair.pubkey)?.expiry else {
|
||||
return // If there are no expiry dates (e.g. The user is not a Purple user) or we cannot get it for some reason (e.g. server is temporarily down and we have no cache), don't bother sending notifications
|
||||
}
|
||||
|
||||
let days_to_expiry: Int = round_days_to_date(purple_expiration_date, from: Date.now)
|
||||
|
||||
let applicable_impending_expiry_notification_schedule_items: [Int] = PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE.filter({ $0 >= days_to_expiry })
|
||||
|
||||
for applicable_impending_expiry_notification_schedule_item in applicable_impending_expiry_notification_schedule_items {
|
||||
// Send notifications predicted by the schedule
|
||||
// Note: The `insert_app_notification` has built-in logic to prevent us from sending two identical notifications, so we need not worry about it here.
|
||||
await handler(.init(
|
||||
content: .purple_impending_expiration(
|
||||
days_remaining: applicable_impending_expiry_notification_schedule_item,
|
||||
expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970)
|
||||
),
|
||||
timestamp: purple_expiration_date.addingTimeInterval(-Double(applicable_impending_expiry_notification_schedule_item) * ONE_DAY))
|
||||
)
|
||||
}
|
||||
|
||||
if days_to_expiry < 0 {
|
||||
await handler(.init(
|
||||
content: .purple_expired(expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970)),
|
||||
timestamp: purple_expiration_date)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func round_days_to_date(_ target_date: Date, from from_date: Date) -> Int {
|
||||
return Int(round(target_date.timeIntervalSince(from_date) / ONE_DAY))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user