damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 003482c971da3a78c7d990e958970694ce5afe9f
parent c4f0e833ff1fb34fdebcba38f670c6f274defa80
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Fri,  1 Dec 2023 21:26:27 +0000

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>

Diffstat:
MDamusNotificationService/NotificationExtensionState.swift | 12++++++++++++
MDamusNotificationService/NotificationFormatter.swift | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MDamusNotificationService/NotificationService.swift | 7+++++--
Mdamus.xcodeproj/project.pbxproj | 32++++++++++++++++++++++++++++++++
Mdamus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme | 2+-
Mdamus/Models/HeadlessDamusState.swift | 5+++++
Mdamus/Models/HomeModel.swift | 144+++----------------------------------------------------------------------------
Mdamus/Models/NotificationsManager.swift | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mdamus/Models/ThreadModel.swift | 2+-
Adamus/Nostr/MakeZapRequest.swift | 36++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/NostrEvent+.swift | 77-----------------------------------------------------------------------------
Mdamus/Util/EventCache.swift | 43-------------------------------------------
Adamus/Util/WalletConnect+.swift | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Util/WalletConnect.swift | 109-------------------------------------------------------------------------------
Mdamus/Util/Zap.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/ZapDataModel.swift | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/Zaps+.swift | 15+++++++++++++++
Mdamus/Util/Zaps.swift | 7-------
Mdamus/Views/Events/TextEvent.swift | 15---------------
19 files changed, 530 insertions(+), 398 deletions(-)

