From b5f61182c5a36da00f8b397792fe17a69fcd84a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:29:48 +0000 Subject: [PATCH] Fix missing chat notification avatar on Apple Watch Chat notifications are upgraded to Communication Notifications and attach the sender's avatar to the INPerson via downloadAvatarImage. The image was built with INImage(imageData:), which renders on the iPhone but is not relayed to paired devices like the Apple Watch. The Watch then falls back to drawing a monogram from the sender's initials instead of the avatar. Write the downloaded avatar bytes to a file in the shared App Group container and back the INImage with INImage(url:) so the system can resolve the image lazily across devices. Files are named by a stable hash of the content-addressed thumbnail URL so notifications from the same sender reuse one file. --- modules/BlueskyNSE/NotificationService.swift | 60 ++++++++++++++++++++ modules/BlueskyNSE/README.md | 8 +++ 2 files changed, 68 insertions(+) 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 |