Communication notifications

Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
William Casarin
2025-03-01 14:23:53 -08:00
parent 6ef4b60d14
commit af4949e26a
11 changed files with 356 additions and 19 deletions

View File

@@ -2,6 +2,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.developer.usernotifications.communication</key>
<true/>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.security.app-sandbox</key>

View File

@@ -5,15 +5,32 @@
// Created by Daniel DAquino on 2023-11-10.
//
import Kingfisher
import ImageIO
import UserNotifications
import Foundation
import UniformTypeIdentifiers
import Intents
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
private func configureKingfisherCache() {
guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) else {
return
}
let cachePath = groupURL.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
if let cache = try? ImageCache(name: "sharedCache", cacheDirectoryURL: cachePath) {
KingfisherManager.shared.cache = cache
}
}
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
configureKingfisherCache()
self.contentHandler = contentHandler
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String,
@@ -40,9 +57,16 @@ class NotificationService: UNNotificationServiceExtension {
return
}
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let profile = txn?.unsafeUnownedValue?.profile
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
let sender_profile = {
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let profile = txn?.unsafeUnownedValue?.profile
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
return ProfileBuf(picture: picture,
name: profile?.name,
display_name: profile?.display_name,
nip05: profile?.nip05)
}()
let sender_pubkey = nostr_event.pubkey
// Don't show notification details that match mute list.
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
@@ -56,7 +80,7 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(content)
return
}
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
Log.debug("should_display_notification failed", for: .push_notifications)
// We should not display notification for this event. Suppress notification.
@@ -65,7 +89,7 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content)
return
}
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
Log.debug("generate_local_notification_object failed", for: .push_notifications)
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
@@ -74,15 +98,58 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content)
return
}
Task {
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey)
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
return
}
contentHandler(improvedContent)
do {
var options: [AnyHashable: Any] = [:]
if let imageSource = CGImageSourceCreateWithURL(sender_profile.picture as CFURL, nil),
let uti = CGImageSourceGetType(imageSource) {
options[UNNotificationAttachmentOptionsTypeHintKey] = uti
}
let attachment = try UNNotificationAttachment(identifier: sender_profile.picture.absoluteString, url: sender_profile.picture, options: options)
improvedContent.attachments = [attachment]
} catch {
Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription)
}
let kind = nostr_event.known_kind
// these aren't supported yet
if !(kind == .text || kind == .dm) {
contentHandler(improvedContent)
return
}
// rich communication notifications for kind1, dms, etc
let message_intent = await message_intent_from_note(ndb: state.ndb,
sender_profile: sender_profile,
content: improvedContent.body,
note: nostr_event,
our_pubkey: state.keypair.pubkey)
improvedContent.threadIdentifier = nostr_event.thread_id().hex()
improvedContent.categoryIdentifier = "COMMUNICATION"
let interaction = INInteraction(intent: message_intent, response: nil)
interaction.direction = .incoming
do {
try await interaction.donate()
let updated = try improvedContent.updating(from: message_intent)
contentHandler(updated)
} catch {
Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription)
contentHandler(improvedContent)
}
}
}
@@ -95,3 +162,162 @@ class NotificationService: UNNotificationServiceExtension {
}
}
struct ProfileBuf {
let picture: URL
let name: String?
let display_name: String?
let nip05: String?
}
func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: String, note: NdbNote, our_pubkey: Pubkey) async -> INSendMessageIntent {
let sender_pk = note.pubkey
let sender = await profile_to_inperson(name: sender_profile.name,
display_name: sender_profile.display_name,
picture: sender_profile.picture.absoluteString,
nip05: sender_profile.nip05,
pubkey: sender_pk,
our_pubkey: our_pubkey)
let conversationIdentifier = note.thread_id().hex()
var recipients: [INPerson] = []
var pks: [Pubkey] = []
let meta = INSendMessageIntentDonationMetadata()
// gather recipients
if let recipient_note_id = note.direct_replies() {
let replying_to = ndb.lookup_note(recipient_note_id)
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
if replying_to_pk != sender_pk {
// we push the actual person being replied to first
pks.append(replying_to_pk)
}
}
}
let pubkeys = Array(note.referenced_pubkeys)
meta.recipientCount = pubkeys.count
if pubkeys.contains(sender_pk) {
meta.recipientCount -= 1
}
for pk in pubkeys.prefix(3) {
if pk == sender_pk || pks.contains(pk) {
continue
}
if !meta.isReplyToCurrentUser && pk == our_pubkey {
meta.mentionsCurrentUser = true
}
pks.append(pk)
}
for pk in pks {
let recipient = await pubkey_to_inperson(ndb: ndb, pubkey: pk, our_pubkey: our_pubkey)
recipients.append(recipient)
}
// we enable default formatting this way
var groupName = INSpeakableString(spokenPhrase: "")
// otherwise we just say its a DM
if note.known_kind == .dm {
groupName = INSpeakableString(spokenPhrase: "DM")
}
let intent = INSendMessageIntent(recipients: recipients,
outgoingMessageType: .outgoingMessageText,
content: content,
speakableGroupName: groupName,
conversationIdentifier: conversationIdentifier,
serviceName: "kind\(note.kind)",
sender: sender,
attachments: nil)
intent.donationMetadata = meta
// this is needed for recipients > 0
if let img = sender.image {
intent.setImage(img, forParameterNamed: \.speakableGroupName)
}
return intent
}
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let profile_txn = ndb.lookup_profile(pubkey)
let profile = profile_txn?.unsafeUnownedValue?.profile
let name = profile?.name
let display_name = profile?.display_name
let nip05 = profile?.nip05
let picture = profile?.picture
return await profile_to_inperson(name: name,
display_name: display_name,
picture: picture,
nip05: nip05,
pubkey: pubkey,
our_pubkey: our_pubkey)
}
func fetch_pfp(picture: URL) async throws -> RetrieveImageResult {
try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: picture)) { result in
switch result {
case .success(let img):
continuation.resume(returning: img)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func profile_to_inperson(name: String?, display_name: String?, picture: String?, nip05: String?, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let npub = pubkey.npub
let handle = INPersonHandle(value: npub, type: .unknown)
var aliases: [INPersonHandle] = []
if let nip05 {
aliases.append(INPersonHandle(value: nip05, type: .emailAddress))
}
let nostrName = DisplayName(name: name, display_name: display_name, pubkey: pubkey)
let nameComponents = nostrName.nameComponents()
let displayName = nostrName.displayName
let contactIdentifier = npub
let customIdentifier = npub
let suggestionType = INPersonSuggestionType.socialProfile
var image: INImage? = nil
if let picture,
let url = URL(string: picture),
let img = try? await fetch_pfp(picture: url),
let imgdata = img.data()
{
image = INImage(imageData: imgdata)
} else {
Log.error("Failed to fetch pfp (%s) for %s", for: .push_notifications, picture ?? "nil", displayName)
}
let person = INPerson(personHandle: handle,
nameComponents: nameComponents,
displayName: displayName,
image: image,
contactIdentifier: contactIdentifier,
customIdentifier: customIdentifier,
isMe: pubkey == our_pubkey,
suggestionType: suggestionType
)
return person
}
func robohash(_ pk: Pubkey) -> String {
return "https://robohash.org/" + pk.hex()
}

View File

@@ -189,6 +189,7 @@
4C54AA0729A540BA003E4487 /* NotificationsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C54AA0629A540BA003E4487 /* NotificationsModel.swift */; };
4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C54AA0929A55429003E4487 /* EventGroup.swift */; };
4C54AA0C29A5543C003E4487 /* ZapGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C54AA0B29A5543C003E4487 /* ZapGroup.swift */; };
4C5726BA2D72C6FA00E7FF82 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C5726B92D72C6FA00E7FF82 /* Kingfisher */; };
4C59B98C2A76C2550032FFEB /* ProfileUpdatedNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C59B98B2A76C2550032FFEB /* ProfileUpdatedNotify.swift */; };
4C5C7E68284ED36500A22DF5 /* SearchHomeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5C7E67284ED36500A22DF5 /* SearchHomeModel.swift */; };
4C5C7E6A284EDE2E00A22DF5 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5C7E69284EDE2E00A22DF5 /* SearchResultsView.swift */; };
@@ -2610,6 +2611,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4C5726BA2D72C6FA00E7FF82 /* Kingfisher in Frameworks */,
D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */,
D7EDED312B1290B80018B19C /* MarkdownUI in Frameworks */,
D7DB1FEA2D5A9F5A00CF06DA /* CryptoSwift in Frameworks */,
@@ -4174,6 +4176,7 @@
D789D11F2AFEFBF20083A7AB /* secp256k1 */,
D7EDED302B1290B80018B19C /* MarkdownUI */,
D7DB1FE92D5A9F5A00CF06DA /* CryptoSwift */,
4C5726B92D72C6FA00E7FF82 /* Kingfisher */,
);
productName = DamusNotificationService;
productReference = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */;
@@ -6332,6 +6335,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
@@ -6363,6 +6367,7 @@
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
@@ -6384,6 +6389,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
@@ -6415,6 +6421,7 @@
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
@@ -6870,6 +6877,11 @@
package = 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
4C5726B92D72C6FA00E7FF82 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
4C649880286E0EE300EAE2B3 /* secp256k1 */ = {
isa = XCSwiftPackageProductDependency;
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>

View File

@@ -115,7 +115,7 @@ class DraftArtifacts: Equatable {
if case .pubkey(let pubkey) = mention.ref {
// A profile reference, format things properly.
let profile = damus_state.ndb.lookup_profile(pubkey)?.unsafeUnownedValue?.profile
let profile_name = parse_display_name(profile: profile, pubkey: pubkey).username
let profile_name = DisplayName(profile: profile, pubkey: pubkey).username
guard let url_address = URL(string: block.asString) else {
rich_text_content.append(.init(string: block.asString))
continue

View File

@@ -58,7 +58,7 @@ extension NdbProfile {
}
static func displayName(profile: Profile?, pubkey: Pubkey) -> DisplayName {
return parse_display_name(profile: profile, pubkey: pubkey)
return DisplayName(name: profile?.name, display_name: profile?.display_name, pubkey: pubkey)
}
var damus_donation: Int? {

View File

@@ -14,6 +14,7 @@ import Foundation
class Constants {
//static let EXAMPLE_DEMOS: DamusState = .empty
static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus"
static let IMAGE_CACHE_DIRNAME: String = "ImageCache"
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"

View File

@@ -10,7 +10,15 @@ import Foundation
enum DisplayName: Equatable {
case both(username: String, displayName: String)
case one(String)
init (profile: Profile?, pubkey: Pubkey) {
self = parse_display_name(name: profile?.name, display_name: profile?.display_name, pubkey: pubkey)
}
init (name: String?, display_name: String?, pubkey: Pubkey) {
self = parse_display_name(name: name, display_name: display_name, pubkey: pubkey)
}
var displayName: String {
switch self {
case .one(let one):
@@ -28,20 +36,37 @@ enum DisplayName: Equatable {
return username
}
}
func nameComponents() -> PersonNameComponents {
var components = PersonNameComponents()
switch self {
case .one(let one):
components.nickname = one
return components
case .both(username: let username, displayName: let displayName):
components.nickname = username
let names = displayName.split(separator: " ")
if let name = names.first {
components.givenName = String(name)
components.familyName = names.dropFirst().joined(separator: " ")
}
return components
}
}
}
func parse_display_name(profile: Profile?, pubkey: Pubkey) -> DisplayName {
func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) -> DisplayName {
if pubkey == ANON_PUBKEY {
return .one(NSLocalizedString("Anonymous", comment: "Placeholder display name of anonymous user."))
}
guard let profile else {
if name == nil && display_name == nil {
return .one(abbrev_bech32_pubkey(pubkey: pubkey))
}
let name = profile.name?.isEmpty == false ? profile.name : nil
let disp_name = profile.display_name?.isEmpty == false ? profile.display_name : nil
let name = name?.isEmpty == false ? name : nil
let disp_name = display_name?.isEmpty == false ? display_name : nil
if let name, let disp_name, name != disp_name {
return .both(username: name, displayName: disp_name)

View File

@@ -123,7 +123,7 @@ struct DamusPurpleAccountView: View {
func profile_display_name() -> String {
let profile_txn: NdbTxn<ProfileRecord?>? = damus_state.profiles.lookup_with_timestamp(account.pubkey)
let profile: NdbProfile? = profile_txn?.unsafeUnownedValue?.profile
let display_name = parse_display_name(profile: profile, pubkey: account.pubkey).displayName
let display_name = DisplayName(profile: profile, pubkey: account.pubkey).displayName
return display_name
}
}

View File

@@ -2,6 +2,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.developer.usernotifications.communication</key>
<true/>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>

View File

@@ -5,6 +5,7 @@
// Created by William Casarin on 2022-04-01.
//
import Kingfisher
import SwiftUI
import StoreKit
@@ -59,13 +60,28 @@ struct MainView: View {
}
}
func registerNotificationCategories() {
// Define the communication category
let communicationCategory = UNNotificationCategory(
identifier: "COMMUNICATION",
actions: [],
intentIdentifiers: ["INSendMessageIntent"],
options: []
)
// Register the category with the notification center
UNUserNotificationCenter.current().setNotificationCategories([communicationCategory])
}
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var state: DamusState? = nil
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
UNUserNotificationCenter.current().delegate = self
SKPaymentQueue.default().add(StoreObserver.standard)
registerNotificationCategories()
migrateKingfisherCacheIfNeeded()
configureKingfisherCache()
return true
}
@@ -96,6 +112,55 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
Task { await QueueableNotify<LossyLocalNotification>.shared.add(item: notification) }
completionHandler()
}
private func migrateKingfisherCacheIfNeeded() {
let fileManager = FileManager.default
let defaults = UserDefaults.standard
let migrationKey = "KingfisherCacheMigrated"
// Check if migration has already been done
guard !defaults.bool(forKey: migrationKey) else { return }
// Get the default Kingfisher cache (before we override it)
let defaultCache = ImageCache.default
let oldCachePath = defaultCache.diskStorage.directoryURL.path
// New shared cache location
guard let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) else { return }
let newCachePath = groupURL.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME).path
// Check if the old cache exists
if fileManager.fileExists(atPath: oldCachePath) {
do {
// Move the old cache to the new location
try fileManager.moveItem(atPath: oldCachePath, toPath: newCachePath)
print("Successfully migrated Kingfisher cache to \(newCachePath)")
} catch {
print("Failed to migrate cache: \(error)")
// Optionally, copy instead of move if you want to preserve the old cache as a fallback
do {
try fileManager.copyItem(atPath: oldCachePath, toPath: newCachePath)
print("Copied cache instead due to error")
} catch {
print("Failed to copy cache: \(error)")
}
}
}
// Mark migration as complete
defaults.set(true, forKey: migrationKey)
}
private func configureKingfisherCache() {
guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) else {
return
}
let cachePath = groupURL.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
if let cache = try? ImageCache(name: "sharedCache", cacheDirectoryURL: cachePath) {
KingfisherManager.shared.cache = cache
}
}
}
class OrientationTracker: ObservableObject {