diff --git a/DamusNotificationService/NotificationExtensionState.swift b/DamusNotificationService/NotificationExtensionState.swift index 7bb1c810..ea23d391 100644 --- 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 index 06698259..8c9bec54 100644 --- 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 index dfdc354e..352fd1b6 100644 --- 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 index 80876cbd..3ef08435 100644 --- 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 = ""; }; D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = ""; }; D74AAFC42B1538DE006CF0F4 /* NotificationExtensionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtensionState.swift; sourceTree = ""; }; + D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeZapRequest.swift; sourceTree = ""; }; + D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapDataModel.swift; sourceTree = ""; }; + D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = ""; }; + D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = ""; }; D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = ""; }; D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = ""; }; D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = ""; }; @@ -1987,6 +2003,7 @@ D798D22B2B086C7400234419 /* NostrEvent+.swift */, D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */, B57B4C652B312C3700A232C0 /* NostrAuth.swift */, + D74AAFCB2B155D07006CF0F4 /* MakeZapRequest.swift */, ); path = Nostr; sourceTree = ""; @@ -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 = ""; @@ -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 index 3c546f9b..319fb4ad 100644 --- a/damus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme +++ b/damus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme @@ -59,7 +59,7 @@ + RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/7D0A5302-D07E-4C7C-B509-A7C552BD5A65/damus.app"> Bool } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index f7af6f0e..2e794fe5 100644 --- 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 index ea1cd16a..ba78359c 100644 --- 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 index 760b5010..06f63c7b 100644 --- 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 new file mode 100644 index 00000000..15f9f733 --- /dev/null +++ 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 index b87593f6..3e866d5e 100644 --- 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 index bc502ce5..ad8493a4 100644 --- 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 new file mode 100644 index 00000000..7271e1ab --- /dev/null +++ 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 { + let data = PayInvoiceRequest(invoice: invoice) + return WalletRequest(method: "pay_invoice", params: data) +} + +func make_wallet_balance_request() -> WalletRequest { + return WalletRequest(method: "get_balance", params: nil) +} + +struct EmptyRequest: Codable { +} + +struct PayInvoiceRequest: Codable { + let invoice: String +} + +func make_wallet_connect_request(req: WalletRequest, 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 index 79972987..496b0d90 100644 --- 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 { - let data = PayInvoiceRequest(invoice: invoice) - return WalletRequest(method: "pay_invoice", params: data) -} - -func make_wallet_balance_request() -> WalletRequest { - return WalletRequest(method: "get_balance", params: nil) -} - -struct EmptyRequest: Codable { -} - -struct PayInvoiceRequest: Codable { - let invoice: String -} - -func make_wallet_connect_request(req: WalletRequest, 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 index bb57d7ff..19f8282e 100644 --- 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 new file mode 100644 index 00000000..e5934497 --- /dev/null +++ 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 new file mode 100644 index 00000000..47d95e37 --- /dev/null +++ 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 index 2d353013..c16e0d0e 100644 --- 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 index cdcec76f..1570dcc0 100644 --- 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) {