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) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-04-22 10:20:38 +03:00
parent 5d40532aa9
commit c17d1bcc05
4 changed files with 110 additions and 13 deletions
+7
View File
@@ -8,6 +8,13 @@
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict>
<key>MainAppScheme</key>
<string>bluesky</string>
+84 -7
View File
@@ -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) {
@@ -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)
+18 -5
View File
@@ -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<NotificationReason, 'chat-message'>
reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
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':