Files
damus/damus/Models/NotificationsModel.swift
Daniel D’Aquino 3569919eaf 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>
2024-02-28 23:20:48 -08:00

392 lines
11 KiB
Swift

//
// NotificationsModel.swift
// damus
//
// Created by William Casarin on 2023-02-21.
//
import Foundation
enum NotificationItem {
case repost(NoteId, EventGroup)
case reaction(NoteId, EventGroup)
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 {
return ev
}
return nil
}
var is_zap: ZapGroup? {
switch self {
case .profile_zap(let zapgrp):
return zapgrp
case .event_zap(_, let zapgrp):
return zapgrp
case .reaction:
return nil
case .reply:
return nil
case .repost:
return nil
case .damus_app_notification(_):
return nil
}
}
var last_event_at: UInt32 {
switch self {
case .reaction(_, let evgrp):
return evgrp.last_event_at
case .repost(_, let evgrp):
return evgrp.last_event_at
case .profile_zap(let zapgrp):
return zapgrp.last_event_at
case .event_zap(_, let zapgrp):
return zapgrp.last_event_at
case .reply(let reply):
return reply.created_at
case .damus_app_notification(let notification):
return notification.last_event_at
}
}
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
switch self {
case .repost(_, let evgrp):
return evgrp.would_filter(isIncluded)
case .reaction(_, let evgrp):
return evgrp.would_filter(isIncluded)
case .profile_zap(let zapgrp):
return zapgrp.would_filter(isIncluded)
case .event_zap(_, let zapgrp):
return zapgrp.would_filter(isIncluded)
case .reply(let ev):
return !isIncluded(ev)
case .damus_app_notification(_):
return true
}
}
func filter(_ isIncluded: (NostrEvent) -> Bool) -> NotificationItem? {
switch self {
case .repost(let evid, let evgrp):
return evgrp.filter(isIncluded).map { .repost(evid, $0) }
case .reaction(let evid, let evgrp):
return evgrp.filter(isIncluded).map { .reaction(evid, $0) }
case .profile_zap(let zapgrp):
return zapgrp.filter(isIncluded).map { .profile_zap($0) }
case .event_zap(let evid, let zapgrp):
return zapgrp.filter(isIncluded).map { .event_zap(evid, $0) }
case .reply(let ev):
if isIncluded(ev) { return .reply(ev) }
return nil
case .damus_app_notification(_):
return self
}
}
}
class NotificationsModel: ObservableObject, ScrollQueue {
var incoming_zaps: [Zapping] = []
var incoming_events: [NostrEvent] = []
var should_queue: Bool = true
// mappings from events to
var zaps: [NoteId: ZapGroup] = [:]
var profile_zaps = ZapGroup()
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>()
@Published var notifications: [NotificationItem] = []
func set_should_queue(_ val: Bool) {
self.should_queue = val
}
func uniq_pubkeys() -> [Pubkey] {
var pks = Set<Pubkey>()
for ev in incoming_events {
pks.insert(ev.pubkey)
}
for grp in reposts {
for ev in grp.value.events {
pks.insert(ev.pubkey)
}
}
for ev in replies {
pks.insert(ev.pubkey)
}
for zap in incoming_zaps {
pks.insert(zap.request.ev.pubkey)
}
return Array(pks)
}
func build_notifications() -> [NotificationItem] {
var notifs: [NotificationItem] = []
for el in zaps {
let evid = el.key
let zapgrp = el.value
let notif: NotificationItem = .event_zap(evid, zapgrp)
notifs.append(notif)
}
if !profile_zaps.zaps.isEmpty {
notifs.append(.profile_zap(profile_zaps))
}
for el in reposts {
let evid = el.key
let evgrp = el.value
notifs.append(.repost(evid, evgrp))
}
for el in reactions {
let evid = el.key
let evgrp = el.value
notifs.append(.reaction(evid, evgrp))
}
for reply in replies {
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
}
private func insert_repost(_ ev: NostrEvent, cache: EventCache) -> Bool {
guard let reposted_ev = ev.get_inner_event(cache: cache) else {
return false
}
let id = reposted_ev.id
if let evgrp = self.reposts[id] {
return evgrp.insert(ev)
} else {
let evgrp = EventGroup()
self.reposts[id] = evgrp
return evgrp.insert(ev)
}
}
private func insert_text(_ ev: NostrEvent) -> Bool {
guard !has_reply.contains(ev.id) else {
return false
}
has_reply.insert(ev.id)
replies.append(ev)
return true
}
private func insert_reaction(_ ev: NostrEvent) -> Bool {
guard let id = ev.referenced_ids.last else {
return false
}
if let evgrp = self.reactions[id] {
return evgrp.insert(ev)
} else {
let evgrp = EventGroup()
self.reactions[id] = evgrp
return evgrp.insert(ev)
}
}
private func insert_event_immediate(_ ev: NostrEvent, cache: EventCache) -> Bool {
if ev.known_kind == .boost {
return insert_repost(ev, cache: cache)
} else if ev.known_kind == .like {
return insert_reaction(ev)
} else if ev.known_kind == .text {
return insert_text(ev)
}
return false
}
private func insert_zap_immediate(_ zap: Zapping) -> Bool {
switch zap.target {
case .note(let notezt):
let id = notezt.note_id
if let zapgrp = self.zaps[notezt.note_id] {
return zapgrp.insert(zap)
} else {
let zapgrp = ZapGroup()
self.zaps[id] = zapgrp
return zapgrp.insert(zap)
}
case .profile:
return profile_zaps.insert(zap)
}
}
func insert_event(_ ev: NostrEvent, damus_state: DamusState) -> Bool {
if has_ev.contains(ev.id) {
return false
}
if should_queue {
incoming_events.append(ev)
has_ev.insert(ev.id)
return true
}
if insert_event_immediate(ev, cache: damus_state.events) {
self.notifications = build_notifications()
return true
}
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)
}
if insert_zap_immediate(zap) {
self.notifications = build_notifications()
return true
}
return false
}
func filter(_ isIncluded: (NostrEvent) -> Bool) {
var changed = false
var count = 0
count = incoming_events.count
incoming_events = incoming_events.filter(isIncluded)
changed = changed || incoming_events.count != count
count = profile_zaps.zaps.count
profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) }
changed = changed || profile_zaps.zaps.count != count
for el in reactions {
count = el.value.events.count
el.value.events = el.value.events.filter(isIncluded)
changed = changed || el.value.events.count != count
}
for el in reposts {
count = el.value.events.count
el.value.events = el.value.events.filter(isIncluded)
changed = changed || el.value.events.count != count
}
for el in zaps {
count = el.value.zaps.count
el.value.zaps = el.value.zaps.filter {
isIncluded($0.request.ev)
}
changed = changed || el.value.zaps.count != count
}
count = replies.count
replies = replies.filter(isIncluded)
changed = changed || replies.count != count
if changed {
self.notifications = build_notifications()
}
}
func flush(_ damus_state: DamusState) -> Bool {
var inserted = false
for zap in incoming_zaps {
inserted = insert_zap_immediate(zap) || inserted
}
for event in incoming_events {
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()
}
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)
}
}