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:
@@ -8,6 +8,13 @@
|
|||||||
<string>com.apple.usernotifications.service</string>
|
<string>com.apple.usernotifications.service</string>
|
||||||
<key>NSExtensionPrincipalClass</key>
|
<key>NSExtensionPrincipalClass</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>IntentsSupported</key>
|
||||||
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>MainAppScheme</key>
|
<key>MainAppScheme</key>
|
||||||
<string>bluesky</string>
|
<string>bluesky</string>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Intents
|
||||||
|
|
||||||
let APP_GROUP = "group.app.bsky"
|
let APP_GROUP = "group.app.bsky"
|
||||||
typealias ContentHandler = (UNNotificationContent) -> Void
|
typealias ContentHandler = (UNNotificationContent) -> Void
|
||||||
@@ -40,17 +41,18 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.bestAttempt = bestAttempt
|
self.bestAttempt = bestAttempt
|
||||||
if reason == "chat-message" {
|
|
||||||
|
if reason == "chat-message" || reason == "chat-reaction" {
|
||||||
mutateWithChatMessage(bestAttempt)
|
mutateWithChatMessage(bestAttempt)
|
||||||
|
let finalContent = createCommunicationNotification(
|
||||||
|
from: bestAttempt,
|
||||||
|
userInfo: request.content.userInfo
|
||||||
|
)
|
||||||
|
contentHandler(finalContent)
|
||||||
} else {
|
} else {
|
||||||
mutateWithBadge(bestAttempt)
|
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() {
|
override func serviceExtensionTimeWillExpire() {
|
||||||
@@ -61,6 +63,81 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(bestAttempt)
|
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
|
// MARK: Mutations
|
||||||
|
|
||||||
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ class BackgroundNotificationHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remoteMessage.data["reason"] == "chat-message") {
|
if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") {
|
||||||
mutateWithChatMessage(remoteMessage)
|
mutateWithChatMessage(remoteMessage)
|
||||||
} else {
|
} else {
|
||||||
mutateWithOtherReason(remoteMessage)
|
mutateWithOtherReason(remoteMessage)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type NotificationReason =
|
|||||||
| 'reply'
|
| 'reply'
|
||||||
| 'quote'
|
| 'quote'
|
||||||
| 'chat-message'
|
| 'chat-message'
|
||||||
|
| 'chat-reaction'
|
||||||
| 'starterpack-joined'
|
| 'starterpack-joined'
|
||||||
| 'like-via-repost'
|
| 'like-via-repost'
|
||||||
| 'repost-via-repost'
|
| 'repost-via-repost'
|
||||||
@@ -44,7 +45,7 @@ export type NotificationReason =
|
|||||||
export type NotificationPayload =
|
export type NotificationPayload =
|
||||||
| undefined
|
| undefined
|
||||||
| {
|
| {
|
||||||
reason: Exclude<NotificationReason, 'chat-message'>
|
reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'>
|
||||||
uri: string
|
uri: string
|
||||||
subject: string
|
subject: string
|
||||||
recipientDid: string
|
recipientDid: string
|
||||||
@@ -55,6 +56,12 @@ export type NotificationPayload =
|
|||||||
messageId: string
|
messageId: string
|
||||||
recipientDid: string
|
recipientDid: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
reason: 'chat-reaction'
|
||||||
|
convoId: string
|
||||||
|
messageId: string
|
||||||
|
recipientDid: string
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_HANDLER_OPTIONS = {
|
const DEFAULT_HANDLER_OPTIONS = {
|
||||||
shouldShowBanner: false,
|
shouldShowBanner: false,
|
||||||
@@ -192,8 +199,11 @@ export function useNotificationsHandler() {
|
|||||||
const handleNotification = (payload?: NotificationPayload) => {
|
const handleNotification = (payload?: NotificationPayload) => {
|
||||||
if (!payload) return
|
if (!payload) return
|
||||||
|
|
||||||
if (payload.reason === 'chat-message') {
|
if (
|
||||||
logger.debug(`useNotificationsHandler: handling chat message`, {
|
payload.reason === 'chat-message' ||
|
||||||
|
payload.reason === 'chat-reaction'
|
||||||
|
) {
|
||||||
|
logger.debug(`useNotificationsHandler: handling chat notification`, {
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -270,7 +280,8 @@ export function useNotificationsHandler() {
|
|||||||
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
logger.debug('useNotificationsHandler: incoming', {e, payload})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
payload.reason === 'chat-message' &&
|
(payload.reason === 'chat-message' ||
|
||||||
|
payload.reason === 'chat-reaction') &&
|
||||||
payload.recipientDid === currentAccount?.did
|
payload.recipientDid === currentAccount?.did
|
||||||
) {
|
) {
|
||||||
const shouldAlert = payload.convoId !== currentConvoId
|
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 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.
|
// Whenever currentAccount changes, we should try to handle it again.
|
||||||
if (
|
if (
|
||||||
storedAccountSwitchPayload?.reason === 'chat-message' &&
|
(storedAccountSwitchPayload?.reason === 'chat-message' ||
|
||||||
|
storedAccountSwitchPayload?.reason === 'chat-reaction') &&
|
||||||
currentAccount?.did === storedAccountSwitchPayload.recipientDid
|
currentAccount?.did === storedAccountSwitchPayload.recipientDid
|
||||||
) {
|
) {
|
||||||
handleNotification(storedAccountSwitchPayload)
|
handleNotification(storedAccountSwitchPayload)
|
||||||
@@ -429,6 +441,7 @@ export function notificationToURL(payload: NotificationPayload): string | null {
|
|||||||
return `/profile/${urip.host}`
|
return `/profile/${urip.host}`
|
||||||
}
|
}
|
||||||
case 'chat-message':
|
case 'chat-message':
|
||||||
|
case 'chat-reaction':
|
||||||
// should be handled separately
|
// should be handled separately
|
||||||
return null
|
return null
|
||||||
case 'verified':
|
case 'verified':
|
||||||
|
|||||||
Reference in New Issue
Block a user