diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a2e8de..aa3c1f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- **iOS Communication Notifications**: load sender / group avatars via `INImage imageWithImageData:` (downloaded or local bytes) instead of `imageWithURL:` on `file://` cache URLs, which often failed while iOS persisted the intent image (URL-encoded filename + `.png`). Also set the intent image for the `sender` parameter (required for the lock-screen circular avatar + app-icon badge layout) and use `sender.id` as `customIdentifier`. + ## [10.5.0] - 2026-07-24 ### Fixed diff --git a/docs/react-native/ios/appearance.mdx b/docs/react-native/ios/appearance.mdx index 136e614d..72968aa6 100644 --- a/docs/react-native/ios/appearance.mdx +++ b/docs/react-native/ios/appearance.mdx @@ -92,3 +92,118 @@ To learn more, view the [Category Summary Text](/react-native/ios/categories#cat documentation. > This functionality is only available on iOS >= 12. + +## Communication Notifications + +On iOS 15+, [`ios.communicationInfo`](/react-native/reference/Interface.IOSCommunicationInfo) donates an +`INSendMessageIntent` so Messages-style notifications show a **circular sender avatar** with your **app icon badge** +on the lock screen and Notification Center. + +### Direct message (HTTPS avatar) + +Prefer remote HTTPS avatars for pushes handled by a Notification Service Extension — the extension can download the +image while the app is backgrounded or killed: + +```js +import notifee from 'react-native-notify-kit'; + +await notifee.displayNotification({ + title: 'Alex', + body: 'Are we still on for the walk?', + ios: { + communicationInfo: { + conversationId: 'chat-42', + body: 'Are we still on for the walk?', + sender: { + id: 'user-alex', + displayName: 'Alex', + avatar: 'https://cdn.example.com/avatars/alex.png', + }, + }, + }, +}); +``` + +### Group conversation + +Provide `groupName` (and optionally `groupAvatar`) for group chats. The native layer adds a placeholder recipient so +iOS treats the intent as a group conversation: + +```js +await notifee.displayNotification({ + title: 'Puppy Parents', + body: 'Sam: See you at the park!', + ios: { + communicationInfo: { + conversationId: 'group-7', + body: 'See you at the park!', + groupName: 'Puppy Parents', + groupAvatar: 'https://cdn.example.com/groups/7.png', + sender: { + id: 'user-sam', + displayName: 'Sam', + avatar: 'https://cdn.example.com/avatars/sam.png', + }, + }, + }, +}); +``` + +### Local / cached avatars + +`sender.avatar` and `groupAvatar` also accept local `file://` URLs, absolute paths, or bundle resource names (useful +for foreground demos). The core loads **image bytes** into `INImage` rather than passing a `file://` URL to +`imageWithURL:`, which can fail when iOS persists the intent image after Notification Service Extension caching. + +```js +await notifee.displayNotification({ + title: 'Jordan', + body: 'Local avatar smoke test', + ios: { + communicationInfo: { + conversationId: 'local-demo', + body: 'Local avatar smoke test', + sender: { + id: 'user-jordan', + displayName: 'Jordan', + // e.g. after downloading/caching under the app or NSE shared container + avatar: 'file:///var/mobile/Containers/Data/Application/.../Library/Caches/avatar.png', + }, + }, + }, +}); +``` + +### FCM / APNs (background & killed) + +For remote delivery, include `communicationInfo` inside Notify Kit options so the NSE can apply the intent. With the +server SDK: + +```js +import { buildNotifyKitPayload } from 'react-native-notify-kit/server'; + +const message = buildNotifyKitPayload({ + token: deviceToken, + notification: { + title: 'Alex', + body: 'Are we still on for the walk?', + ios: { + communicationInfo: { + conversationId: 'chat-42', + body: 'Are we still on for the walk?', + sender: { + id: 'user-alex', + displayName: 'Alex', + avatar: 'https://cdn.example.com/avatars/alex.png', + }, + }, + }, + }, +}); +``` + +`buildNotifyKitPayload` sets `mutable-content: 1` on APNs so the Notification Service Extension can run. Without the +NSE (or without `communicationInfo` in the payload), iOS will show a standard alert without the circular avatar layout. + +> Requires iOS 15+, Communication Notifications capability / entitlement as configured for your app, and a Notification +> Service Extension for background/killed remote pushes. diff --git a/ios/NotifeeCore/NotifeeCoreUtil.m b/ios/NotifeeCore/NotifeeCoreUtil.m index 17c135f6..7855dd40 100644 --- a/ios/NotifeeCore/NotifeeCoreUtil.m +++ b/ios/NotifeeCore/NotifeeCoreUtil.m @@ -1249,6 +1249,45 @@ + (NSMutableDictionary *)parseUNNotificationContent:(UNNotificationContent *)con return dictionary; } +/** + * Build an INImage for Communication Notifications. + * + * `INImage imageWithURL:` with a local `file://` cache URL often fails while iOS + * persists the intent image (invalid URL-encoded filename + ".png"). Prefer + * `imageWithImageData:` from downloaded / local bytes; fall back to a remote URL. + */ ++ (INImage *)inImageFromCommunicationAvatarString:(NSString *)avatarString { + if (avatarString == nil || avatarString.length == 0) { + return nil; + } + + NSURL *resolvedURL = nil; + BOOL isRemote = [avatarString hasPrefix:@"http://"] || [avatarString hasPrefix:@"https://"]; + + if (isRemote) { + resolvedURL = [self downloadMediaSynchronously:avatarString]; + } else if ([avatarString hasPrefix:@"file://"]) { + resolvedURL = [NSURL URLWithString:avatarString]; + } else if ([avatarString hasPrefix:@"/"]) { + resolvedURL = [NSURL fileURLWithPath:avatarString]; + } else { + resolvedURL = [self getURLFromString:avatarString]; + } + + if (resolvedURL != nil) { + NSData *imageData = [NSData dataWithContentsOfURL:resolvedURL]; + if (imageData != nil && imageData.length > 0) { + return [INImage imageWithImageData:imageData]; + } + } + + if (isRemote) { + return [INImage imageWithURL:[NSURL URLWithString:avatarString]]; + } + + return nil; +} + + (INSendMessageIntent *)generateSenderIntentForCommunicationNotification: (NSDictionary *)communicationInfo { if (@available(iOS 15.0, *)) { @@ -1256,11 +1295,10 @@ + (INSendMessageIntent *)generateSenderIntentForCommunicationNotification: INPersonHandle *senderPersonHandle = [[INPersonHandle alloc] initWithValue:sender[@"id"] type:INPersonHandleTypeUnknown]; - // Parse sender's avatar + // Parse sender's avatar (image data — not file:// imageWithURL) INImage *avatar = nil; if (sender[@"avatar"] != nil) { - NSURL *url = [self getURLFromString:sender[@"avatar"]]; - avatar = [INImage imageWithURL:url]; + avatar = [self inImageFromCommunicationAvatarString:sender[@"avatar"]]; } INPerson *senderPerson = [[INPerson alloc] initWithPersonHandle:senderPersonHandle @@ -1268,7 +1306,7 @@ + (INSendMessageIntent *)generateSenderIntentForCommunicationNotification: displayName:sender[@"displayName"] image:avatar contactIdentifier:nil - customIdentifier:nil]; + customIdentifier:sender[@"id"]]; NSMutableArray *recipients = nil; @@ -1305,11 +1343,17 @@ + (INSendMessageIntent *)generateSenderIntentForCommunicationNotification: sender:senderPerson attachments:nil]; - if (communicationInfo[@"groupAvatar"] != nil) { - NSURL *groupAvatarURL = [[NSURL alloc] initWithString:communicationInfo[@"groupAvatar"]]; - INImage *groupAvatarImage = [INImage imageWithURL:groupAvatarURL]; + // Required for the lock-screen circular avatar + app-icon badge layout. + if (avatar != nil) { + [intent setImage:avatar forParameterNamed:@"sender"]; + } - [intent setImage:groupAvatarImage forParameterNamed:@"speakableGroupName"]; + if (communicationInfo[@"groupAvatar"] != nil) { + INImage *groupAvatarImage = + [self inImageFromCommunicationAvatarString:communicationInfo[@"groupAvatar"]]; + if (groupAvatarImage != nil) { + [intent setImage:groupAvatarImage forParameterNamed:@"speakableGroupName"]; + } } return intent; diff --git a/packages/react-native/src/types/NotificationIOS.ts b/packages/react-native/src/types/NotificationIOS.ts index 68c3ac60..cc7955a4 100644 --- a/packages/react-native/src/types/NotificationIOS.ts +++ b/packages/react-native/src/types/NotificationIOS.ts @@ -121,7 +121,12 @@ export interface NotificationIOS { } /** - * An interface to support communication notifications on iOS 15 and above + * An interface to support communication notifications on iOS 15 and above. + * + * Avatars (`sender.avatar`, `groupAvatar`) accept a remote `https://` URL (preferred for + * Notification Service Extension downloads), a local `file://` path, an absolute path, or a + * bundle resource name. The native core loads image bytes into `INImage` so lock-screen circular + * avatars render reliably (including after NSE disk caching). * * @platform ios */ @@ -129,6 +134,10 @@ export interface IOSCommunicationInfo { conversationId: string; body?: string; groupName?: string; + /** + * Optional group avatar. Prefer `https://` for remote pushes; local `file://` / absolute paths + * also work for foreground `displayNotification` demos. + */ groupAvatar?: string; sender: IOSCommunicationInfoPerson; } @@ -136,6 +145,10 @@ export interface IOSCommunicationInfo { export interface IOSCommunicationInfoPerson { id: string; displayName: string; + /** + * Optional sender avatar URL or local path. Remote HTTPS is preferred for background / NSE. + * Local `file://` paths are loaded as image data (not `INImage imageWithURL:`). + */ avatar?: string; }