From af4949e26abe367dd0eb160a31adc584bc6d4489 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 1 Mar 2025 14:23:53 -0800 Subject: [PATCH] Communication notifications Signed-off-by: William Casarin --- .../DamusNotificationService.entitlements | 2 + .../NotificationService.swift | 242 +++++++++++++++++- damus.xcodeproj/project.pbxproj | 12 + damus/Info.plist | 4 + damus/Models/DraftsModel.swift | 2 +- damus/Nostr/Nostr.swift | 2 +- damus/Util/Constants.swift | 1 + damus/Util/DisplayName.swift | 39 ++- .../Views/Purple/DamusPurpleAccountView.swift | 2 +- damus/damus.entitlements | 2 + damus/damusApp.swift | 67 ++++- 11 files changed, 356 insertions(+), 19 deletions(-) diff --git a/DamusNotificationService/DamusNotificationService.entitlements b/DamusNotificationService/DamusNotificationService.entitlements index a917e809..9073fede 100644 --- a/DamusNotificationService/DamusNotificationService.entitlements +++ b/DamusNotificationService/DamusNotificationService.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.usernotifications.communication + com.apple.developer.kernel.extended-virtual-addressing com.apple.security.app-sandbox diff --git a/DamusNotificationService/NotificationService.swift b/DamusNotificationService/NotificationService.swift index fc44e3b4..c97e9239 100644 --- 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/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 2278519d..a6847090 100644 --- 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 */; @@ -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" */; diff --git a/damus/Info.plist b/damus/Info.plist index e8f9ce21..5d32856d 100644 --- a/damus/Info.plist +++ b/damus/Info.plist @@ -2,6 +2,10 @@ + NSUserActivityTypes + + INSendMessageIntent + CFBundleURLTypes diff --git a/damus/Models/DraftsModel.swift b/damus/Models/DraftsModel.swift index 09a5e424..1ae5b772 100644 --- 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 index e6cba176..f0588c84 100644 --- 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 index c3d133fa..726dcd3e 100644 --- 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 index 60249b3b..3a57eb65 100644 --- 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 index 1e7160bf..56a1d91c 100644 --- 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? = 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 index 0da9f032..cef6df9b 100644 --- a/damus/damus.entitlements +++ b/damus/damus.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.usernotifications.communication + aps-environment development com.apple.developer.associated-domains diff --git a/damus/damusApp.swift b/damus/damusApp.swift index 5f3dcf25..5db118a7 100644 --- 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.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 {