diff --git a/DamusNotificationService/NotificationExtensionState.swift b/DamusNotificationService/NotificationExtensionState.swift @@ -14,6 +14,8 @@ struct NotificationExtensionState: HeadlessDamusState { let muted_threads: MutedThreadsManager let keypair: Keypair let profiles: Profiles + let zaps: Zaps + let lnurls: LNUrls init?() { guard let ndb = try? Ndb(owns_db_file: false) else { return nil } @@ -25,5 +27,15 @@ struct NotificationExtensionState: HeadlessDamusState { self.muted_threads = MutedThreadsManager(keypair: keypair) self.keypair = keypair self.profiles = Profiles(ndb: ndb) + self.zaps = Zaps(our_pubkey: keypair.pubkey) + self.lnurls = LNUrls() + } + + @discardableResult + func add_zap(zap: Zapping) -> Bool { + // store generic zap mapping + self.zaps.add_zap(zap: zap) + + return true } } diff --git a/DamusNotificationService/NotificationFormatter.swift b/DamusNotificationService/NotificationFormatter.swift @@ -49,7 +49,7 @@ struct NotificationFormatter { // MARK: - Formatting with LocalNotification - func format_message(displayName: String, notify: LocalNotification) -> (content: UNMutableNotificationContent, identifier: String) { + func format_message(displayName: String, notify: LocalNotification) -> (content: UNMutableNotificationContent, identifier: String)? { let content = UNMutableNotificationContent() var title = "" var identifier = "" @@ -68,8 +68,8 @@ struct NotificationFormatter { title = displayName identifier = "myDMNotification" case .zap, .profile_zap: - // not handled here - break + // not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?` + return nil } content.title = title content.body = notify.content @@ -78,4 +78,59 @@ struct NotificationFormatter { return (content, identifier) } + + func format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)? { + // Try sync method first and return if it works + if let sync_formatted_message = self.format_message(displayName: displayName, notify: notify) { + return sync_formatted_message + } + + // If it does not work, try async formatting methods + let content = UNMutableNotificationContent() + + switch notify.type { + case .zap, .profile_zap: + guard let zap = await get_zap(from: notify.event, state: state) else { + return nil + } + content.title = Self.zap_notification_title(zap) + content.body = Self.zap_notification_body(profiles: state.profiles, zap: zap) + content.sound = UNNotificationSound.default + content.userInfo = LossyLocalNotification(type: .zap, mention: .note(notify.event.id)).to_user_info() + return (content, "myZapNotification") + default: + // The sync method should have taken care of this. + return nil + } + } + + // MARK: - Formatting zap utility notifications + + static 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.") + } + } + + static 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) + } + } } diff --git a/DamusNotificationService/NotificationService.swift b/DamusNotificationService/NotificationService.swift @@ -52,8 +52,11 @@ class NotificationService: UNNotificationServiceExtension { return } - let (improvedContent, _) = NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object) - contentHandler(improvedContent) + Task { + if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) { + contentHandler(improvedContent) + } + } } override func serviceExtensionTimeWillExpire() { diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -451,6 +451,18 @@ D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; }; D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */; }; D74AAFC52B1538DF006CF0F4 /* NotificationExtensionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */; }; + D74AAFC62B155B8B006CF0F4 /* Zaps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883A72975FC1800DC99E7 /* Zaps.swift */; }; + D74AAFC72B155BD0006CF0F4 /* Zap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAE6297EFA7B00430951 /* Zap.swift */; }; + D74AAFC82B155C9D006CF0F4 /* InsertSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA728297703006E126D /* InsertSort.swift */; }; + D74AAFC92B155CA5006CF0F4 /* UpdateStatsNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA352A32A76AFF3003BB08B /* UpdateStatsNotify.swift */; }; + D74AAFCC2B155D07006CF0F4 /* MakeZapRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */; }; + D74AAFCD2B155D07006CF0F4 /* MakeZapRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */; }; + D74AAFCF2B155D8C006CF0F4 /* ZapDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */; }; + D74AAFD02B155D8C006CF0F4 /* ZapDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */; }; + D74AAFD12B155DA4006CF0F4 /* RelayURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FF93FF2AC7AC5200FD969D /* RelayURL.swift */; }; + D74AAFD22B155E78006CF0F4 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; }; + D74AAFD42B155ECB006CF0F4 /* Zaps+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */; }; + D74AAFD62B155F0C006CF0F4 /* WalletConnect+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */; }; D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; }; D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; }; D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; }; @@ -1324,6 +1336,10 @@ D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; }; D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = "<group>"; }; D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtensionState.swift; sourceTree = "<group>"; }; + D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeZapRequest.swift; sourceTree = "<group>"; }; + D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapDataModel.swift; sourceTree = "<group>"; }; + D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = "<group>"; }; + D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = "<group>"; }; D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; }; D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; }; D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; }; @@ -1987,6 +2003,7 @@ D798D22B2B086C7400234419 /* NostrEvent+.swift */, D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */, B57B4C652B312C3700A232C0 /* NostrAuth.swift */, + D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */, ); path = Nostr; sourceTree = "<group>"; @@ -2078,6 +2095,9 @@ 50C3E0892AA8E3F7006A4BC0 /* AVPlayer+Additions.swift */, D7EDED202B117DCA0018B19C /* SequenceUtils.swift */, D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */, + D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */, + D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */, + D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */, ); path = Util; sourceTree = "<group>"; @@ -2983,10 +3003,12 @@ 4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */, 4C3D52B8298DB5C6001C5831 /* TextEvent.swift in Sources */, 4C216F362870A9A700040376 /* InputDismissKeyboard.swift in Sources */, + D74AAFCF2B155D8C006CF0F4 /* ZapDataModel.swift in Sources */, 4C8D1A6F29F31E5000ACDF75 /* FriendsButton.swift in Sources */, 3A5E47C52A4A6CF400C0D090 /* Trie.swift in Sources */, B57B4C642B312BFA00A232C0 /* RelayAuthenticationDetail.swift in Sources */, D7EDED2E2B128E8A0018B19C /* CollectionExtension.swift in Sources */, + D74AAFD62B155F0C006CF0F4 /* WalletConnect+.swift in Sources */, 4C216F382871EDE300040376 /* DirectMessageModel.swift in Sources */, BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */, 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, @@ -3036,6 +3058,7 @@ 4C86F7C62A76C51100EC0817 /* AttachedWalletNotify.swift in Sources */, 4CF0ABE12981A83900D66079 /* MutelistView.swift in Sources */, 4CB883A82975FC1800DC99E7 /* Zaps.swift in Sources */, + D74AAFD42B155ECB006CF0F4 /* Zaps+.swift in Sources */, 4C75EFB128049D510006080F /* NostrResponse.swift in Sources */, 4C7D09592A05BEAD00943473 /* KeyboardVisible.swift in Sources */, 4CEE2AF7280B2DEA00AB5EEF /* ProfileName.swift in Sources */, @@ -3265,6 +3288,7 @@ 4C90BD1A283AA67F008EE7EF /* Bech32.swift in Sources */, E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */, 4CB8FC232A41ABA800763C51 /* AboutView.swift in Sources */, + D74AAFCC2B155D07006CF0F4 /* MakeZapRequest.swift in Sources */, 5C513FBA297F72980072348F /* CustomPicker.swift in Sources */, 4C1253622A76D00B0004F4B8 /* PostNotify.swift in Sources */, 4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */, @@ -3387,9 +3411,11 @@ D7CE1B1F2B0BE1B8002EDAD4 /* damus.c in Sources */, D7CE1B1B2B0BE144002EDAD4 /* emitter.c in Sources */, D7EDED342B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */, + D74AAFC72B155BD0006CF0F4 /* Zap.swift in Sources */, D7CB5D562B11759900AD4105 /* MuteThreadNotify.swift in Sources */, D7EDED182B1177A00018B19C /* LNUrlPayRequest.swift in Sources */, D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */, + D74AAFD02B155D8C006CF0F4 /* ZapDataModel.swift in Sources */, D7CB5D572B11762900AD4105 /* UserStatus.swift in Sources */, D7CE1B402B0BE719002EDAD4 /* FlatBufferObject.swift in Sources */, D7CE1B442B0BE719002EDAD4 /* Mutable.swift in Sources */, @@ -3399,14 +3425,18 @@ D7CB5D602B11770C00AD4105 /* FollowState.swift in Sources */, D7CB5D402B116E8A00AD4105 /* UserSettingsStore.swift in Sources */, D7CE1B1C2B0BE147002EDAD4 /* refmap.c in Sources */, + D74AAFC92B155CA5006CF0F4 /* UpdateStatsNotify.swift in Sources */, D7CE1B242B0BE1F1002EDAD4 /* hash_u5.c in Sources */, D79C4C172AFEB061003A41B4 /* NotificationService.swift in Sources */, D7CB5D522B1174D100AD4105 /* FriendFilter.swift in Sources */, D7CE1B362B0BE702002EDAD4 /* FbConstants.swift in Sources */, + D74AAFD12B155DA4006CF0F4 /* RelayURL.swift in Sources */, D7EDED272B117FF10018B19C /* CompatibleAttribute.swift in Sources */, D7CE1B222B0BE1EB002EDAD4 /* utf8.c in Sources */, + D74AAFCD2B155D07006CF0F4 /* MakeZapRequest.swift in Sources */, D7CCFC072B05833200323D86 /* NdbNote.swift in Sources */, D7CE1B3F2B0BE719002EDAD4 /* Enum.swift in Sources */, + D74AAFD22B155E78006CF0F4 /* WalletConnect.swift in Sources */, D7EDED222B117DCA0018B19C /* SequenceUtils.swift in Sources */, D7CE1B422B0BE719002EDAD4 /* Offset.swift in Sources */, D7FB10A72B0C371A00FA8D42 /* Log.swift in Sources */, @@ -3421,10 +3451,12 @@ D798D2242B0859C900234419 /* LocalizationUtil.swift in Sources */, D7CE1B322B0BE6C3002EDAD4 /* NdbTxn.swift in Sources */, D7CE1B372B0BE719002EDAD4 /* Verifier.swift in Sources */, + D74AAFC82B155C9D006CF0F4 /* InsertSort.swift in Sources */, D7EDED292B1182060018B19C /* AttachMediaUtility.swift in Sources */, D798D21A2B0856CC00234419 /* Mentions.swift in Sources */, D7CE1B212B0BE1CB002EDAD4 /* wasm.c in Sources */, D7CE1B3B2B0BE719002EDAD4 /* Int+extension.swift in Sources */, + D74AAFC62B155B8B006CF0F4 /* Zaps.swift in Sources */, D7CCFC0B2B0585EA00323D86 /* nostrdb.c in Sources */, D7CE1B252B0BE1F4002EDAD4 /* sha256.c in Sources */, D7CE1B262B0BE1F8002EDAD4 /* bech32.c in Sources */, diff --git a/damus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme b/damus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme @@ -59,7 +59,7 @@ <RemoteRunnable runnableDebuggingMode = "1" BundleIdentifier = "com.jb55.damus2" - RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/12BC3574-F80A-4852-869A-0D826412B040/damus.app"> + RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/7D0A5302-D07E-4C7C-B509-A7C552BD5A65/damus.app"> </RemoteRunnable> <MacroExpansion> <BuildableReference diff --git a/damus/Models/HeadlessDamusState.swift b/damus/Models/HeadlessDamusState.swift @@ -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 } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -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 -} - diff --git a/damus/Models/NotificationsManager.swift b/damus/Models/NotificationsManager.swift @@ -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 + } + } +} diff --git a/damus/Models/ThreadModel.swift b/damus/Models/ThreadModel.swift @@ -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 { diff --git a/damus/Nostr/MakeZapRequest.swift b/damus/Nostr/MakeZapRequest.swift @@ -0,0 +1,36 @@ +// +// MakeZapRequest.swift +// damus +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +enum MakeZapRequest { + case priv(ZapRequest, PrivateZapRequest) + case normal(ZapRequest) + + var private_inner_request: ZapRequest { + switch self { + case .priv(_, let pzr): + return pzr.req + case .normal(let zr): + return zr + } + } + + var potentially_anon_outer_request: ZapRequest { + switch self { + case .priv(let zr, _): + return zr + case .normal(let zr): + return zr + } + } +} + +struct PrivateZapRequest { + let req: ZapRequest + let enc: String +} diff --git a/damus/Nostr/NostrEvent+.swift b/damus/Nostr/NostrEvent+.swift @@ -62,11 +62,6 @@ func zap_target_to_tags(_ target: ZapTarget) -> [[String]] { } } -struct PrivateZapRequest { - let req: ZapRequest - let enc: String -} - func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> PrivateZapRequest? { // target tags must be the same as zap request target tags let tags = zap_target_to_tags(target) @@ -81,78 +76,6 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, return PrivateZapRequest(req: ZapRequest(ev: note), enc: enc) } -func decrypt_private_zap(our_privkey: Privkey, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? { - guard let anon_tag = zapreq.tags.first(where: { t in - t.count >= 2 && t[0].matches_str("anon") - }) else { - return nil - } - - let enc_note = anon_tag[1].string() - - var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32) - - // check to see if the private note was from us - if note == nil { - guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: NoteId(target.id), created_at: zapreq.created_at) else { - return nil - } - // use our private keypair and their pubkey to get the shared secret - note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32) - } - - guard let note else { - return nil - } - - guard note.kind == 9733 else { - return nil - } - - let zr_etag = zapreq.referenced_ids.first - let note_etag = note.referenced_ids.first - - guard zr_etag == note_etag else { - return nil - } - - let zr_ptag = zapreq.referenced_pubkeys.first - let note_ptag = note.referenced_pubkeys.first - - guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else { - return nil - } - - guard validate_event(ev: note) == .ok else { - return nil - } - - return note -} - -enum MakeZapRequest { - case priv(ZapRequest, PrivateZapRequest) - case normal(ZapRequest) - - var private_inner_request: ZapRequest { - switch self { - case .priv(_, let pzr): - return pzr.req - case .normal(let zr): - return zr - } - } - - var potentially_anon_outer_request: ZapRequest { - switch self { - case .priv(let zr, _): - return zr - case .normal(let zr): - return zr - } - } -} - func make_first_contact_event(keypair: Keypair) -> NostrEvent? { let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey) let rw_relay_info = RelayInfo(read: true, write: true) diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift @@ -54,49 +54,6 @@ class PreviewModel: ObservableObject { } } -class ZapsDataModel: ObservableObject { - @Published var zaps: [Zapping] - - init(_ zaps: [Zapping]) { - self.zaps = zaps - } - - func confirm_nwc(reqid: NoteId) { - guard let zap = zaps.first(where: { z in z.request.ev.id == reqid }), - case .pending(let pzap) = zap - else { - return - } - - switch pzap.state { - case .external: - break - case .nwc(let nwc_state): - if nwc_state.update_state(state: .confirmed) { - self.objectWillChange.send() - } - } - } - - var zap_total: Int64 { - zaps.reduce(0) { total, zap in total + zap.amount } - } - - func from(_ pubkey: Pubkey) -> [Zapping] { - return self.zaps.filter { z in z.request.ev.pubkey == pubkey } - } - - @discardableResult - func remove(reqid: ZapRequestId) -> Bool { - guard zaps.first(where: { z in z.request.id == reqid }) != nil else { - return false - } - - self.zaps = zaps.filter { z in z.request.id != reqid } - return true - } -} - class RelativeTimeModel: ObservableObject { @Published var value: String = "" } diff --git a/damus/Util/WalletConnect+.swift b/damus/Util/WalletConnect+.swift @@ -0,0 +1,118 @@ +// +// WalletConnect+.swift +// damus +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest<PayInvoiceRequest> { + let data = PayInvoiceRequest(invoice: invoice) + return WalletRequest(method: "pay_invoice", params: data) +} + +func make_wallet_balance_request() -> WalletRequest<EmptyRequest> { + return WalletRequest(method: "get_balance", params: nil) +} + +struct EmptyRequest: Codable { +} + +struct PayInvoiceRequest: Codable { + let invoice: String +} + +func make_wallet_connect_request<T>(req: WalletRequest<T>, to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? { + let tags = [to_pk.tag] + let created_at = UInt32(Date().timeIntervalSince1970) + guard let content = encode_json(req) else { + return nil + } + return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194) +} + +func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) { + var filter = NostrFilter(kinds: [.nwc_response]) + filter.authors = [url.pubkey] + filter.limit = 0 + let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") + + pool.send(.subscribe(sub), to: [url.relay.id], skip_ephemeral: false) +} + +@discardableResult +func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { + let req = make_wallet_pay_invoice_request(invoice: invoice) + guard let ev = make_wallet_connect_request(req: req, to_pk: url.pubkey, keypair: url.keypair) else { + return nil + } + + try? pool.add_relay(.nwc(url: url.relay)) + subscribe_to_nwc(url: url, pool: pool) + post.send(ev, to: [url.relay.id], skip_ephemeral: false, delay: delay, on_flush: on_flush) + return ev +} + + +func nwc_success(state: DamusState, resp: FullWalletResponse) { + // find the pending zap and mark it as pending-confirmed + for kv in state.zaps.our_zaps { + let zaps = kv.value + + for zap in zaps { + guard case .pending(let pzap) = zap, + case .nwc(let nwc_state) = pzap.state, + case .postbox_pending(let nwc_req) = nwc_state.state, + nwc_req.id == resp.req_id + else { + continue + } + + if nwc_state.update_state(state: .confirmed) { + // notify the zaps model of an update so it can mark them as paid + state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send() + print("NWC success confirmed") + } + + return + } + } +} + +func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async { + let percent_f = Double(percent) / 100.0 + let donations_msats = Int64(percent_f * Double(base_msats)) + + let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus") + guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else { + // we failed... oh well. no donation for us. + print("damus-donation failed to fetch invoice") + return + } + + print("damus-donation donating...") + nwc_pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) +} + +func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) { + // find a pending zap with the nwc request id associated with this response and remove it + for kv in zapcache.our_zaps { + let zaps = kv.value + + for zap in zaps { + guard case .pending(let pzap) = zap, + case .nwc(let nwc_state) = pzap.state, + case .postbox_pending(let req) = nwc_state.state, + req.id == resp.req_id + else { + continue + } + + // remove the pending zap if there was an error + let reqid = ZapRequestId(from_pending: pzap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) + return + } + } +} diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift @@ -153,112 +153,3 @@ struct WalletResponse: Decodable { } } -func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest<PayInvoiceRequest> { - let data = PayInvoiceRequest(invoice: invoice) - return WalletRequest(method: "pay_invoice", params: data) -} - -func make_wallet_balance_request() -> WalletRequest<EmptyRequest> { - return WalletRequest(method: "get_balance", params: nil) -} - -struct EmptyRequest: Codable { -} - -struct PayInvoiceRequest: Codable { - let invoice: String -} - -func make_wallet_connect_request<T>(req: WalletRequest<T>, to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? { - let tags = [to_pk.tag] - let created_at = UInt32(Date().timeIntervalSince1970) - guard let content = encode_json(req) else { - return nil - } - return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194) -} - -func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) { - var filter = NostrFilter(kinds: [.nwc_response]) - filter.authors = [url.pubkey] - filter.limit = 0 - let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") - - pool.send(.subscribe(sub), to: [url.relay.id], skip_ephemeral: false) -} - -@discardableResult -func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { - let req = make_wallet_pay_invoice_request(invoice: invoice) - guard let ev = make_wallet_connect_request(req: req, to_pk: url.pubkey, keypair: url.keypair) else { - return nil - } - - try? pool.add_relay(.nwc(url: url.relay)) - subscribe_to_nwc(url: url, pool: pool) - post.send(ev, to: [url.relay.id], skip_ephemeral: false, delay: delay, on_flush: on_flush) - return ev -} - - -func nwc_success(state: DamusState, resp: FullWalletResponse) { - // find the pending zap and mark it as pending-confirmed - for kv in state.zaps.our_zaps { - let zaps = kv.value - - for zap in zaps { - guard case .pending(let pzap) = zap, - case .nwc(let nwc_state) = pzap.state, - case .postbox_pending(let nwc_req) = nwc_state.state, - nwc_req.id == resp.req_id - else { - continue - } - - if nwc_state.update_state(state: .confirmed) { - // notify the zaps model of an update so it can mark them as paid - state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send() - print("NWC success confirmed") - } - - return - } - } -} - -func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async { - let percent_f = Double(percent) / 100.0 - let donations_msats = Int64(percent_f * Double(base_msats)) - - let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus") - guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else { - // we failed... oh well. no donation for us. - print("damus-donation failed to fetch invoice") - return - } - - print("damus-donation donating...") - nwc_pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) -} - -func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) { - // find a pending zap with the nwc request id associated with this response and remove it - for kv in zapcache.our_zaps { - let zaps = kv.value - - for zap in zaps { - guard case .pending(let pzap) = zap, - case .nwc(let nwc_state) = pzap.state, - case .postbox_pending(let req) = nwc_state.state, - req.id == resp.req_id - else { - continue - } - - // remove the pending zap if there was an error - let reqid = ZapRequestId(from_pending: pzap) - remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) - return - } - } -} diff --git a/damus/Util/Zap.swift b/damus/Util/Zap.swift @@ -336,6 +336,69 @@ struct Zap { } } +func decrypt_private_zap(our_privkey: Privkey, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? { + guard let anon_tag = zapreq.tags.first(where: { t in + t.count >= 2 && t[0].matches_str("anon") + }) else { + return nil + } + + let enc_note = anon_tag[1].string() + + var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32) + + // check to see if the private note was from us + if note == nil { + guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: NoteId(target.id), created_at: zapreq.created_at) else { + return nil + } + // use our private keypair and their pubkey to get the shared secret + note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32) + } + + guard let note else { + return nil + } + + guard note.kind == 9733 else { + return nil + } + + let zr_etag = zapreq.referenced_ids.first + let note_etag = note.referenced_ids.first + + guard zr_etag == note_etag else { + return nil + } + + let zr_ptag = zapreq.referenced_pubkeys.first + let note_ptag = note.referenced_pubkeys.first + + guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else { + return nil + } + + guard validate_event(ev: note) == .ok else { + return nil + } + + return note +} + +func event_is_anonymous(ev: NostrEvent) -> Bool { + return ev.known_kind == .zap_request && event_has_tag(ev: ev, tag: "anon") +} + +func event_has_tag(ev: NostrEvent, tag: String) -> Bool { + for t in ev.tags { + if t.count >= 1 && t[0].matches_str(tag) { + return true + } + } + + return false +} + /// Fetches the description from either the invoice, or tags, depending on the type of invoice func get_zap_description(_ ev: NostrEvent, inv_desc: InvoiceDescription) -> String? { switch inv_desc { diff --git a/damus/Util/ZapDataModel.swift b/damus/Util/ZapDataModel.swift @@ -0,0 +1,51 @@ +// +// ZapDataModel.swift +// damus +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +class ZapsDataModel: ObservableObject { + @Published var zaps: [Zapping] + + init(_ zaps: [Zapping]) { + self.zaps = zaps + } + + func confirm_nwc(reqid: NoteId) { + guard let zap = zaps.first(where: { z in z.request.ev.id == reqid }), + case .pending(let pzap) = zap + else { + return + } + + switch pzap.state { + case .external: + break + case .nwc(let nwc_state): + if nwc_state.update_state(state: .confirmed) { + self.objectWillChange.send() + } + } + } + + var zap_total: Int64 { + zaps.reduce(0) { total, zap in total + zap.amount } + } + + func from(_ pubkey: Pubkey) -> [Zapping] { + return self.zaps.filter { z in z.request.ev.pubkey == pubkey } + } + + @discardableResult + func remove(reqid: ZapRequestId) -> Bool { + guard zaps.first(where: { z in z.request.id == reqid }) != nil else { + return false + } + + self.zaps = zaps.filter { z in z.request.id != reqid } + return true + } +} diff --git a/damus/Util/Zaps+.swift b/damus/Util/Zaps+.swift @@ -0,0 +1,15 @@ +// +// Zaps+.swift +// damus +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +func remove_zap(reqid: ZapRequestId, zapcache: Zaps, evcache: EventCache) { + guard let zap = zapcache.remove_zap(reqid: reqid.reqid) else { + return + } + evcache.get_cache_data(NoteId(zap.target.id)).zaps_model.remove(reqid: reqid) +} diff --git a/damus/Util/Zaps.swift b/damus/Util/Zaps.swift @@ -99,10 +99,3 @@ class Zaps { } } } - -func remove_zap(reqid: ZapRequestId, zapcache: Zaps, evcache: EventCache) { - guard let zap = zapcache.remove_zap(reqid: reqid.reqid) else { - return - } - evcache.get_cache_data(NoteId(zap.target.id)).zaps_model.remove(reqid: reqid) -} diff --git a/damus/Views/Events/TextEvent.swift b/damus/Views/Events/TextEvent.swift @@ -57,21 +57,6 @@ struct TextEvent: View { } -func event_has_tag(ev: NostrEvent, tag: String) -> Bool { - for t in ev.tags { - if t.count >= 1 && t[0].matches_str(tag) { - return true - } - } - - return false -} - - -func event_is_anonymous(ev: NostrEvent) -> Bool { - return ev.known_kind == .zap_request && event_has_tag(ev: ev, tag: "anon") -} - struct TextEvent_Previews: PreviewProvider { static var previews: some View { VStack(spacing: 20) {