Merge tag 'v1.7-rc2'

v1.7 Madeira release RC2

55c26d22cb v1.7 (11)
4c8134908c Enable IAP feature for release
3ac7d75235 Add UI error message when IAP succeeds but receipt verification fails
b49a5f4d29 Purple: Improve UX on Damus Purple renewals
3569919eaf Add Damus Purple impending expiry notification support
This commit is contained in:
William Casarin
2024-02-29 03:10:49 -08:00
13 changed files with 501 additions and 39 deletions

View File

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

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

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

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

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