diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index db3d261b56..d81b83a6bb 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -1,3 +1,4 @@ +import CryptoKit import Intents import UIKit import UserNotifications @@ -190,9 +191,68 @@ class NotificationService: UNNotificationServiceExtension { semaphore.wait() guard let data = imageData else { return nil } + + // Back the INImage with a file in the shared App Group container rather + // than raw image data. `INImage(imageData:)` renders fine on the iPhone + // itself, but the in-memory bytes are not relayed to paired devices like + // the Apple Watch. The Watch then receives an INPerson with no usable + // image and falls back to drawing a monogram from the sender's initials. + // Pointing the INImage at a file URL the Watch can resolve lets it render + // the actual avatar. + if let fileURL = writeAvatarToSharedContainer(data: data, urlString: thumbnailUrlString) { + return INImage(url: fileURL) + } + + // Fall back to in-memory data if we could not write to the container. The + // avatar still shows on the iPhone in that case. return INImage(imageData: data) } + // Writes avatar bytes to a file in the shared App Group container so the + // resulting INImage can be backed by a URL. The system reads this file + // lazily (including when relaying the notification to the Apple Watch), so + // it must live in the persistent shared container rather than a temporary + // directory. Files are named by a stable hash of the source URL, which is + // content-addressed (the CID changes when a user updates their avatar), so + // repeated notifications from the same sender reuse one file instead of + // accumulating duplicates. + func writeAvatarToSharedContainer(data: Data, urlString: String) -> URL? { + guard + let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: APP_GROUP + ) + else { + return nil + } + + let avatarsDir = containerURL.appendingPathComponent( + "notification-avatars", + isDirectory: true + ) + + let fileName = Insecure.MD5.hash(data: Data(urlString.utf8)) + .map { String(format: "%02x", $0) } + .joined() + let fileURL = avatarsDir.appendingPathComponent(fileName) + + // Reuse an already-downloaded avatar if present. + if FileManager.default.fileExists(atPath: fileURL.path) { + return fileURL + } + + do { + try FileManager.default.createDirectory( + at: avatarsDir, + withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: .atomic) + } catch { + return nil + } + + return fileURL + } + // MARK: Mutations func mutateWithBadge(_ content: UNMutableNotificationContent) { diff --git a/modules/BlueskyNSE/README.md b/modules/BlueskyNSE/README.md index 63136141cc..51ed9dea55 100644 --- a/modules/BlueskyNSE/README.md +++ b/modules/BlueskyNSE/README.md @@ -47,6 +47,14 @@ Two sound types are supported: DM sound only plays if the user has enabled the `playSoundChat` preference in the main app's chat settings. +### Communication Notifications (Avatars) + +Chat notifications (`reason == "chat-message"` / `"chat-reaction"`) are upgraded to iOS Communication Notifications via `INSendMessageIntent`, which lets the sender's avatar appear alongside the message. + +The avatar is downloaded from `senderAvatarUrl` (rewritten to the `avatar_thumbnail` variant to keep it small) and attached to the `INPerson` sender. The downloaded bytes are written to a file in the shared App Group container and the `INImage` is created with `INImage(url:)`, **not** `INImage(imageData:)`. + +This distinction matters for paired devices: an `INImage` backed by in-memory data renders on the iPhone but is not relayed to the Apple Watch, which then falls back to drawing a monogram from the sender's initials. Backing the image with a file URL the system can resolve lazily lets the Watch render the real avatar. Avatar files are written under `notification-avatars/` in the container and named by a stable hash of the (content-addressed) source URL, so notifications from the same sender reuse one file. + ## Key Files | File | Purpose |