Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions docs/react-native/ios/appearance.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 52 additions & 8 deletions ios/NotifeeCore/NotifeeCoreUtil.m
Original file line number Diff line number Diff line change
Expand Up @@ -1249,26 +1249,64 @@ + (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, *)) {
NSDictionary *sender = communicationInfo[@"sender"];
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
nameComponents:nil
displayName:sender[@"displayName"]
image:avatar
contactIdentifier:nil
customIdentifier:nil];
customIdentifier:sender[@"id"]];

NSMutableArray *recipients = nil;

Expand Down Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion packages/react-native/src/types/NotificationIOS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,21 +121,34 @@ 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
*/
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;
}

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;
}

Expand Down