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:
Daniel D’Aquino
2023-12-01 21:26:27 +00:00
committed by William Casarin
parent c4f0e833ff
commit 003482c971
19 changed files with 530 additions and 398 deletions

View File

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

View File

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

View File

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

View File

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