Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for notification actions on iOS #773

Merged
merged 5 commits into from
Feb 5, 2018
Merged
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
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ FCM.on(FCMEvent.Notification, async (notif) => {
// await someAsyncCall();

if(Platform.OS ==='ios'){
if (notif._actionIdentifier === 'com.myapp.MyCategory.Confirm') {
// handle notification action here
// the text from user is in notif._userText if type of the action is NotificationActionType.TextInput
}
//optional
//iOS requires developers to call completionHandler to end notification process. If you do not call it your background remote notifications could be throttled, to read more about it see https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623013-application.
//This library handles it for you automatically with default behavior (for remote notification, finish with NoData; for WillPresent, finish depend on "show_in_foreground"). However if you want to return different result, follow the following code to override
Expand Down Expand Up @@ -372,7 +376,7 @@ class App extends Component {
body: "My Notification Message", // as FCM payload (required)
sound: "default", // as FCM payload
priority: "high", // as FCM payload
click_action: "ACTION", // as FCM payload
click_action: "com.myapp.MyCategory", // as FCM payload - this is used as category identifier on iOS.
badge: 10, // as FCM payload IOS only, set 0 to clear badges
number: 10, // Android only
ticker: "My Notification Ticker", // Android only
Expand Down Expand Up @@ -416,6 +420,28 @@ class App extends Component {
my_custom_data_2: 'my_custom_field_value_2'
});

// Call this somewhere at initialization to register types of your actionable notifications. See https://goo.gl/UanU9p.
FCM.setNotificationCategories([
{
id: 'com.myapp.MyCategory',
actions: [
{
type: NotificationActionType.Default, // or NotificationActionType.TextInput
id: 'com.myapp.MyCategory.Confirm',
title: 'Confirm', // Use with NotificationActionType.Default
textInputButtonTitle: 'Send', // Use with NotificationActionType.TextInput
textInputPlaceholder: 'Message', // Use with NotificationActionType.TextInput
// Available options: NotificationActionOption.None, NotificationActionOption.AuthenticationRequired, NotificationActionOption.Destructive and NotificationActionOption.Foreground.
options: NotificationActionOption.AuthenticationRequired, // single or array
},
],
intentIdentifiers: [],
// Available options: NotificationCategoryOption.None, NotificationCategoryOption.CustomDismissAction and NotificationCategoryOption.AllowInCarPlay.
// On iOS >= 11.0 there is also NotificationCategoryOption.PreviewsShowTitle and NotificationCategoryOption.PreviewsShowSubtitle.
options: [NotificationCategoryOption.CustomDismissAction, NotificationCategoryOption.PreviewsShowTitle], // single or array
},
]);

FCM.deleteInstanceId()
.then( () => {
//Deleted instance id successfully
Expand Down Expand Up @@ -543,6 +569,26 @@ FCM.send('984XXXXXXXXX', {

The `Data Object` is message data comprising as many key-value pairs of the message's payload as are needed (ensure that the value of each pair in the data object is a `string`). Your `Sender ID` is a unique numerical value generated when you created your Firebase project, it is available in the `Cloud Messaging` tab of the Firebase console `Settings` pane. The sender ID is used to identify each app server that can send messages to the client app.

### Sending remote notifications with category on iOS
If you want to send notification which will have actions as you defined in app it's important to correctly set it's `category` (`click_action`) property. It's also good to set `"content-available" : 1` so app will gets enough time to handle actions in background.

So the fcm payload should look like this:
```javascript
{
"to": "some_device_token",
"content_available": true,
"notification": {
"title": "Alarm",
"subtitle": "First Alarm",
"body": "First Alarm",
"click_action": "com.myapp.MyCategory" // The id of notification category which you defined with FCM.setNotificationCategories
},
"data": {
"extra": "juice"
}
}
```

## Q & A

#### Why do you build another local notification
Expand Down
41 changes: 41 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ declare module "react-native-fcm" {
const Local = "local_notification";
}

export enum NotificationCategoryOption {
CustomDismissAction = 'UNNotificationCategoryOptionCustomDismissAction',
AllowInCarPlay = 'UNNotificationCategoryOptionAllowInCarPlay',
PreviewsShowTitle = 'UNNotificationCategoryOptionHiddenPreviewsShowTitle',
PreviewsShowSubtitle = 'UNNotificationCategoryOptionHiddenPreviewsShowSubtitle',
None = 'UNNotificationCategoryOptionNone'
}

export enum NotificationActionOption {
AuthenticationRequired = 'UNNotificationActionOptionAuthenticationRequired',
Destructive = 'UNNotificationActionOptionDestructive',
Foreground = 'UNNotificationActionOptionForeground',
None = 'UNNotificationActionOptionNone'
}

export enum NotificationActionType {
Default = 'UNNotificationActionTypeDefault',
TextInput = 'UNNotificationActionTypeTextInput',
}

export interface Notification {
collapse_key: string;
opened_from_tray: boolean;
Expand All @@ -44,6 +64,8 @@ declare module "react-native-fcm" {
};
local_notification?: boolean;
_notificationType: string;
_actionIdentifier?: string;
_userText?: string;
finish(type?: string): void;
[key: string]: any;
}
Expand Down Expand Up @@ -83,6 +105,23 @@ declare module "react-native-fcm" {
remove(): void;
}

export interface NotificationAction {
type: NotificationActionType;
id: string;
title?: string;
textInputButtonTitle?: string;
textInputPlaceholder?: string;
options: NotificationActionOption | NotificationActionOption[];
}

export interface NotificationCategory {
id: string;
actions: NotificationAction[];
intentIdentifiers: string[];
hiddenPreviewsBodyPlaceholder?: string;
options?: NotificationCategoryOption | NotificationCategoryOption[];
}

export class FCM {
static requestPermissions(): Promise<void>;
static getFCMToken(): Promise<string>;
Expand All @@ -109,6 +148,8 @@ declare module "react-native-fcm" {
static enableDirectChannel(): void
static isDirectChannelEstablished(): Promise<boolean>
static getAPNSToken(): Promise<string>

static setNotificationCategories(categories: NotificationCategory[]): void;
}

export default FCM;
Expand Down
28 changes: 28 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ export const NotificationType = {
Local: 'local_notification'
};

export const NotificationCategoryOption = {
CustomDismissAction: 'UNNotificationCategoryOptionCustomDismissAction',
AllowInCarPlay: 'UNNotificationCategoryOptionAllowInCarPlay',
PreviewsShowTitle: 'UNNotificationCategoryOptionHiddenPreviewsShowTitle',
PreviewsShowSubtitle: 'UNNotificationCategoryOptionHiddenPreviewsShowSubtitle',
None: 'UNNotificationCategoryOptionNone'
};

export const NotificationActionOption = {
AuthenticationRequired: 'UNNotificationActionOptionAuthenticationRequired',
Destructive: 'UNNotificationActionOptionDestructive',
Foreground: 'UNNotificationActionOptionForeground',
None: 'UNNotificationActionOptionNone',
};

export const NotificationActionType = {
Default: 'UNNotificationActionTypeDefault',
TextInput: 'UNNotificationActionTypeTextInput',
};

const RNFIRMessaging = NativeModules.RNFIRMessaging;

const FCM = {};
Expand Down Expand Up @@ -174,4 +194,12 @@ FCM.send = (senderId, payload) => {
RNFIRMessaging.send(senderId, payload);
};

FCM.setNotificationCategories = (categories) => {
if (Platform.OS === 'ios') {
RNFIRMessaging.setNotificationCategories(categories);
}
}

export default FCM;

export {};
133 changes: 131 additions & 2 deletions ios/RNFIRMessaging.m
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,94 @@ + (UILocalNotification *)UILocalNotification:(id)json

@end

@implementation RCTConvert (UNNotificationAction)

typedef NS_ENUM(NSUInteger, UNNotificationActionType) {
UNNotificationActionTypeDefault,
UNNotificationActionTypeTextInput
};

+ (UNNotificationAction *) UNNotificationAction:(id)json {
NSDictionary<NSString *, id> *details = [self NSDictionary:json];

NSString *identifier = [RCTConvert NSString: details[@"id"]];
NSString *title = [RCTConvert NSString: details[@"title"]];
UNNotificationActionOptions options = [RCTConvert UNNotificationActionOptions: details[@"options"]];
UNNotificationActionType type = [RCTConvert UNNotificationActionType:details[@"type"]];

if (type == UNNotificationActionTypeTextInput) {
NSString *textInputButtonTitle = [RCTConvert NSString: details[@"textInputButtonTitle"]];
NSString *textInputPlaceholder = [RCTConvert NSString: details[@"textInputPlaceholder"]];

return [UNTextInputNotificationAction actionWithIdentifier:identifier title:title options:options textInputButtonTitle:textInputButtonTitle textInputPlaceholder:textInputPlaceholder];
}

return [UNNotificationAction actionWithIdentifier:identifier
title:title
options:options];

}

RCT_ENUM_CONVERTER(UNNotificationActionType, (@{
@"UNNotificationActionTypeDefault": @(UNNotificationActionTypeDefault),
@"UNNotificationActionTypeTextInput": @(UNNotificationActionTypeTextInput),
}), UNNotificationActionTypeDefault, integerValue)


RCT_MULTI_ENUM_CONVERTER(UNNotificationActionOptions, (@{
@"UNNotificationActionOptionAuthenticationRequired": @(UNNotificationActionOptionAuthenticationRequired),
@"UNNotificationActionOptionDestructive": @(UNNotificationActionOptionDestructive),
@"UNNotificationActionOptionForeground": @(UNNotificationActionOptionForeground),
@"UNNotificationActionOptionNone": @(UNNotificationActionOptionNone),
}), UNNotificationActionOptionNone, integerValue)


@end

@implementation RCTConvert (UNNotificationCategory)


+ (UNNotificationCategory *) UNNotificationCategory:(id)json {
NSDictionary<NSString *, id> *details = [self NSDictionary:json];

NSString *identifier = [RCTConvert NSString: details[@"id"]];

NSMutableArray *actions = [[NSMutableArray alloc] init];
for (NSDictionary *actionDict in details[@"actions"]) {
[actions addObject:[RCTConvert UNNotificationAction:actionDict]];
}

NSArray<NSString *> *intentIdentifiers = [RCTConvert NSStringArray:details[@"intentIdentifiers"]];
NSString *hiddenPreviewsBodyPlaceholder = [RCTConvert NSString:details[@"hiddenPreviewsBodyPlaceholder"]];
UNNotificationCategoryOptions options = [RCTConvert UNNotificationCategoryOptions: details[@"options"]];

if (hiddenPreviewsBodyPlaceholder) {
if (@available(iOS 11.0, *)) {
return [UNNotificationCategory categoryWithIdentifier:identifier actions:actions intentIdentifiers:intentIdentifiers hiddenPreviewsBodyPlaceholder:hiddenPreviewsBodyPlaceholder options:options];
}
}

return [UNNotificationCategory categoryWithIdentifier:identifier actions:actions intentIdentifiers:intentIdentifiers options:options];
}

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"

RCT_MULTI_ENUM_CONVERTER(UNNotificationCategoryOptions, (@{
@"UNNotificationCategoryOptionNone": @(UNNotificationCategoryOptionNone),
@"UNNotificationCategoryOptionCustomDismissAction": @(UNNotificationCategoryOptionCustomDismissAction),
@"UNNotificationCategoryOptionAllowInCarPlay": @(UNNotificationCategoryOptionAllowInCarPlay),
@"UNNotificationCategoryOptionHiddenPreviewsShowTitle": @(UNNotificationCategoryOptionHiddenPreviewsShowTitle),
@"UNNotificationCategoryOptionHiddenPreviewsShowSubtitle": @(UNNotificationCategoryOptionHiddenPreviewsShowSubtitle),
}), UNNotificationCategoryOptionNone, integerValue)

#pragma clang diagnostic pop


@end

static NSDictionary *initialNotificationActionResponse;

@interface RNFIRMessaging ()
@property (nonatomic, strong) NSMutableDictionary *notificationCallbacks;
@end
Expand All @@ -144,7 +232,7 @@ @implementation RNFIRMessaging
}

+ (BOOL)requiresMainQueueSetup {
return YES;
return YES;
}

+ (void)didReceiveRemoteNotification:(nonnull NSDictionary *)userInfo fetchCompletionHandler:(nonnull RCTRemoteNotificationCallback)completionHandler {
Expand All @@ -169,7 +257,21 @@ + (void)didReceiveNotificationResponse:(UNNotificationResponse *)response withCo
if (response.actionIdentifier) {
[data setValue:response.actionIdentifier forKey:@"_actionIdentifier"];
}
[[NSNotificationCenter defaultCenter] postNotificationName:FCMNotificationReceived object:self userInfo:@{@"data": data, @"completionHandler": completionHandler}];

if ([response isKindOfClass:UNTextInputNotificationResponse.class]) {
[data setValue:[(UNTextInputNotificationResponse *)response userText] forKey:@"_userText"];
}

NSDictionary *userInfo = @{@"data": data, @"completionHandler": completionHandler};

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (data[@"_actionIdentifier"] && ![data[@"_actionIdentifier"] isEqualToString:UNNotificationDefaultActionIdentifier]) {
initialNotificationActionResponse = userInfo;
Copy link
Owner

@evollu evollu Feb 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need to assign initialNotificationActionResponse here and use it in line 318? can't you just call postNotificationName?
@krystofcelba

Copy link
Contributor Author

@krystofcelba krystofcelba Feb 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because if the app is killed and the user taps an action, the didReceiveRemoteNotification:fetchCompletionHandler: method gets called right after the app starts so the JS context isn't running and handler for that isn't registered. So the notification is kept here and dispatched again after the appropriate handler is registered in addListener. I tested it, and it is very robust this way. It works even if the action isn't marked as foreground so the app only have some limited amount of time to do something in the background (30sec I think).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks.
I figured that yesterday.
I added #792 to handle this issue for all types of notification callbacks
should be compatible with you changes

}
});

[[NSNotificationCenter defaultCenter] postNotificationName:FCMNotificationReceived object:self userInfo:userInfo];
}

+ (void)willPresentNotification:(UNNotification *)notification withCompletionHandler:(nonnull RCTWillPresentNotificationCallback)completionHandler
Expand Down Expand Up @@ -210,6 +312,14 @@ - (instancetype)init {
return self;
}

-(void) addListener:(NSString *)eventName {
[super addListener:eventName];

if([eventName isEqualToString:FCMNotificationReceived] && initialNotificationActionResponse) {
[[NSNotificationCenter defaultCenter] postNotificationName:FCMNotificationReceived object:self userInfo:[initialNotificationActionResponse copy]];
}
}

RCT_EXPORT_METHOD(enableDirectChannel)
{
[[FIRMessaging messaging] setShouldEstablishDirectChannel:@YES];
Expand All @@ -230,6 +340,8 @@ - (instancetype)init {
}
}



RCT_EXPORT_METHOD(getAPNSToken:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
{
NSData * deviceToken = [FIRMessaging messaging].APNSToken;
Expand Down Expand Up @@ -416,6 +528,20 @@ - (void)applicationReceivedRemoteMessage:(FIRMessagingRemoteMessage *)remoteMess
}
}

RCT_EXPORT_METHOD(setNotificationCategories:(NSArray *)categories)
{
if([UNUserNotificationCenter currentNotificationCenter] != nil) {
NSMutableSet *categoriesSet = [[NSMutableSet alloc] init];

for(NSDictionary *categoryDict in categories) {
UNNotificationCategory *category = [RCTConvert UNNotificationCategory:categoryDict];
[categoriesSet addObject:category];
}

[[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:categoriesSet];
}
}

RCT_EXPORT_METHOD(setBadgeNumber: (NSInteger) number)
{
dispatch_async(dispatch_get_main_queue(), ^{
Expand Down Expand Up @@ -493,6 +619,9 @@ - (void)handleNotificationReceived:(NSNotification *)notification

[self sendEventWithName:FCMNotificationReceived body:data];

if (initialNotificationActionResponse) {
initialNotificationActionResponse = nil;
}
}

- (void)sendDataMessageFailure:(NSNotification *)notification
Expand Down