damus

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

commit 0c148c8a1fc55cd7b598e38306d697f30f3eb96b
parent 814bcf694f2066e87dad5a9c7c26d551d474251f
Author: William Casarin <jb55@jb55.com>
Date:   Mon,  3 Mar 2025 14:22:11 -0800

Merge Communication Notifications

Diffstat:
MDamusNotificationService/DamusNotificationService.entitlements | 2++
MDamusNotificationService/NotificationService.swift | 242++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MPackage.swift | 35++++++++++++++++++++++++++++++++---
Mdamus.xcodeproj/project.pbxproj | 36++++++++++++++++--------------------
Mdamus/Info.plist | 4++++
Mdamus/Models/DraftsModel.swift | 2+-
Mdamus/Nostr/Nostr.swift | 2+-
Mdamus/Util/Constants.swift | 1+
Mdamus/Util/DisplayName.swift | 39++++++++++++++++++++++++++++++++-------
Mdamus/Views/Purple/DamusPurpleAccountView.swift | 2+-
Mdamus/damus.entitlements | 2++
Mdamus/damusApp.swift | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MdamusTests/EditPictureControlTests.swift | 2++
13 files changed, 394 insertions(+), 42 deletions(-)

diff --git a/DamusNotificationService/DamusNotificationService.entitlements b/DamusNotificationService/DamusNotificationService.entitlements @@ -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> diff --git a/DamusNotificationService/NotificationService.swift b/DamusNotificationService/NotificationService.swift @@ -5,15 +5,32 @@ // Created by Daniel D’Aquino 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() +} + + diff --git a/Package.swift b/Package.swift @@ -1,3 +1,32 @@ -dependencies: [ - .Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main") -] +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "damus", + platforms: [ + .iOS(.v16), + .macOS(.v12) + ], + products: [ + .library( + name: "damus", + targets: ["damus"]), + ], + dependencies: [ + .package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main") + ], + targets: [ + .target( + name: "damus", + dependencies: [ + .product(name: "secp256k1", package: "secp256k1.swift") + ], + path: "damus"), + .testTarget( + name: "damusTests", + dependencies: ["damus"], + path: "damusTests"), + ] +) diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -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 */; @@ -6225,7 +6228,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 4; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -6248,7 +6251,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MACOSX_DEPLOYMENT_TARGET = 12.3; - MARKETING_VERSION = 1.10; + MARKETING_VERSION = 1.13; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -6294,7 +6297,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 4; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -6313,7 +6316,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MACOSX_DEPLOYMENT_TARGET = 12.3; - MARKETING_VERSION = 1.10; + MARKETING_VERSION = 1.13; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -6332,8 +6335,8 @@ 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\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; @@ -6360,9 +6363,9 @@ "$(inherited)", "$(PROJECT_DIR)", ); - 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,8 +6387,8 @@ 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\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; @@ -6412,9 +6415,9 @@ "$(inherited)", "$(PROJECT_DIR)", ); - 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; @@ -6500,7 +6503,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "share extension/share extension.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -6519,7 +6521,6 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.13; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -6538,7 +6539,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "share extension/share extension.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -6553,7 +6553,6 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.13; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -6572,7 +6571,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "highlighter action extension/highlighter action extension.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -6587,7 +6585,6 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.13; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -6607,7 +6604,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "highlighter action extension/highlighter action extension.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -6622,7 +6618,6 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.13; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -6641,7 +6636,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 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 = gnu11; @@ -6656,7 +6650,6 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.13; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -6676,7 +6669,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 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 = gnu11; @@ -6691,7 +6683,6 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.13; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -6870,6 +6861,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" */; diff --git a/damus/Info.plist b/damus/Info.plist @@ -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> diff --git a/damus/Models/DraftsModel.swift b/damus/Models/DraftsModel.swift @@ -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 diff --git a/damus/Nostr/Nostr.swift b/damus/Nostr/Nostr.swift @@ -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? { diff --git a/damus/Util/Constants.swift b/damus/Util/Constants.swift @@ -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" diff --git a/damus/Util/DisplayName.swift b/damus/Util/DisplayName.swift @@ -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) diff --git a/damus/Views/Purple/DamusPurpleAccountView.swift b/damus/Views/Purple/DamusPurpleAccountView.swift @@ -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 } } diff --git a/damus/damus.entitlements b/damus/damus.entitlements @@ -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> diff --git a/damus/damusApp.swift b/damus/damusApp.swift @@ -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 { diff --git a/damusTests/EditPictureControlTests.swift b/damusTests/EditPictureControlTests.swift @@ -270,6 +270,7 @@ final class EditPictureControlTests: XCTestCase { XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) } + /* @MainActor func testEditPictureControlFirstTimeSetup() async { var current_image_url: URL? = nil @@ -325,6 +326,7 @@ final class EditPictureControlTests: XCTestCase { sleep(2) // Wait a bit for things to load assertSnapshot(matching: hostView, as: .image(on: .iPhoneSe(.portrait))) } + */ // MARK: Mock classes