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>
This commit is contained in:
Daniel D’Aquino
2023-11-14 07:21:39 +00:00
committed by William Casarin
parent 878b1caa95
commit ad75d8546c
13 changed files with 562 additions and 5 deletions

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
//
// NostrEventInfoFromPushNotification.swift
// DamusNotificationService
//
// Created by Daniel DAquino 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
}
}
}

View File

@@ -0,0 +1,48 @@
//
// NotificationFormatter.swift
// DamusNotificationService
//
// Created by Daniel DAquino 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
}
}

View File

@@ -0,0 +1,39 @@
//
// NotificationService.swift
// DamusNotificationService
//
// Created by Daniel DAquino 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)
}
}
}