Implement zap notification support for push notifications
The code paths for generating zap notifications were very different from the paths used by most other notifications. In this commit, I include the logic and data structures necessary for formatting zap notifications in the same fashion as local notifications. A good amount of refactoring and moving functions/structures around was necessary to reuse zap local notification logic. I also attempted to make the notification generation process more consistent between zaps and other notifications, without changing too much of existing logic to avoid even more regression risk. General push notifications + local notifications test ----------------------------------------------------- PASS Device: iPhone 15 Pro simulator iOS: 17.0.1 Damus: This commit Setup: - Two phones running Damus on different accounts - Local relay with strfry-push-notify test setup - Apple push notification test tool Coverage: 1. Mention notifications 2. DM notifications 3. Reaction notifications 4. Repost notifications Steps for each notification type: 1. Trigger a notification (local and then push) 2. Ensure that the notification is received on the other device 3. Ensure that the notification is formatted correctly 4. Ensure that DMs are decrypted correctly 5. Ensure that profile names are unfurled correctly 6. Click on the notification and ensure that the app opens to the correct screen Result: PASS (all notifications received and formatted correctly) Notes: - For some reason my relay is not receiving zap events, so I could not test zap notifications yet. - Reply notifications do not seem to be implemented yet - These apply to the tests below as well Changelog-Added: Zap notification support for push notifications Signed-off-by: Daniel D’Aquino <daniel@daquino.me> Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
committed by
William Casarin
parent
c4f0e833ff
commit
003482c971
@@ -18,4 +18,9 @@ protocol HeadlessDamusState {
|
||||
var muted_threads: MutedThreadsManager { get }
|
||||
var keypair: Keypair { get }
|
||||
var profiles: Profiles { get }
|
||||
var zaps: Zaps { get }
|
||||
var lnurls: LNUrls { get }
|
||||
|
||||
@discardableResult
|
||||
func add_zap(zap: Zapping) -> Bool
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ class HomeModel {
|
||||
|
||||
@MainActor
|
||||
func handle_zap_event(_ ev: NostrEvent) {
|
||||
process_zap_event(damus_state: damus_state, ev: ev) { zapres in
|
||||
process_zap_event(state: damus_state, ev: ev) { zapres in
|
||||
guard case .done(let zap) = zapres,
|
||||
zap.target.pubkey == self.damus_state.keypair.pubkey,
|
||||
should_show_event(keypair: self.damus_state.keypair, hellthreads: self.damus_state.muted_threads, contacts: self.damus_state.contacts, ev: zap.request.ev) else {
|
||||
@@ -1093,39 +1093,11 @@ func zap_vibrate(zap_amount: Int64) {
|
||||
vibration_generator.impactOccurred()
|
||||
}
|
||||
|
||||
func zap_notification_title(_ zap: Zap) -> String {
|
||||
if zap.private_request != nil {
|
||||
return NSLocalizedString("Private Zap", comment: "Title of notification when a private zap is received.")
|
||||
} else {
|
||||
return NSLocalizedString("Zap", comment: "Title of notification when a non-private zap is received.")
|
||||
}
|
||||
}
|
||||
|
||||
func zap_notification_body(profiles: Profiles, zap: Zap, locale: Locale = Locale.current) -> String {
|
||||
let src = zap.request.ev
|
||||
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
|
||||
|
||||
let name = profiles.lookup(id: pk).map { profile in
|
||||
Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
|
||||
}.value
|
||||
|
||||
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
|
||||
let formattedSats = format_msats_abbrev(zap.invoice.amount)
|
||||
|
||||
if src.content.isEmpty {
|
||||
let format = localizedStringFormat(key: "zap_notification_no_message", locale: locale)
|
||||
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name)
|
||||
} else {
|
||||
let format = localizedStringFormat(key: "zap_notification_with_message", locale: locale)
|
||||
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name, src.content)
|
||||
}
|
||||
}
|
||||
|
||||
func create_in_app_profile_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, profile_id: Pubkey) {
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
content.title = zap_notification_title(zap)
|
||||
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.title = NotificationFormatter.zap_notification_title(zap)
|
||||
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.sound = UNNotificationSound.default
|
||||
content.userInfo = LossyLocalNotification(type: .profile_zap, mention: .pubkey(profile_id)).to_user_info()
|
||||
|
||||
@@ -1145,8 +1117,8 @@ func create_in_app_profile_zap_notification(profiles: Profiles, zap: Zap, locale
|
||||
func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, evId: NoteId) {
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
content.title = zap_notification_title(zap)
|
||||
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.title = NotificationFormatter.zap_notification_title(zap)
|
||||
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.sound = UNNotificationSound.default
|
||||
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(evId)).to_user_info()
|
||||
|
||||
@@ -1162,109 +1134,3 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProcessZapResult {
|
||||
case already_processed(Zap)
|
||||
case done(Zap)
|
||||
case failed
|
||||
}
|
||||
|
||||
// securely get the zap target's pubkey. this can be faked so we need to be
|
||||
// careful
|
||||
func get_zap_target_pubkey(ev: NostrEvent, events: EventCache) -> Pubkey? {
|
||||
let etags = Array(ev.referenced_ids)
|
||||
|
||||
guard let etag = etags.first else {
|
||||
// no etags, ptag-only case
|
||||
|
||||
guard let a = ev.referenced_pubkeys.just_one() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: just return data here
|
||||
return a
|
||||
}
|
||||
|
||||
// we have an e-tag
|
||||
|
||||
// ensure that there is only 1 etag to stop fake note zap attacks
|
||||
guard etags.count == 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// we can't trust the p tag on note zaps because they can be faked
|
||||
guard let pk = events.lookup(etag)?.pubkey else {
|
||||
// We don't have the event in cache so we can't check the pubkey.
|
||||
|
||||
// We could return this as an invalid zap but that wouldn't be correct
|
||||
// all of the time, and may reject valid zaps. What we need is a new
|
||||
// unvalidated zap state, but for now we simply leak a bit of correctness...
|
||||
|
||||
return ev.referenced_pubkeys.just_one()
|
||||
}
|
||||
|
||||
return pk
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func process_zap_event(damus_state: DamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
|
||||
// These are zap notifications
|
||||
guard let ptag = get_zap_target_pubkey(ev: ev, events: damus_state.events) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
// just return the zap if we already have it
|
||||
if let zap = damus_state.zaps.zaps[ev.id], case .zap(let z) = zap {
|
||||
completion(.already_processed(z))
|
||||
return
|
||||
}
|
||||
|
||||
if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: ptag) {
|
||||
guard let zap = process_zap_event_with_zapper(damus_state: damus_state, ev: ev, zapper: local_zapper) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
damus_state.add_zap(zap: .zap(zap))
|
||||
completion(.done(zap))
|
||||
return
|
||||
}
|
||||
|
||||
guard let lnurl = damus_state.profiles.lookup_with_timestamp(ptag)
|
||||
.map({ pr in pr?.lnurl }).value else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
Task { [lnurl] in
|
||||
guard let zapper = await fetch_zapper_from_lnurl(lnurls: damus_state.lnurls, pubkey: ptag, lnurl: lnurl) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
damus_state.profiles.profile_data(ptag).zapper = zapper
|
||||
guard let zap = process_zap_event_with_zapper(damus_state: damus_state, ev: ev, zapper: zapper) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
damus_state.add_zap(zap: .zap(zap))
|
||||
completion(.done(zap))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fileprivate func process_zap_event_with_zapper(damus_state: DamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
|
||||
let our_keypair = damus_state.keypair
|
||||
|
||||
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
damus_state.add_zap(zap: .zap(zap))
|
||||
|
||||
return zap
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamu
|
||||
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
||||
return LocalNotification(type: .dm, event: ev, target: ev, content: convo)
|
||||
}
|
||||
else if type == .zap,
|
||||
state.settings.zap_notification {
|
||||
return LocalNotification(type: .zap, event: ev, target: ev, content: ev.content)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -88,7 +92,7 @@ func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamu
|
||||
func create_local_notification(profiles: Profiles, notify: LocalNotification) {
|
||||
let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
|
||||
|
||||
let (content, identifier) = NotificationFormatter.shared.format_message(displayName: displayName, notify: notify)
|
||||
guard let (content, identifier) = NotificationFormatter.shared.format_message(displayName: displayName, notify: notify) else { return }
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
|
||||
@@ -130,3 +134,126 @@ func event_author_name(profiles: Profiles, pubkey: Pubkey) -> String {
|
||||
Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
||||
}).value
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_zap(from ev: NostrEvent, state: HeadlessDamusState) async -> Zap? {
|
||||
return await withCheckedContinuation { continuation in
|
||||
process_zap_event(state: state, ev: ev) { zapres in
|
||||
continuation.resume(returning: zapres.get_zap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func process_zap_event(state: HeadlessDamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
|
||||
// These are zap notifications
|
||||
guard let ptag = get_zap_target_pubkey(ev: ev, ndb: state.ndb) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
// just return the zap if we already have it
|
||||
if let zap = state.zaps.zaps[ev.id], case .zap(let z) = zap {
|
||||
completion(.already_processed(z))
|
||||
return
|
||||
}
|
||||
|
||||
if let local_zapper = state.profiles.lookup_zapper(pubkey: ptag) {
|
||||
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: local_zapper) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
state.add_zap(zap: .zap(zap))
|
||||
completion(.done(zap))
|
||||
return
|
||||
}
|
||||
|
||||
guard let lnurl = state.profiles.lookup_with_timestamp(ptag)
|
||||
.map({ pr in pr?.lnurl }).value else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
Task { [lnurl] in
|
||||
guard let zapper = await fetch_zapper_from_lnurl(lnurls: state.lnurls, pubkey: ptag, lnurl: lnurl) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
state.profiles.profile_data(ptag).zapper = zapper
|
||||
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: zapper) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
state.add_zap(zap: .zap(zap))
|
||||
completion(.done(zap))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// securely get the zap target's pubkey. this can be faked so we need to be
|
||||
// careful
|
||||
func get_zap_target_pubkey(ev: NostrEvent, ndb: Ndb) -> Pubkey? {
|
||||
let etags = Array(ev.referenced_ids)
|
||||
|
||||
guard let etag = etags.first else {
|
||||
// no etags, ptag-only case
|
||||
|
||||
guard let a = ev.referenced_pubkeys.just_one() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: just return data here
|
||||
return a
|
||||
}
|
||||
|
||||
// we have an e-tag
|
||||
|
||||
// ensure that there is only 1 etag to stop fake note zap attacks
|
||||
guard etags.count == 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// we can't trust the p tag on note zaps because they can be faked
|
||||
guard let pk = ndb.lookup_note(etag).unsafeUnownedValue?.pubkey else {
|
||||
// We don't have the event in cache so we can't check the pubkey.
|
||||
|
||||
// We could return this as an invalid zap but that wouldn't be correct
|
||||
// all of the time, and may reject valid zaps. What we need is a new
|
||||
// unvalidated zap state, but for now we simply leak a bit of correctness...
|
||||
|
||||
return ev.referenced_pubkeys.just_one()
|
||||
}
|
||||
|
||||
return pk
|
||||
}
|
||||
|
||||
fileprivate func process_zap_event_with_zapper(state: HeadlessDamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
|
||||
let our_keypair = state.keypair
|
||||
|
||||
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.add_zap(zap: .zap(zap))
|
||||
|
||||
return zap
|
||||
}
|
||||
|
||||
enum ProcessZapResult {
|
||||
case already_processed(Zap)
|
||||
case done(Zap)
|
||||
case failed
|
||||
|
||||
func get_zap() -> Zap? {
|
||||
switch self {
|
||||
case .already_processed(let zap):
|
||||
return zap
|
||||
case .done(let zap):
|
||||
return zap
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class ThreadModel: ObservableObject {
|
||||
}
|
||||
|
||||
if ev.known_kind == .zap {
|
||||
process_zap_event(damus_state: damus_state, ev: ev) { zap in
|
||||
process_zap_event(state: damus_state, ev: ev) { zap in
|
||||
|
||||
}
|
||||
} else if ev.is_textlike {
|
||||
|
||||
Reference in New Issue
Block a user