From c17d1bcc0527cb780ee0ed88627cde164469d718 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 22 Apr 2026 10:20:38 +0300 Subject: [PATCH] add iOS communication notifications for chat with sender avatars Uses INSendMessageIntent to display sender profile pictures in DM notifications. The NSE downloads the avatar thumbnail and creates a Communication Notification, falling back gracefully if the download fails or the avatar URL is absent. Also adds chat-reaction support to the notification handler on both iOS and Android. Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/BlueskyNSE/Info.plist | 7 ++ modules/BlueskyNSE/NotificationService.swift | 91 +++++++++++++++++-- .../BackgroundNotificationHandler.kt | 2 +- src/lib/hooks/useNotificationHandler.ts | 23 ++++- 4 files changed, 110 insertions(+), 13 deletions(-) diff --git a/modules/BlueskyNSE/Info.plist b/modules/BlueskyNSE/Info.plist index c2dd7eda69..e9271925e0 100644 --- a/modules/BlueskyNSE/Info.plist +++ b/modules/BlueskyNSE/Info.plist @@ -8,6 +8,13 @@ com.apple.usernotifications.service NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + MainAppScheme bluesky diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index 481402890f..e6ebe8d043 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -1,5 +1,6 @@ import UserNotifications import UIKit +import Intents let APP_GROUP = "group.app.bsky" typealias ContentHandler = (UNNotificationContent) -> Void @@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension { } self.bestAttempt = bestAttempt - if reason == "chat-message" { + + if reason == "chat-message" || reason == "chat-reaction" { mutateWithChatMessage(bestAttempt) + let finalContent = createCommunicationNotification( + from: bestAttempt, + userInfo: request.content.userInfo + ) + contentHandler(finalContent) } else { mutateWithBadge(bestAttempt) + contentHandler(bestAttempt) } - - // Any image downloading (or other network tasks) should be handled at the end - // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire - // gets called, we might not have all the needed mutations completed in time. - - contentHandler(bestAttempt) } override func serviceExtensionTimeWillExpire() { @@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttempt) } + // MARK: Communication Notification + + func createCommunicationNotification( + from content: UNMutableNotificationContent, + userInfo: [AnyHashable: Any] + ) -> UNNotificationContent { + let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown" + let convoId = userInfo["convoId"] as? String + + var avatarImage: INImage? = nil + if let avatarUrlString = userInfo["senderAvatarUrl"] as? String { + avatarImage = downloadAvatarImage(from: avatarUrlString) + } + + let senderHandle = INPersonHandle(value: nil, type: .unknown) + let sender = INPerson( + personHandle: senderHandle, + nameComponents: nil, + displayName: senderDisplayName, + image: avatarImage, + contactIdentifier: nil, + customIdentifier: nil + ) + + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content.body, + speakableGroupName: nil, + conversationIdentifier: convoId, + serviceName: nil, + sender: sender, + attachments: nil + ) + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate(completion: nil) + + do { + return try content.updating(from: intent) + } catch { + return content + } + } + + func downloadAvatarImage(from urlString: String) -> INImage? { + let thumbnailUrlString = urlString.replacingOccurrences( + of: "/img/avatar/", + with: "/img/avatar_thumbnail/" + ) + + guard let url = URL(string: thumbnailUrlString) else { return nil } + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + + var imageData: Data? = nil + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data, + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 { + imageData = data + } + semaphore.signal() + } + task.resume() + semaphore.wait() + + guard let data = imageData else { return nil } + return INImage(imageData: data) + } + // MARK: Mutations func mutateWithBadge(_ content: UNMutableNotificationContent) { diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt index 4f8a6b892a..fba23dfa0c 100644 --- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt +++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt @@ -13,7 +13,7 @@ class BackgroundNotificationHandler( return } - if (remoteMessage.data["reason"] == "chat-message") { + if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") { mutateWithChatMessage(remoteMessage) } else { mutateWithOtherReason(remoteMessage) diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 10651f62b8..60d6ebabfe 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -29,6 +29,7 @@ export type NotificationReason = | 'reply' | 'quote' | 'chat-message' + | 'chat-reaction' | 'starterpack-joined' | 'like-via-repost' | 'repost-via-repost' @@ -44,7 +45,7 @@ export type NotificationReason = export type NotificationPayload = | undefined | { - reason: Exclude + reason: Exclude uri: string subject: string recipientDid: string @@ -55,6 +56,12 @@ export type NotificationPayload = messageId: string recipientDid: string } + | { + reason: 'chat-reaction' + convoId: string + messageId: string + recipientDid: string + } const DEFAULT_HANDLER_OPTIONS = { shouldShowBanner: false, @@ -192,8 +199,11 @@ export function useNotificationsHandler() { const handleNotification = (payload?: NotificationPayload) => { if (!payload) return - if (payload.reason === 'chat-message') { - logger.debug(`useNotificationsHandler: handling chat message`, { + if ( + payload.reason === 'chat-message' || + payload.reason === 'chat-reaction' + ) { + logger.debug(`useNotificationsHandler: handling chat notification`, { payload, }) @@ -270,7 +280,8 @@ export function useNotificationsHandler() { logger.debug('useNotificationsHandler: incoming', {e, payload}) if ( - payload.reason === 'chat-message' && + (payload.reason === 'chat-message' || + payload.reason === 'chat-reaction') && payload.recipientDid === currentAccount?.did ) { const shouldAlert = payload.convoId !== currentConvoId @@ -341,7 +352,8 @@ export function useNotificationsHandler() { // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. // Whenever currentAccount changes, we should try to handle it again. if ( - storedAccountSwitchPayload?.reason === 'chat-message' && + (storedAccountSwitchPayload?.reason === 'chat-message' || + storedAccountSwitchPayload?.reason === 'chat-reaction') && currentAccount?.did === storedAccountSwitchPayload.recipientDid ) { handleNotification(storedAccountSwitchPayload) @@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null { return `/profile/${urip.host}` } case 'chat-message': + case 'chat-reaction': // should be handled separately return null case 'verified':