damus

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

commit ad75d8546c40ebabf0a23f3a745aaae66e1063fa
parent 878b1caa952621265007c6551f3397843d76e7a0
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Tue, 14 Nov 2023 07:21:39 +0000

Add experimental push notification support

I added support for the experimental push notifications feature. There are many improvements to be made, so this feature is currently opt-in only. If the user does not opt-in, their device tokens will not be sent out and thus they will receive no push notifications.

We should perform more testing on real-life staging environments before fully releasing this feature.

Testing
-------

Testing was done gradually during development.

Device: iOS simulators
iOS: 17
Damus version: A few different but recent prototypes
Rough coverage:
1. Checked that no device tokens are sent out when setting is off
2. Checked that I can successfully receive device tokens when feature is ON and set to localhost.
3. Checked sending test push notifications of types "note" (kind: 1), reaction (kind: 7) and DMs (kind 4) works and shows a generic but reasonable push notification message
4. Checked that clicking on the notifications above take the user to the correct screen

Closes: https://github.com/damus-io/damus/issues/67
Changelog-Added: Add experimental push notification support
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
ADamusNotificationService/DamusNotificationService.entitlements | 14++++++++++++++
ADamusNotificationService/Info.plist | 13+++++++++++++
ADamusNotificationService/NostrEventInfoFromPushNotification.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
ADamusNotificationService/NotificationFormatter.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
ADamusNotificationService/NotificationService.swift | 39+++++++++++++++++++++++++++++++++++++++
Mdamus.xcodeproj/project.pbxproj | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Adamus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/ContentView.swift | 4+++-
Mdamus/Models/UserSettingsStore.swift | 6++++++
Mdamus/Util/LocalNotification.swift | 35+++++++++++++++++++++++++++++++++++
Mdamus/Views/Settings/DeveloperSettingsView.swift | 7++++++-
Mdamus/damus.entitlements | 4++++
Mdamus/damusApp.swift | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++--
13 files changed, 562 insertions(+), 5 deletions(-)

