311 lines
8.6 KiB
TypeScript
311 lines
8.6 KiB
TypeScript
import messaging, {
|
|
FirebaseMessagingTypes,
|
|
} from '@react-native-firebase/messaging';
|
|
import notifee, {
|
|
AndroidImportance,
|
|
AndroidVisibility,
|
|
AuthorizationStatus,
|
|
EventType,
|
|
} from '@notifee/react-native';
|
|
import { Platform } from 'react-native';
|
|
|
|
const CHANNEL_ID = 'convex_crm_default';
|
|
const CHANNEL_NAME = 'Convex CRM Notifications';
|
|
const CHANNEL_DESCRIPTION = 'General push notifications for Convex CRM';
|
|
|
|
export interface NotificationData {
|
|
[key: string]: string;
|
|
}
|
|
|
|
export type MessageHandler = (
|
|
message: FirebaseMessagingTypes.RemoteMessage,
|
|
) => void;
|
|
|
|
export type NotificationOpenHandler = (data: NotificationData) => void;
|
|
|
|
export async function requestPermission(): Promise<boolean> {
|
|
try {
|
|
const settings = await notifee.requestPermission();
|
|
const granted =
|
|
settings.authorizationStatus === AuthorizationStatus.AUTHORIZED ||
|
|
settings.authorizationStatus === AuthorizationStatus.PROVISIONAL;
|
|
console.log('[NotificationService] Permission granted:', granted);
|
|
return granted;
|
|
} catch (error) {
|
|
console.error('[NotificationService] requestPermission error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function createDefaultChannel(): Promise<void> {
|
|
if (Platform.OS !== 'android') {
|
|
return;
|
|
}
|
|
try {
|
|
await notifee.createChannel({
|
|
id: CHANNEL_ID,
|
|
name: CHANNEL_NAME,
|
|
description: CHANNEL_DESCRIPTION,
|
|
importance: AndroidImportance.HIGH,
|
|
visibility: AndroidVisibility.PUBLIC,
|
|
sound: 'default',
|
|
vibration: true,
|
|
vibrationPattern: [300, 500],
|
|
lights: true,
|
|
lightColor: '#4F46E5', // indigo accent
|
|
});
|
|
console.log('[NotificationService] Default Android channel created:', CHANNEL_ID);
|
|
} catch (error) {
|
|
console.error('[NotificationService] createDefaultChannel error:', error);
|
|
}
|
|
}
|
|
|
|
export async function getFCMToken(): Promise<string | null> {
|
|
try {
|
|
// Ensure FCM is registered to receive messages
|
|
if (!messaging().isDeviceRegisteredForRemoteMessages) {
|
|
await messaging().registerDeviceForRemoteMessages();
|
|
}
|
|
const token = await messaging().getToken();
|
|
console.log('[NotificationService] FCM Token:', token);
|
|
return token;
|
|
} catch (error) {
|
|
console.error('[NotificationService] getFCMToken error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to token-refresh events.
|
|
* Call this once; the returned function unsubscribes when invoked.
|
|
*/
|
|
export function onTokenRefresh(
|
|
callback: (token: string) => void,
|
|
): () => void {
|
|
const unsubscribe = messaging().onTokenRefresh(newToken => {
|
|
console.log('[NotificationService] FCM token refreshed:', newToken);
|
|
callback(newToken);
|
|
});
|
|
return unsubscribe;
|
|
}
|
|
|
|
export async function displayNotification(
|
|
title: string,
|
|
body: string,
|
|
data: NotificationData = {},
|
|
): Promise<void> {
|
|
try {
|
|
await notifee.displayNotification({
|
|
title,
|
|
body,
|
|
data,
|
|
android: {
|
|
channelId: CHANNEL_ID,
|
|
importance: AndroidImportance.HIGH,
|
|
smallIcon: 'ic_launcher', // use ic_notification once drawable is added to android/app/src/main/res/drawable
|
|
pressAction: { id: 'default' },
|
|
color: '#4F46E5',
|
|
},
|
|
ios: {
|
|
sound: 'default',
|
|
foregroundPresentationOptions: {
|
|
alert: true,
|
|
badge: true,
|
|
sound: true,
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('[NotificationService] displayNotification error:', error);
|
|
}
|
|
}
|
|
|
|
export function onForegroundMessage(
|
|
onOpen?: NotificationOpenHandler,
|
|
): () => void {
|
|
// FCM foreground listener
|
|
const unsubscribeFCM = messaging().onMessage(
|
|
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
|
|
console.log('[NotificationService] Foreground FCM message:', remoteMessage);
|
|
|
|
const title =
|
|
remoteMessage.notification?.title ?? 'Convex CRM';
|
|
const body =
|
|
remoteMessage.notification?.body ?? 'You have a new notification';
|
|
const data = (remoteMessage.data ?? {}) as NotificationData;
|
|
|
|
await displayNotification(title, body, data);
|
|
},
|
|
);
|
|
|
|
// Notifee foreground event listener (handles taps on Notifee notifications)
|
|
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
|
|
if (type === EventType.PRESS && detail.notification?.data) {
|
|
console.log(
|
|
'[NotificationService] Notifee notification pressed (foreground):',
|
|
detail.notification.data,
|
|
);
|
|
onOpen?.(detail.notification.data as NotificationData);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unsubscribeFCM();
|
|
unsubscribeNotifee();
|
|
};
|
|
}
|
|
|
|
export function registerBackgroundHandler(): void {
|
|
messaging().setBackgroundMessageHandler(
|
|
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
|
|
console.log(
|
|
'[NotificationService] Background FCM message:',
|
|
remoteMessage,
|
|
);
|
|
// FCM data-only messages in the background need manual display
|
|
if (remoteMessage.data && !remoteMessage.notification) {
|
|
const title =
|
|
(remoteMessage.data.title as string) ?? 'Convex CRM';
|
|
const body =
|
|
(remoteMessage.data.body as string) ?? 'You have a new notification';
|
|
await displayNotification(title, body, remoteMessage.data as NotificationData);
|
|
}
|
|
},
|
|
);
|
|
|
|
// Notifee background event (handles taps on notifications from background)
|
|
notifee.onBackgroundEvent(async ({ type, detail }) => {
|
|
if (type === EventType.PRESS) {
|
|
console.log(
|
|
'[NotificationService] Notifee notification pressed (background):',
|
|
detail.notification?.data,
|
|
);
|
|
}
|
|
if (type === EventType.DISMISSED) {
|
|
console.log(
|
|
'[NotificationService] Notification dismissed (background)',
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Subscribe to FCM notification taps when the app is in the background
|
|
* (but not fully quit). Returns an unsubscribe function.
|
|
*/
|
|
export function onNotificationOpenedApp(
|
|
callback: NotificationOpenHandler,
|
|
): () => void {
|
|
const unsubscribe = messaging().onNotificationOpenedApp(remoteMessage => {
|
|
console.log(
|
|
'[NotificationService] App opened from background notification:',
|
|
remoteMessage,
|
|
);
|
|
callback((remoteMessage.data ?? {}) as NotificationData);
|
|
});
|
|
return unsubscribe;
|
|
}
|
|
|
|
/**
|
|
* Check whether the app was launched by tapping a notification
|
|
* while it was fully quit. Call once on app startup.
|
|
*/
|
|
export async function getInitialNotification(
|
|
callback: NotificationOpenHandler,
|
|
): Promise<void> {
|
|
try {
|
|
const remoteMessage = await messaging().getInitialNotification();
|
|
if (remoteMessage) {
|
|
console.log(
|
|
'[NotificationService] App launched from quit-state notification:',
|
|
remoteMessage,
|
|
);
|
|
callback((remoteMessage.data ?? {}) as NotificationData);
|
|
}
|
|
} catch (error) {
|
|
console.error('[NotificationService] getInitialNotification error:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the app icon badge count (iOS only; no-op on Android).
|
|
*/
|
|
export async function setBadgeCount(count: number): Promise<void> {
|
|
try {
|
|
await notifee.setBadgeCount(count);
|
|
} catch (error) {
|
|
console.error('[NotificationService] setBadgeCount error:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear the app icon badge (iOS only; no-op on Android).
|
|
*/
|
|
export async function clearBadge(): Promise<void> {
|
|
try {
|
|
await notifee.setBadgeCount(0);
|
|
} catch (error) {
|
|
console.error('[NotificationService] clearBadge error:', error);
|
|
}
|
|
}
|
|
|
|
export interface InitializeOptions {
|
|
onNotificationOpen?: NotificationOpenHandler;
|
|
onTokenRefreshed?: (token: string) => void;
|
|
}
|
|
|
|
export async function initialize(
|
|
options: InitializeOptions = {},
|
|
): Promise<() => void> {
|
|
const { onNotificationOpen, onTokenRefreshed } = options;
|
|
|
|
// 1. Permission
|
|
await requestPermission();
|
|
|
|
// 2. Android channel
|
|
await createDefaultChannel();
|
|
|
|
// 3. FCM token
|
|
await getFCMToken();
|
|
|
|
// 4. Token refresh
|
|
const unsubscribeTokenRefresh = onTokenRefresh(token => {
|
|
onTokenRefreshed?.(token);
|
|
});
|
|
|
|
// 5. Foreground messages
|
|
const unsubscribeForeground = onForegroundMessage(onNotificationOpen);
|
|
|
|
// 6. Background tap
|
|
const unsubscribeBackgroundOpen = onNotificationOpenedApp(data => {
|
|
onNotificationOpen?.(data);
|
|
});
|
|
|
|
// 7. Quit-state launch
|
|
await getInitialNotification(data => {
|
|
onNotificationOpen?.(data);
|
|
});
|
|
|
|
// Return aggregate cleanup
|
|
return () => {
|
|
unsubscribeTokenRefresh();
|
|
unsubscribeForeground();
|
|
unsubscribeBackgroundOpen();
|
|
};
|
|
}
|
|
|
|
export const NotificationService = {
|
|
requestPermission,
|
|
createDefaultChannel,
|
|
getFCMToken,
|
|
onTokenRefresh,
|
|
displayNotification,
|
|
onForegroundMessage,
|
|
registerBackgroundHandler,
|
|
onNotificationOpenedApp,
|
|
getInitialNotification,
|
|
setBadgeCount,
|
|
clearBadge,
|
|
initialize,
|
|
};
|