Skip to content

Push Notifications

Customer support teams need immediate notification of:

  • New messages in conversations
  • Conversation assignments
  • Mentions and team activity
  • System notifications
  • Even when the app is closed or backgrounded

Why Firebase Cloud Messaging:

  • Reliable cross-platform delivery (iOS & Android)
  • Background notification handling
  • Rich notification support (images, actions)
  • Deep linking integration
  • Free tier sufficient for most use cases

Libraries:

  • @react-native-firebase/messaging - FCM integration
  • @notifee/react-native - Local notification display (iOS)
Zelta Chat Server
Firebase Cloud Messaging
├─ App Foreground → onMessage
│ ↓
│ Display In-App Notification
├─ App Background → onNotificationOpenedApp
│ ↓
│ Navigate to Conversation
└─ App Closed → getInitialNotification
Launch App → Navigate to Conversation

Supported Notification Types:

const NOTIFICATION_TYPES = [
'conversation_creation',
'conversation_assignment',
'assigned_conversation_new_message',
'conversation_mention',
'participating_conversation_new_message',
];

FCM Message:

{
"data": {
"payload": {
"data": {
"notification": {
"notification_type": "assigned_conversation_new_message",
"primary_actor": {
"id": 123,
"conversation_id": 456
},
"primary_actor_type": "Message",
"primary_actor_id": 123
}
}
}
}
}
App First Launch
Request Notification Permission
├─ Granted
│ ↓
│ Get FCM Token
│ ↓
│ Register Token with Zelta Chat Server
│ ↓
│ Subscribe to Topics
└─ Denied
Show Settings Prompt

Notification Received Flow (App Foreground)

Section titled “Notification Received Flow (App Foreground)”
FCM Message Received
onMessage Handler
Parse Notification Data
Extract Conversation ID
Display In-App Notification (Notifee)
User Taps Notification
Navigate to Conversation
User Taps Notification
App Brought to Foreground
onNotificationOpenedApp Handler
Extract FCM Message Data
Transform to Conversation Link
Navigate via Deep Link System
Load Conversation
User Taps Notification
App Launches
getInitialNotification
Extract Message Data
Store Deep Link Intent
App Initialization Complete
Navigate to Conversation

Message Handler Registration (src/navigation/index.tsx):

import messaging from '@react-native-firebase/messaging';
// Background message handler (must be at top level)
messaging().setBackgroundMessageHandler(async remoteMessage => {
console.log('Message handled in the background!', remoteMessage);
// Process notification data
});

Permission Request:

const requestNotificationPermission = async () => {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
const token = await messaging().getToken();
// Send token to Zelta Chat server
await registerFCMToken(token);
}
};

Extract Notification Data (src/utils/pushUtils.ts):

export const findNotificationFromFCM = ({ message }: { message: any }) => {
let notification = null;
// FCM HTTP v1
if (message?.data?.payload) {
const parsedPayload = JSON.parse(message.data.payload);
notification = parsedPayload.data.notification;
}
// FCM legacy
else {
notification = JSON.parse(message.data.notification);
}
return notification;
};

Build Conversation Link:

export const findConversationLinkFromPush = ({
notification,
installationUrl,
}: {
notification: Notification;
installationUrl: string;
}) => {
const { notificationType, primaryActor, primaryActorId, primaryActorType } = notification;
if (NOTIFICATION_TYPES.includes(notificationType)) {
let conversationId = null;
if (primaryActorType === 'Conversation') {
conversationId = primaryActor.id;
} else if (primaryActorType === 'Message') {
conversationId = primaryActor.conversationId;
}
if (conversationId) {
return `${installationUrl}/app/accounts/1/conversations/${conversationId}/${primaryActorId}/${primaryActorType}`;
}
}
return undefined;
};

Initial URL Handling (src/navigation/index.tsx):

const linking = {
prefixes: [installationUrl],
async getInitialURL() {
// Check deep link first
const url = await Linking.getInitialURL();
if (url) return url;
// Check FCM notification
const message = await messaging().getInitialNotification();
if (message) {
const notification = findNotificationFromFCM({ message });
const camelCaseNotification = transformNotification(notification);
const conversationLink = findConversationLinkFromPush({
notification: camelCaseNotification,
installationUrl,
});
return conversationLink;
}
return undefined;
},
subscribe(listener: (url: string) => void) {
// Listen for notifications when app in background
const unsubscribe = messaging().onNotificationOpenedApp(message => {
if (message) {
const notification = findNotificationFromFCM({ message });
const camelCaseNotification = transformNotification(notification);
const conversationLink = findConversationLinkFromPush({
notification: camelCaseNotification,
installationUrl,
});
if (conversationLink) {
listener(conversationLink);
}
}
});
return unsubscribe;
},
};

Display Local Notification (iOS only):

import notifee from '@notifee/react-native';
const displayLocalNotification = async (notification: Notification) => {
if (Platform.OS === 'ios') {
await notifee.displayNotification({
title: notification.pushMessageTitle,
body: notification.primaryActor.content,
data: notification,
ios: {
sound: 'default',
},
});
}
};

Clear Notifications:

export const clearAllDeliveredNotifications = async () => {
if (Platform.OS === 'ios') {
await notifee.cancelAllNotifications();
}
};

Update Badge Count:

export const updateBadgeCount = async ({ count = 0 }) => {
if (Platform.OS === 'ios' && count >= 0) {
await notifee.setBadgeCount(count);
}
};

✅ Good Patterns:

// Always check notification type
if (NOTIFICATION_TYPES.includes(notificationType)) {
// Handle notification
}
// Clear notifications on app launch
useEffect(() => {
clearAllDeliveredNotifications();
}, []);
// Update badge count on unread changes
useEffect(() => {
updateBadgeCount({ count: unreadCount });
}, [unreadCount]);

❌ Anti-Patterns:

// DON'T: Forget to handle null/undefined
const link = findConversationLinkFromPush(notification);
navigate(link); // ❌ link might be undefined
// DON'T: Block the message handler
messaging().setBackgroundMessageHandler(async message => {
await slowAsyncOperation(); // ❌ Handler should be fast
});
// DON'T: Show notifications when app is focused on that conversation
if (activeConversationId === notificationConversationId) {
return; // ✅ Don't show duplicate notification
}

Prevent notification spam:

const recentNotifications = new Set<string>();
const THROTTLE_WINDOW = 5000; // 5 seconds
const shouldShowNotification = (notificationId: string): boolean => {
if (recentNotifications.has(notificationId)) {
return false;
}
recentNotifications.add(notificationId);
setTimeout(() => {
recentNotifications.delete(notificationId);
}, THROTTLE_WINDOW);
return true;
};

Group notifications by conversation:

const groupNotifications = (notifications: Notification[]) => {
const grouped = new Map<number, Notification[]>();
notifications.forEach(notif => {
const convId = notif.primaryActor.conversationId;
if (!grouped.has(convId)) {
grouped.set(convId, []);
}
grouped.get(convId)!.push(notif);
});
return grouped;
};
jest.mock('@react-native-firebase/messaging', () => ({
default: jest.fn(() => ({
requestPermission: jest.fn(() => Promise.resolve(1)),
getToken: jest.fn(() => Promise.resolve('mock-token')),
getInitialNotification: jest.fn(() => Promise.resolve(null)),
onNotificationOpenedApp: jest.fn(),
setBackgroundMessageHandler: jest.fn(),
})),
}));
describe('Push Notifications', () => {
it('should extract conversation link from FCM message', () => {
const message = {
data: {
payload: JSON.stringify({
data: {
notification: {
notification_type: 'assigned_conversation_new_message',
primary_actor: { id: 123, conversation_id: 456 },
primary_actor_type: 'Message',
},
},
}),
},
};
const notification = findNotificationFromFCM({ message });
const link = findConversationLinkFromPush({
notification,
installationUrl: 'https://app.Zelta Chat.com',
});
expect(link).toContain('/conversations/456');
});
});
await notifee.displayNotification({
title: 'New Message',
body: message.content,
ios: {
categoryId: 'message',
foregroundPresentationOptions: {
badge: true,
sound: true,
banner: true,
},
},
});
// Register category with actions
await notifee.setNotificationCategories([
{
id: 'message',
actions: [
{
id: 'reply',
title: 'Reply',
input: true,
},
{
id: 'mark-read',
title: 'Mark as Read',
},
],
},
]);
await notifee.displayNotification({
title: 'New Message',
body: message.content,
ios: {
attachments: [
{
url: message.imageUrl,
thumbnailTime: 3,
},
],
},
});
const subscribeToTopics = async (accountId: number, userId: number) => {
await messaging().subscribeToTopic(`account_${accountId}`);
await messaging().subscribeToTopic(`user_${userId}`);
};
const unsubscribeFromTopics = async (accountId: number, userId: number) => {
await messaging().unsubscribeFromTopic(`account_${accountId}`);
await messaging().unsubscribeFromTopic(`user_${userId}`);
};