diff --git a/DamusNotificationService/DamusNotificationService.entitlements b/DamusNotificationService/DamusNotificationService.entitlements @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.app-sandbox</key> + <true/> + <key>com.apple.security.application-groups</key> + <array> + <string>group.com.damus</string> + </array> + <key>com.apple.security.network.client</key> + <true/> +</dict> +</plist> diff --git a/DamusNotificationService/Info.plist b/DamusNotificationService/Info.plist @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>NSExtension</key> + <dict> + <key>NSExtensionPointIdentifier</key> + <string>com.apple.usernotifications.service</string> + <key>NSExtensionPrincipalClass</key> + <string>$(PRODUCT_MODULE_NAME).NotificationService</string> + </dict> +</dict> +</plist> diff --git a/DamusNotificationService/NostrEventInfoFromPushNotification.swift b/DamusNotificationService/NostrEventInfoFromPushNotification.swift @@ -0,0 +1,49 @@ +// +// NostrEventInfoFromPushNotification.swift +// DamusNotificationService +// +// Created by Daniel D’Aquino on 2023-11-13. +// + +import Foundation + +/// The representation of a JSON-encoded Nostr Event used by the push notification server +/// Needs to match with https://gitlab.com/soapbox-pub/strfry-policies/-/raw/433459d8084d1f2d6500fdf916f22caa3b4d7be5/src/types.ts +struct NostrEventInfoFromPushNotification: Codable { + let id: String // Hex-encoded + let sig: String // Hex-encoded + let kind: NostrKind + let tags: [[String]] + let pubkey: String // Hex-encoded + let content: String + let created_at: Int + + static func from(dictionary: [AnyHashable: Any]) -> NostrEventInfoFromPushNotification? { + guard let id = dictionary["id"] as? String, + let sig = dictionary["sig"] as? String, + let kind_int = dictionary["kind"] as? UInt32, + let kind = NostrKind(rawValue: kind_int), + let tags = dictionary["tags"] as? [[String]], + let pubkey = dictionary["pubkey"] as? String, + let content = dictionary["content"] as? String, + let created_at = dictionary["created_at"] as? Int else { + return nil + } + return NostrEventInfoFromPushNotification(id: id, sig: sig, kind: kind, tags: tags, pubkey: pubkey, content: content, created_at: created_at) + } + + func reactionEmoji() -> String? { + guard self.kind == NostrKind.like else { + return nil + } + + switch self.content { + case "", "+": + return "❤️" + case "-": + return "👎" + default: + return self.content + } + } +} diff --git a/DamusNotificationService/NotificationFormatter.swift b/DamusNotificationService/NotificationFormatter.swift @@ -0,0 +1,48 @@ +// +// NotificationFormatter.swift +// DamusNotificationService +// +// Created by Daniel D’Aquino on 2023-11-13. +// + +import Foundation +import UserNotifications + +struct NotificationFormatter { + static var shared = NotificationFormatter() + + // TODO: These is a very generic notification formatter. Once we integrate NostrDB into the extension, we should reuse various functions present in `HomeModel.swift` + func formatMessage(event: NostrEventInfoFromPushNotification) -> UNNotificationContent? { + let content = UNMutableNotificationContent() + if let event_json_data = try? JSONEncoder().encode(event), // Must be encoded, as the notification completion handler requires this object to conform to `NSSecureCoding` + let event_json_string = String(data: event_json_data, encoding: .utf8) { + content.userInfo = [ + "nostr_event_info": event_json_string + ] + } + switch event.kind { + case .text: + content.title = NSLocalizedString("Someone posted a note", comment: "Title label for push notification where someone posted a note") + content.body = event.content + break + case .dm: + content.title = NSLocalizedString("New message", comment: "Title label for push notifications where a direct message was sent to the user") + content.body = NSLocalizedString("(Contents are encrypted)", comment: "Label on push notification indicating that the contents of the message are encrypted") + break + case .like: + guard let reactionEmoji = event.reactionEmoji() else { + content.title = NSLocalizedString("Someone reacted to your note", comment: "Generic title label for push notifications where someone reacted to the user's post") + break + } + content.title = NSLocalizedString("New note reaction", comment: "Title label for push notifications where someone reacted to the user's post with a specific emoji") + content.body = String(format: NSLocalizedString("Someone reacted to your note with %@", comment: "Body label for push notifications where someone reacted to the user's post with a specific emoji"), reactionEmoji) + break + case .zap: + content.title = NSLocalizedString("Someone zapped you ⚡️", comment: "Title label for a push notification where someone zapped the user") + break + default: + return nil + } + return content + } +} diff --git a/DamusNotificationService/NotificationService.swift b/DamusNotificationService/NotificationService.swift @@ -0,0 +1,39 @@ +// +// NotificationService.swift +// DamusNotificationService +// +// Created by Daniel D’Aquino on 2023-11-10. +// + +import UserNotifications +import Foundation + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + + // Modify the notification content here... + guard let nostrEventInfoDictionary = request.content.userInfo["nostr_event"] as? [AnyHashable: Any], + let nostrEventInfo = NostrEventInfoFromPushNotification.from(dictionary: nostrEventInfoDictionary) else { + contentHandler(request.content) + return; + } + + if let improvedContent = NotificationFormatter.shared.formatMessage(event: nostrEventInfo) { + contentHandler(improvedContent) + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -429,6 +429,8 @@ BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; + D70A3B172B02DCE5008BD568 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; }; + D70A3B192B02DD2D008BD568 /* NostrEventInfoFromPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B182B02DD2D008BD568 /* NostrEventInfoFromPushNotification.swift */; }; D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; }; D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723C38D2AB8D83400065664 /* ContentFilters.swift */; }; D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */; }; @@ -442,8 +444,13 @@ D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; }; D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; + D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; }; + D79C4C172AFEB061003A41B4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C4C162AFEB061003A41B4 /* NotificationService.swift */; }; + D79C4C1B2AFEB061003A41B4 /* DamusNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */; }; D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343EF2AD0D77C00CED48B /* SnapshotTesting */; }; + D7DBD41F2B02F15E002A6197 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; }; + D7DBD4202B0307C7002A6197 /* NostrEventInfoFromPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B182B02DD2D008BD568 /* NostrEventInfoFromPushNotification.swift */; }; D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; }; D7FF94002AC7AC5300FD969D /* RelayURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FF93FF2AC7AC5200FD969D /* RelayURL.swift */; }; E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; @@ -481,8 +488,29 @@ remoteGlobalIDString = 4CE6DEE227F7A08100C66700; remoteInfo = damus; }; + D79C4C192AFEB061003A41B4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4CE6DEDB27F7A08100C66700 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D79C4C132AFEB061003A41B4; + remoteInfo = DamusNotificationService; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + D79C4C1D2AFEB061003A41B4 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D79C4C1B2AFEB061003A41B4 /* DamusNotificationService.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrFilter+Hashable.swift"; sourceTree = "<group>"; }; 3165648A295B70D500C64604 /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = "<group>"; }; @@ -1130,6 +1158,8 @@ BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; }; + D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFormatter.swift; sourceTree = "<group>"; }; + D70A3B182B02DD2D008BD568 /* NostrEventInfoFromPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventInfoFromPushNotification.swift; sourceTree = "<group>"; }; D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = "<group>"; }; D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = "<group>"; }; D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventViewTests.swift; sourceTree = "<group>"; }; @@ -1143,6 +1173,10 @@ D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; }; + D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DamusNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D79C4C162AFEB061003A41B4 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; + D79C4C182AFEB061003A41B4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; + D79C4C1C2AFEB061003A41B4 /* DamusNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DamusNotificationService.entitlements; sourceTree = "<group>"; }; D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; }; D7FF93FF2AC7AC5200FD969D /* RelayURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayURL.swift; sourceTree = "<group>"; }; E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = "<group>"; }; @@ -1192,6 +1226,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D79C4C112AFEB061003A41B4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -2114,6 +2156,7 @@ 4CE6DEE527F7A08100C66700 /* damus */, 4CE6DEF627F7A08200C66700 /* damusTests */, 4CE6DF0027F7A08200C66700 /* damusUITests */, + D79C4C152AFEB061003A41B4 /* DamusNotificationService */, 4CE6DEE427F7A08100C66700 /* Products */, 4CEE2AE62804F57B00AB5EEF /* Frameworks */, ); @@ -2127,6 +2170,7 @@ 4CE6DEE327F7A08100C66700 /* damus.app */, 4CE6DEF327F7A08200C66700 /* damusTests.xctest */, 4CE6DEFD27F7A08200C66700 /* damusUITests.xctest */, + D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */, ); name = Products; sourceTree = "<group>"; @@ -2339,6 +2383,18 @@ path = Mocking; sourceTree = "<group>"; }; + D79C4C152AFEB061003A41B4 /* DamusNotificationService */ = { + isa = PBXGroup; + children = ( + D79C4C1C2AFEB061003A41B4 /* DamusNotificationService.entitlements */, + D79C4C162AFEB061003A41B4 /* NotificationService.swift */, + D79C4C182AFEB061003A41B4 /* Info.plist */, + D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */, + D70A3B182B02DD2D008BD568 /* NostrEventInfoFromPushNotification.swift */, + ); + path = DamusNotificationService; + sourceTree = "<group>"; + }; F71694E82A66221E001F4053 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -2378,10 +2434,12 @@ 4CE6DEE027F7A08100C66700 /* Frameworks */, 4C1D4FB22A7965230024F453 /* ShellScript */, 4CE6DEE127F7A08100C66700 /* Resources */, + D79C4C1D2AFEB061003A41B4 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + D79C4C1A2AFEB061003A41B4 /* PBXTargetDependency */, ); name = damus; packageProductDependencies = ( @@ -2433,6 +2491,26 @@ productReference = 4CE6DEFD27F7A08200C66700 /* damusUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + D79C4C132AFEB061003A41B4 /* DamusNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = D79C4C202AFEB061003A41B4 /* Build configuration list for PBXNativeTarget "DamusNotificationService" */; + buildPhases = ( + D79C4C102AFEB061003A41B4 /* Sources */, + D79C4C112AFEB061003A41B4 /* Frameworks */, + D79C4C122AFEB061003A41B4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DamusNotificationService; + packageProductDependencies = ( + D789D11F2AFEFBF20083A7AB /* secp256k1 */, + ); + productName = DamusNotificationService; + productReference = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2440,7 +2518,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1330; + LastSwiftUpdateCheck = 1500; LastUpgradeCheck = 1500; TargetAttributes = { 4CE6DEE227F7A08100C66700 = { @@ -2455,6 +2533,9 @@ CreatedOnToolsVersion = 13.3; TestTargetID = 4CE6DEE227F7A08100C66700; }; + D79C4C132AFEB061003A41B4 = { + CreatedOnToolsVersion = 15.0.1; + }; }; }; buildConfigurationList = 4CE6DEDE27F7A08100C66700 /* Build configuration list for PBXProject "damus" */; @@ -2508,6 +2589,7 @@ 4CE6DEE227F7A08100C66700 /* damus */, 4CE6DEF227F7A08200C66700 /* damusTests */, 4CE6DEFC27F7A08200C66700 /* damusUITests */, + D79C4C132AFEB061003A41B4 /* DamusNotificationService */, ); }; /* End PBXProject section */ @@ -2546,6 +2628,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D79C4C122AFEB061003A41B4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2713,6 +2802,7 @@ 9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */, 4C1253502A76C5B20004F4B8 /* UnfollowedNotify.swift in Sources */, 4C86F7C62A76C51100EC0817 /* AttachedWalletNotify.swift in Sources */, + D7DBD4202B0307C7002A6197 /* NostrEventInfoFromPushNotification.swift in Sources */, 4CF0ABE12981A83900D66079 /* MutelistView.swift in Sources */, 4CB883A82975FC1800DC99E7 /* Zaps.swift in Sources */, 4C75EFB128049D510006080F /* NostrResponse.swift in Sources */, @@ -3032,6 +3122,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D79C4C102AFEB061003A41B4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D79C4C172AFEB061003A41B4 /* NotificationService.swift in Sources */, + D70A3B192B02DD2D008BD568 /* NostrEventInfoFromPushNotification.swift in Sources */, + D7DBD41F2B02F15E002A6197 /* NostrKind.swift in Sources */, + D70A3B172B02DCE5008BD568 /* NotificationFormatter.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -3045,6 +3146,11 @@ target = 4CE6DEE227F7A08100C66700 /* damus */; targetProxy = 4CE6DEFE27F7A08200C66700 /* PBXContainerItemProxy */; }; + D79C4C1A2AFEB061003A41B4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D79C4C132AFEB061003A41B4 /* DamusNotificationService */; + targetProxy = D79C4C192AFEB061003A41B4 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -3284,6 +3390,7 @@ 4CE6DF0827F7A08200C66700 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3333,6 +3440,7 @@ 4CE6DF0927F7A08200C66700 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3454,6 +3562,73 @@ }; name = Release; }; + D79C4C1E2AFEB061003A41B4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = DamusNotificationService/DamusNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XK7H4JAB3D; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DamusNotificationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DamusNotificationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D79C4C1F2AFEB061003A41B4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = DamusNotificationService/DamusNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XK7H4JAB3D; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DamusNotificationService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DamusNotificationService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3493,6 +3668,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D79C4C202AFEB061003A41B4 /* Build configuration list for PBXNativeTarget "DamusNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D79C4C1E2AFEB061003A41B4 /* Debug */, + D79C4C1F2AFEB061003A41B4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -3554,6 +3738,11 @@ package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; productName = secp256k1; }; + D789D11F2AFEFBF20083A7AB /* secp256k1 */ = { + isa = XCSwiftPackageProductDependency; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + productName = secp256k1; + }; D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */ = { isa = XCSwiftPackageProductDependency; package = D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; diff --git a/damus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme b/damus.xcodeproj/xcshareddata/xcschemes/DamusNotificationService.xcscheme @@ -0,0 +1,100 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1500" + wasCreatedForAppExtension = "YES" + version = "2.0"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "D79C4C132AFEB061003A41B4" + BuildableName = "DamusNotificationService.appex" + BlueprintName = "DamusNotificationService" + ReferencedContainer = "container:damus.xcodeproj"> + </BuildableReference> + </BuildActionEntry> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "4CE6DEE227F7A08100C66700" + BuildableName = "damus.app" + BlueprintName = "damus" + ReferencedContainer = "container:damus.xcodeproj"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "" + selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn" + launchStyle = "0" + askForAppToLaunch = "Yes" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES" + launchAutomaticallySubstyle = "2"> + <RemoteRunnable + runnableDebuggingMode = "1" + BundleIdentifier = "com.jb55.damus2" + RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/5A083DD0-FDE2-43D7-9172-2F97FAD18F20/damus.app"> + </RemoteRunnable> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "4CE6DEE227F7A08100C66700" + BuildableName = "damus.app" + BlueprintName = "damus" + ReferencedContainer = "container:damus.xcodeproj"> + </BuildableReference> + </MacroExpansion> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" + launchAutomaticallySubstyle = "2"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "4CE6DEE227F7A08100C66700" + BuildableName = "damus.app" + BlueprintName = "damus" + ReferencedContainer = "container:damus.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -54,6 +54,7 @@ enum Sheets: Identifiable { struct ContentView: View { let keypair: Keypair + let appDelegate: AppDelegate? var pubkey: Pubkey { return keypair.pubkey @@ -303,6 +304,7 @@ struct ContentView: View { active_sheet = .onboardingSuggestions hasSeenOnboardingSuggestions = true } + self.appDelegate?.settings = damus_state?.settings } .sheet(item: $active_sheet) { item in switch item { @@ -694,7 +696,7 @@ struct ContentView: View { struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil)) + ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil) } } diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -191,6 +191,12 @@ class UserSettingsStore: ObservableObject { @Setting(key: "always_show_onboarding_suggestions", default_value: false) var always_show_onboarding_suggestions: Bool + + @Setting(key: "enable_experimental_push_notifications", default_value: false) + var enable_experimental_push_notifications: Bool + + @Setting(key: "send_device_token_to_localhost", default_value: false) + var send_device_token_to_localhost: Bool @Setting(key: "emoji_reactions", default_value: default_emoji_reactions) var emoji_reactions: [String] diff --git a/damus/Util/LocalNotification.swift b/damus/Util/LocalNotification.swift @@ -19,6 +19,9 @@ struct LossyLocalNotification { } static func from_user_info(user_info: [AnyHashable: Any]) -> LossyLocalNotification? { + if let encoded_nostr_event_push_data = user_info["nostr_event_info"] as? String { + return self.from(encoded_nostr_event_push_data: encoded_nostr_event_push_data) + } guard let id = user_info["id"] as? String, let target_id = MentionRef.from_bech32(str: id) else { return nil @@ -28,6 +31,21 @@ struct LossyLocalNotification { return LossyLocalNotification(type: type, mention: target_id) } + + static func from(encoded_nostr_event_push_data: String) -> LossyLocalNotification? { + guard let json_data = encoded_nostr_event_push_data.data(using: .utf8), + let nostr_event_push_data = try? JSONDecoder().decode(NostrEventInfoFromPushNotification.self, from: json_data) else { + return nil + } + return self.from(nostr_event_push_data: nostr_event_push_data) + } + + static func from(nostr_event_push_data: NostrEventInfoFromPushNotification) -> LossyLocalNotification? { + guard let type = LocalNotificationType.from(nostr_kind: nostr_event_push_data.kind) else { return nil } + guard let note_id: NoteId = NoteId.init(hex: nostr_event_push_data.id) else { return nil } + let target: MentionRef = .note(note_id) + return LossyLocalNotification(type: type, mention: target) + } } struct LocalNotification { @@ -48,4 +66,21 @@ enum LocalNotificationType: String { case repost case zap case profile_zap + + static func from(nostr_kind: NostrKind) -> Self? { + switch nostr_kind { + case .text: + return .mention + case .dm: + return .dm + case .like: + return .like + case .longform: + return .mention + case .zap: + return .zap + default: + return nil + } + } } diff --git a/damus/Views/Settings/DeveloperSettingsView.swift b/damus/Views/Settings/DeveloperSettingsView.swift @@ -17,7 +17,12 @@ struct DeveloperSettingsView: View { Toggle(NSLocalizedString("Developer Mode", comment: "Setting to enable developer mode"), isOn: $settings.developer_mode) .toggleStyle(.switch) if settings.developer_mode { - Toggle(NSLocalizedString("Always show onboarding", comment: "Setting to always show onboarding suggestions, for developers who need to test onboarding"), isOn: $settings.always_show_onboarding_suggestions) + Toggle("Always show onboarding", isOn: $settings.always_show_onboarding_suggestions) + + Toggle("Enable experimental push notifications", isOn: $settings.enable_experimental_push_notifications) + .toggleStyle(.switch) + + Toggle("Send device token to localhost", isOn: $settings.send_device_token_to_localhost) .toggleStyle(.switch) } } diff --git a/damus/damus.entitlements b/damus/damus.entitlements @@ -13,6 +13,10 @@ <true/> <key>com.apple.security.app-sandbox</key> <true/> + <key>com.apple.security.application-groups</key> + <array> + <string>group.com.damus</string> + </array> <key>com.apple.security.device.audio-input</key> <true/> <key>com.apple.security.device.camera</key> diff --git a/damus/damusApp.swift b/damus/damusApp.swift @@ -12,7 +12,7 @@ struct damusApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup { - MainView() + MainView(appDelegate: appDelegate) } } } @@ -21,11 +21,12 @@ struct MainView: View { @State var needs_setup = false; @State var keypair: Keypair? = nil; @StateObject private var orientationTracker = OrientationTracker() + var appDelegate: AppDelegate var body: some View { Group { if let kp = keypair, !needs_setup { - ContentView(keypair: kp) + ContentView(keypair: kp, appDelegate: appDelegate) .environmentObject(orientationTracker) } else { SetupView() @@ -49,15 +50,67 @@ struct MainView: View { .onAppear { orientationTracker.setDeviceMajorAxis() keypair = get_saved_keypair() + appDelegate.keypair = keypair } } } class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + var keypair: Keypair? = nil + var settings: UserSettingsStore? = nil + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { UNUserNotificationCenter.current().delegate = self return true } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + // Return if this feature is disabled + guard let settings = self.settings else { return } + if !settings.enable_experimental_push_notifications { + return + } + + // Send the device token and pubkey to the server + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + + print("Received device token: \(token)") + + guard let pubkey = keypair?.pubkey else { + return + } + + // Send those as JSON to the server + let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()] + + // create post request + let url = URL(string: settings.send_device_token_to_localhost ? "http://localhost:8000/user-info" : "https://notify.damus.io:8000/user-info")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + + // insert json data to the request + request.httpBody = try? JSONSerialization.data(withJSONObject: json, options: []) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + guard let data = data, error == nil else { + print(error?.localizedDescription ?? "No data") + return + } + + if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { + print("Unexpected status code: \(response.statusCode)") + return + } + + let responseJSON = try? JSONSerialization.jsonObject(with: data, options: []) + if let responseJSON = responseJSON as? [String: Any] { + print(responseJSON) + } + } + + task.resume() + } // Handle the notification in the foreground state func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {