mobile-notifications-push
Push notification patterns - expo-notifications (Expo) and @react-native-firebase/messaging (bare RN), permission handling, token management, foreground/background/tap listeners, local scheduling, Android channels, notification categories and actions, badge management, rich notif
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-notifications-push/skills/mobile-notifications-push
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Push Notification Patterns
Quick Guide: Two main approaches:
expo-notifications(Expo workflow, unified API for push + local) and@react-native-firebase/messaging(bare RN, FCM/APNs direct). Always request permissions before retrieving tokens. Handle three notification states: foreground (app open), background (app minimized), and quit (app killed). Set up Android notification channels before displaying any notification. Push notifications require a physical device -- they do not work on emulators or simulators.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST request notification permissions BEFORE retrieving push tokens -- calling getExpoPushTokenAsync or messaging().getToken() without permission will fail or return an unusable token)
(You MUST create an Android notification channel BEFORE displaying any notification on Android 8+ -- notifications without a channel are silently dropped)
(You MUST handle ALL three notification states: foreground (onMessage/addNotificationReceivedListener), background (setBackgroundMessageHandler/registerTaskAsync), and tap/response (addNotificationResponseReceivedListener/onNotificationOpenedApp + getInitialNotification))
(You MUST clean up notification listeners on unmount -- leaked listeners cause memory leaks and duplicate handlers)
(You MUST test push notifications on a physical device -- emulators and simulators do not support push tokens)
</critical_requirements>
Auto-detection: expo-notifications, @react-native-firebase/messaging, push notification, push token, getExpoPushTokenAsync, getDevicePushTokenAsync, scheduleNotificationAsync, setNotificationHandler, addNotificationReceivedListener, addNotificationResponseReceivedListener, setBackgroundMessageHandler, onMessage, onNotificationOpenedApp, getInitialNotification, notification channel, setNotificationChannelAsync, notification category, notification actions, setBadgeCountAsync, FCM, APNs, remote notification, local notification, useLastNotificationResponse
When to use:
- Sending remote push notifications to users via FCM/APNs
- Requesting and managing notification permissions
- Retrieving and storing push tokens (Expo push token or native FCM/APNs token)
- Handling notification events in foreground, background, and quit states
- Scheduling local notifications (reminders, timers, recurring alerts)
- Creating Android notification channels with custom sound/vibration/importance
- Adding interactive notification actions (buttons, text input)
- Managing app icon badge counts
- Implementing notification tap navigation (deep linking from notifications)
Key patterns covered:
- Permission request flow with status checking
- Push token retrieval and refresh handling
- Foreground notification presentation (setNotificationHandler)
- Background message handling (headless JS tasks)
- Notification tap/response handling with navigation
- Local notification scheduling with trigger types
- Android notification channels and channel groups
- Notification categories with interactive actions
- Badge count management
- Rich notification content (images, sounds, data payloads)
When NOT to use:
- In-app messaging or toast/snackbar UI (those are UI components, not OS notifications)
- Email or SMS notifications (server-side concern)
- Web push notifications (different API entirely)
Detailed Resources:
- examples/core.md - Permission flow, token management, foreground/background/tap listeners, notification handler setup
- examples/scheduling.md - Local notification scheduling, trigger types, Android channels, categories and actions, badge management
- reference.md - Decision frameworks, platform differences, checklists
<decision_framework>
Decision Framework
Key decisions: which library (expo-notifications vs @react-native-firebase/messaging), which push token type (Expo vs native), which trigger type for local notifications, and which Android channel importance level.
See reference.md for complete decision trees, platform differences table, and channel importance reference.
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Requesting push token before checking/requesting permissions -- fails silently or returns unusable token on iOS
- Missing Android notification channel creation -- notifications silently dropped on Android 8+ (API 26+)
- Not handling the "quit" state --
getInitialNotification()(Firebase) oruseLastNotificationResponse()(Expo) is the only way to get the notification that launched the app - Using
shouldShowAlertinstead ofshouldShowBanner/shouldShowList-- deprecated API, will break in future expo-notifications versions - Not cleaning up listeners on unmount -- causes memory leaks and duplicate notification handlers
- Testing only on simulator/emulator -- push tokens and remote notifications require a physical device
Medium Priority Issues:
- Hardcoding push token on the server without refresh handling -- tokens rotate and become invalid
- Creating notification channels at notification send time instead of app startup -- causes race condition where first notification is dropped
- Using
console.login background handlers -- headless JS tasks may not have console access; use your logging solution - Not sending
channelIdin Android notification payloads -- notification uses default channel, ignoring your custom channel settings - Requesting permission immediately on app launch -- users deny at higher rates without context; request after demonstrating value
Gotchas & Edge Cases:
- iOS permission dialog shows only ONCE natively -- if denied, subsequent
requestPermissionsAsync()calls return "denied" without showing a dialog; direct users to Settings - Expo SDK 53+ dropped push notification support from Expo Go on Android -- you need a development build to test
setBackgroundMessageHandler(Firebase) andregisterTaskAsync(Expo) must be called at the TOP LEVEL of your entry file (index.js), not inside a component- Firebase data-only messages require
priority: "high"(Android) andcontent-available: 1(iOS) to trigger background handlers - Android notification icons must be white with transparent background -- colored icons render as solid white squares
DailyTriggerandWeeklyTriggerare Android-only in expo-notifications -- useCalendarTriggerfor iOS- Notification categories/actions may not show in background/killed state on some Android devices (known limitation)
- Both
expo-notificationsand@react-native-firebase/messagingregister for the same Android FCM intents -- using both requires manual conflict resolution getInitialNotification()returns null if called too late -- call it early in app initialization, not after navigation is ready
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST request notification permissions BEFORE retrieving push tokens -- calling getExpoPushTokenAsync or messaging().getToken() without permission will fail or return an unusable token)
(You MUST create an Android notification channel BEFORE displaying any notification on Android 8+ -- notifications without a channel are silently dropped)
(You MUST handle ALL three notification states: foreground (onMessage/addNotificationReceivedListener), background (setBackgroundMessageHandler/registerTaskAsync), and tap/response (addNotificationResponseReceivedListener/onNotificationOpenedApp + getInitialNotification))
(You MUST clean up notification listeners on unmount -- leaked listeners cause memory leaks and duplicate handlers)
(You MUST test push notifications on a physical device -- emulators and simulators do not support push tokens)
Failure to follow these rules will result in silently dropped notifications, missed user interactions, and platform-specific failures that are difficult to debug.
</critical_reminders>
Files (skills)
-
examples
-
core.md 15.3 KB
# Push Notifications - Core Patterns > Permission flow, token management, foreground/background/tap listeners, complete setup. See [SKILL.md](../SKILL.md) for decision guidance and red flags. **Related:** [scheduling.md](scheduling.md) for local notification scheduling, triggers, channels, and categories. --- ## Pattern 1: Complete Registration Function (Expo) The standard pattern for requesting permissions, creating the default Android channel, and retrieving the Expo push token. ```typescript import * as Notifications from "expo-notifications"; import * as Device from "expo-device"; import Constants from "expo-constants"; import { Platform } from "react-native"; const DEFAULT_CHANNEL_ID = "default"; const DEFAULT_CHANNEL_NAME = "Default"; async function registerForPushNotificationsAsync(): Promise< string | undefined > { // Android channels must be created before any notification is displayed if (Platform.OS === "android") { await Notifications.setNotificationChannelAsync(DEFAULT_CHANNEL_ID, { name: DEFAULT_CHANNEL_NAME, importance: Notifications.AndroidImportance.MAX, vibrationPattern: [0, 250, 250, 250], lightColor: "#FF231F7C", }); } // Push tokens only work on physical devices if (!Device.isDevice) { throw new Error("Push notifications require a physical device"); } // Check existing permission before prompting const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; if (existingStatus !== "granted") { const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status; } if (finalStatus !== "granted") { throw new Error("Notification permission not granted"); } // Retrieve Expo push token with explicit projectId const projectId = Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId; if (!projectId) { throw new Error("Missing projectId for push token registration"); } const tokenData = await Notifications.getExpoPushTokenAsync({ projectId }); return tokenData.data; } ``` **Why good:** creates Android channel before anything else, validates physical device, checks existing permission before prompting, explicit projectId prevents runtime surprises, throws descriptive errors for each failure mode ```typescript // BAD: Missing critical steps async function registerBad() { // No permission check -- token retrieval may fail silently on iOS const token = await Notifications.getExpoPushTokenAsync(); // No projectId -- will fail in production builds // No Android channel -- notifications silently dropped on Android 8+ // No device check -- crashes on simulator return token.data; } ``` **Why bad:** no permission request (iOS will deny token), no projectId (production builds fail), no Android channel (notifications dropped), no device validation (simulator crash) --- ## Pattern 2: Foreground Notification Handler Controls whether and how notifications are presented when the app is in the foreground. Call this once at module scope (outside components) so it runs before any notification arrives. ```typescript import * as Notifications from "expo-notifications"; // Call at module scope in your app entry file Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldPlaySound: true, shouldSetBadge: true, shouldShowBanner: true, shouldShowList: true, }), }); ``` **Why good:** called at module scope (not inside useEffect), uses current API properties, shows notification in both banner and notification center #### Conditional Foreground Handling Suppress the notification banner when the user is already viewing the relevant content. ```typescript import * as Notifications from "expo-notifications"; import type { Notification } from "expo-notifications"; // Track the current screen or conversation let activeScreenId: string | null = null; export function setActiveScreen(screenId: string | null) { activeScreenId = screenId; } Notifications.setNotificationHandler({ handleNotification: async (notification: Notification) => { const data = notification.request.content.data; const isCurrentScreen = data?.screenId === activeScreenId; return { shouldPlaySound: !isCurrentScreen, shouldSetBadge: true, shouldShowBanner: !isCurrentScreen, shouldShowList: true, }; }, }); ``` **Why good:** avoids duplicate alerts when user is already on the target screen, still updates badge and notification center, sound suppressed contextually --- ## Pattern 3: Notification Listeners Hook (Expo) A custom hook that wires up foreground, tap/response, and token refresh listeners with proper cleanup. ```typescript import { useEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; import type { Notification, NotificationResponse } from "expo-notifications"; interface UseNotificationListenersOptions { onNotificationReceived?: (notification: Notification) => void; onNotificationResponse?: (response: NotificationResponse) => void; onTokenRefresh?: (token: string) => void; } export function useNotificationListeners( options: UseNotificationListenersOptions, ) { const { onNotificationReceived, onNotificationResponse, onTokenRefresh } = options; // Use refs to avoid re-subscribing when callbacks change const receivedRef = useRef(onNotificationReceived); const responseRef = useRef(onNotificationResponse); const tokenRef = useRef(onTokenRefresh); useEffect(() => { receivedRef.current = onNotificationReceived; responseRef.current = onNotificationResponse; tokenRef.current = onTokenRefresh; }); useEffect(() => { // Foreground: notification arrives while app is open const receivedSub = Notifications.addNotificationReceivedListener( (notification) => { receivedRef.current?.(notification); }, ); // Tap/Response: user interacts with a notification const responseSub = Notifications.addNotificationResponseReceivedListener( (response) => { responseRef.current?.(response); }, ); // Token refresh: push token changed (rare but important) const tokenSub = Notifications.addPushTokenListener(({ data }) => { tokenRef.current?.(data); }); return () => { receivedSub.remove(); responseSub.remove(); tokenSub.remove(); }; }, []); return null; } ``` **Why good:** refs prevent re-subscription on callback changes, all three listener types covered, cleanup prevents leaks, token refresh keeps backend in sync ```typescript // BAD: Listeners without cleanup useEffect(() => { Notifications.addNotificationReceivedListener((n) => { console.log(n); }); // No cleanup -- listener leaks on unmount, duplicates on re-render }, []); ``` **Why bad:** no subscription reference stored, no cleanup on unmount, listener accumulates on every mount cycle --- ## Pattern 4: Handling Notification Tap (Navigation) When a user taps a notification, extract the data payload and navigate to the relevant screen. #### Using useLastNotificationResponse (Expo) The `useLastNotificationResponse` hook handles all three states (foreground tap, background tap, and cold launch from notification). ```typescript import { useEffect } from "react"; import * as Notifications from "expo-notifications"; const DEFAULT_ACTION = Notifications.DEFAULT_ACTION_IDENTIFIER; export function useNotificationNavigation( navigate: (screen: string, params?: Record<string, unknown>) => void, ) { const lastResponse = Notifications.useLastNotificationResponse(); useEffect(() => { if (!lastResponse) return; const actionId = lastResponse.actionIdentifier; if (actionId !== DEFAULT_ACTION) return; // Handle custom actions separately const data = lastResponse.notification.request.content.data; if (data?.screen) { navigate(data.screen as string, data.params as Record<string, unknown>); } }, [lastResponse, navigate]); } ``` **Why good:** useLastNotificationResponse works for foreground taps, background taps, AND cold launch (replaces three separate listeners), checks actionIdentifier to distinguish default tap from custom actions #### Using getInitialNotification (Firebase) For Firebase, use `getInitialNotification()` for cold launch and `onNotificationOpenedApp()` for background tap. ```typescript import { useEffect } from "react"; import messaging from "@react-native-firebase/messaging"; export function useFirebaseNotificationNavigation( navigate: (screen: string, params?: Record<string, unknown>) => void, ) { useEffect(() => { // Cold launch: app was killed, user tapped notification to open it messaging() .getInitialNotification() .then((remoteMessage) => { if (remoteMessage?.data?.screen) { navigate( remoteMessage.data.screen as string, remoteMessage.data as Record<string, unknown>, ); } }); // Background: app was minimized, user tapped notification const unsubscribe = messaging().onNotificationOpenedApp((remoteMessage) => { if (remoteMessage?.data?.screen) { navigate( remoteMessage.data.screen as string, remoteMessage.data as Record<string, unknown>, ); } }); return unsubscribe; }, [navigate]); } ``` **Why good:** handles both cold launch (getInitialNotification) and background tap (onNotificationOpenedApp), cleanup on unmount, early call prevents missing the initial notification **Gotcha:** `getInitialNotification()` returns null if called too late in the app lifecycle. Call it as early as possible, before navigation is fully initialized. --- ## Pattern 5: Background Message Handler (Firebase) Must be registered at the TOP LEVEL of your entry file (index.js or App.tsx), not inside a component. Runs as a headless JS task. ```typescript // index.js or app entry file -- TOP LEVEL, not inside a component import messaging from "@react-native-firebase/messaging"; messaging().setBackgroundMessageHandler(async (remoteMessage) => { // This runs in a headless JS context -- no UI access // Process data, update local storage, sync with server, etc. const { data } = remoteMessage; if (data?.type === "new-message") { // Update local unread count, sync badge, etc. // Do NOT try to navigate or update React state here } }); ``` **Why good:** registered at top level (not in component), async handler, accesses data only (no UI operations), processes silently ```typescript // BAD: Background handler inside a component function App() { useEffect(() => { // This is TOO LATE -- handler must be registered before React mounts messaging().setBackgroundMessageHandler(async (msg) => { // Also BAD: trying to set React state in headless context setMessages((prev) => [...prev, msg]); }); }, []); } ``` **Why bad:** registered inside component (too late for background delivery), tries to set React state in headless JS context (crashes), registration depends on component mount --- ## Pattern 6: Firebase Foreground Message Handling Firebase does NOT display notifications when the app is in the foreground by default. You must handle display yourself using a local notification library or custom UI. ```typescript import { useEffect } from "react"; import messaging from "@react-native-firebase/messaging"; import type { FirebaseMessagingTypes } from "@react-native-firebase/messaging"; export function useFirebaseForegroundMessages( onMessage: (message: FirebaseMessagingTypes.RemoteMessage) => void, ) { useEffect(() => { const unsubscribe = messaging().onMessage(async (remoteMessage) => { // Firebase does NOT display foreground notifications automatically // Option 1: Show via local notification library // Option 2: Show custom in-app UI (toast, banner) // Option 3: Update app state silently onMessage(remoteMessage); }); return unsubscribe; }, [onMessage]); } ``` **Why good:** cleanup via unsubscribe return, clear comment that Firebase requires manual foreground display, callback pattern for flexibility --- ## Pattern 7: Token Management with Backend Sync Push tokens must be sent to your backend and kept up to date. Tokens can change when the app is reinstalled, restored from backup, or (rarely) rotated by FCM/APNs. ```typescript import { useEffect, useCallback } from "react"; import * as Notifications from "expo-notifications"; async function syncTokenWithBackend( token: string, userId: string, ): Promise<void> { await fetch("https://your-api.example.com/push-tokens", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, userId, platform: Platform.OS }), }); } export function usePushTokenSync(userId: string) { const handleToken = useCallback( async (token: string) => { await syncTokenWithBackend(token, userId); }, [userId], ); useEffect(() => { // Send initial token registerForPushNotificationsAsync() .then((token) => { if (token) handleToken(token); }) .catch((error) => { // Handle registration failure }); // Listen for token refresh const subscription = Notifications.addPushTokenListener(({ data }) => { handleToken(data); }); return () => subscription.remove(); }, [handleToken]); } ``` **Why good:** syncs token on initial registration AND on refresh, includes platform identifier for backend, cleanup on unmount, userId association for targeted notifications --- ## Pattern 8: Rich Notification Content Push notifications support titles, bodies, images, sounds, badges, and custom data payloads. #### Expo Push Service Payload ```typescript // Server-side: sending via Expo Push Service const message = { to: expoPushToken, title: "New Photo", body: "Sarah shared a photo with you", sound: "default", badge: 1, data: { screen: "photo-detail", photoId: "abc123", senderId: "user456", }, // iOS-specific _contentAvailable: true, // Enables background processing // Android-specific channelId: "messages", // Must match a created channel priority: "high", }; ``` #### Firebase FCM Payload ```typescript // Server-side: sending via FCM const message = { token: fcmToken, notification: { title: "New Photo", body: "Sarah shared a photo with you", imageUrl: "https://example.com/photo-thumb.jpg", // Rich image }, data: { screen: "photo-detail", photoId: "abc123", }, android: { notification: { channelId: "messages", sound: "default", priority: "high", imageUrl: "https://example.com/photo-thumb.jpg", }, }, apns: { payload: { aps: { badge: 1, sound: "default", "content-available": 1, "mutable-content": 1, // Required for notification service extension (rich media on iOS) }, }, fcmOptions: { imageUrl: "https://example.com/photo-thumb.jpg", }, }, }; ``` **Why good:** data payload for navigation, channelId for Android, content-available for background delivery, mutable-content for iOS rich media, imageUrl for both platforms **Gotcha for data-only messages (Firebase):** Messages without a `notification` key (data-only) require `priority: "high"` on Android and `content-available: 1` on iOS to trigger the background handler. Without these, the message may be silently dropped. -
scheduling.md 11 KB
# Push Notifications - Scheduling, Channels, Categories > Local notification scheduling, trigger types, Android channels, notification categories with interactive actions, badge management. See [SKILL.md](../SKILL.md) for decision guidance. **Related:** [core.md](core.md) for permission flow, token management, and remote notification listeners. --- ## Pattern 1: Local Notification Triggers expo-notifications supports multiple trigger types for scheduling local notifications. Trigger types differ between Android and iOS. #### Immediate Notification ```typescript import * as Notifications from "expo-notifications"; // Fire immediately (trigger: null) await Notifications.scheduleNotificationAsync({ content: { title: "Action Complete", body: "Your download has finished", data: { screen: "downloads" }, }, trigger: null, // Fires immediately }); ``` #### Time Interval Trigger ```typescript const REMINDER_DELAY_SECONDS = 3600; // 1 hour await Notifications.scheduleNotificationAsync({ content: { title: "Reminder", body: "Come back and finish your workout!", sound: "default", }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: REMINDER_DELAY_SECONDS, repeats: false, // Set true for repeating (minimum 60 seconds interval) }, }); ``` #### Date Trigger ```typescript const reminderDate = new Date("2025-12-25T09:00:00"); await Notifications.scheduleNotificationAsync({ content: { title: "Merry Christmas!", body: "Open the app for a special surprise", }, trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: reminderDate, }, }); ``` #### Daily Recurring Trigger (Android) ```typescript const DAILY_REMINDER_HOUR = 9; const DAILY_REMINDER_MINUTE = 0; // Android: DailyTrigger await Notifications.scheduleNotificationAsync({ content: { title: "Daily Check-in", body: "How are you feeling today?", }, trigger: { type: Notifications.SchedulableTriggerInputTypes.DAILY, hour: DAILY_REMINDER_HOUR, minute: DAILY_REMINDER_MINUTE, }, }); ``` #### Weekly Recurring Trigger (Android) ```typescript const MONDAY = 2; // 1=Sunday, 2=Monday, ..., 7=Saturday const WEEKLY_HOUR = 10; const WEEKLY_MINUTE = 0; // Android: WeeklyTrigger await Notifications.scheduleNotificationAsync({ content: { title: "Weekly Review", body: "Time to review your goals for the week", }, trigger: { type: Notifications.SchedulableTriggerInputTypes.WEEKLY, weekday: MONDAY, hour: WEEKLY_HOUR, minute: WEEKLY_MINUTE, }, }); ``` #### Calendar Trigger (iOS) ```typescript const DAILY_REMINDER_HOUR = 9; const DAILY_REMINDER_MINUTE = 0; // iOS: CalendarTrigger for daily recurrence await Notifications.scheduleNotificationAsync({ content: { title: "Daily Check-in", body: "How are you feeling today?", }, trigger: { type: Notifications.SchedulableTriggerInputTypes.CALENDAR, repeats: true, dateComponents: { hour: DAILY_REMINDER_HOUR, minute: DAILY_REMINDER_MINUTE, }, }, }); ``` **Why good:** explicit trigger types, named constants for all time values, repeats flag explicit **Gotcha:** `DailyTrigger`, `WeeklyTrigger`, and `YearlyTrigger` are Android-only. Use `CalendarTrigger` with `dateComponents` on iOS for the same functionality. Wrap in `Platform.OS` check for cross-platform code. --- ## Pattern 2: Managing Scheduled Notifications ```typescript import * as Notifications from "expo-notifications"; const EXAMPLE_DELAY_SECONDS = 60; // List all scheduled notifications const scheduled = await Notifications.getAllScheduledNotificationsAsync(); // Cancel a specific notification by its identifier const notificationId = await Notifications.scheduleNotificationAsync({ content: { title: "Reminder", body: "..." }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: EXAMPLE_DELAY_SECONDS, }, }); await Notifications.cancelScheduledNotificationAsync(notificationId); // Cancel all scheduled notifications await Notifications.cancelAllScheduledNotificationsAsync(); ``` **Why good:** stores notification ID for targeted cancellation, lists existing before scheduling to avoid duplicates --- ## Pattern 3: Android Notification Channels and Groups Channels group notifications by type and let users control sound, vibration, and importance per group. Channel groups organize related channels. ```typescript import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; // Channel definitions as constants const CHANNEL_GROUP_SOCIAL = "social"; const CHANNELS = { messages: { id: "messages", name: "Direct Messages", importance: Notifications.AndroidImportance.HIGH, sound: "default", vibrationPattern: [0, 250, 250, 250], groupId: CHANNEL_GROUP_SOCIAL, }, groupChat: { id: "group-chat", name: "Group Chats", importance: Notifications.AndroidImportance.DEFAULT, sound: "default", groupId: CHANNEL_GROUP_SOCIAL, }, appUpdates: { id: "app-updates", name: "App Updates", importance: Notifications.AndroidImportance.LOW, }, marketing: { id: "marketing", name: "Promotions & Offers", importance: Notifications.AndroidImportance.MIN, description: "Special offers and promotions", }, } as const; async function setupAndroidChannels() { if (Platform.OS !== "android") return; // Create channel groups first await Notifications.setNotificationChannelGroupAsync(CHANNEL_GROUP_SOCIAL, { name: "Social", description: "Messages and group chats", }); // Create individual channels for (const channel of Object.values(CHANNELS)) { await Notifications.setNotificationChannelAsync(channel.id, { name: channel.name, importance: channel.importance, sound: "sound" in channel ? channel.sound : undefined, vibrationPattern: "vibrationPattern" in channel ? channel.vibrationPattern : undefined, groupId: "groupId" in channel ? channel.groupId : undefined, description: "description" in channel ? channel.description : undefined, }); } } ``` **Why good:** channels defined as constants with groups, group created before channels that reference it, Platform.OS guard, importance levels match notification priority **Gotcha:** Once a user changes a channel's settings in Android system settings, your code CANNOT override those preferences. You can only set defaults on channel creation. To change settings after creation, you must create a NEW channel with a new ID. --- ## Pattern 4: Notification Categories with Interactive Actions Categories define action buttons and text input fields that appear on notifications. Register at app startup. ```typescript import * as Notifications from "expo-notifications"; async function setupNotificationCategories() { // Message category: reply + mark read await Notifications.setNotificationCategoryAsync("message", [ { identifier: "reply", buttonTitle: "Reply", textInput: { submitButtonTitle: "Send", placeholder: "Type a reply...", }, options: { opensAppToForeground: true, // Open app when replying }, }, { identifier: "mark-read", buttonTitle: "Mark as Read", options: { opensAppToForeground: false, // Handle silently in background }, }, ]); // Social category: like + view await Notifications.setNotificationCategoryAsync("social", [ { identifier: "like", buttonTitle: "Like", options: { opensAppToForeground: false, }, }, { identifier: "view", buttonTitle: "View", options: { opensAppToForeground: true, }, }, ]); } ``` #### Handling Action Responses ```typescript import * as Notifications from "expo-notifications"; function handleNotificationAction( response: Notifications.NotificationResponse, ) { const actionId = response.actionIdentifier; const data = response.notification.request.content.data; switch (actionId) { case Notifications.DEFAULT_ACTION_IDENTIFIER: // User tapped the notification body (not an action button) navigateToScreen(data); break; case "reply": { // Extract text input from the response const userInput = response.userText; if (userInput && data?.conversationId) { sendReply(data.conversationId as string, userInput); } break; } case "mark-read": if (data?.conversationId) { markAsRead(data.conversationId as string); } break; case "like": if (data?.postId) { likePost(data.postId as string); } break; } } ``` **Why good:** DEFAULT_ACTION_IDENTIFIER distinguishes body tap from button tap, userText extracted for text input actions, opensAppToForeground controls whether actions launch the app **Gotcha:** To trigger categories on push notifications, include `categoryIdentifier` in the notification content (Expo) or `category` in the APNs payload. --- ## Pattern 5: Badge Count Management ```typescript import * as Notifications from "expo-notifications"; const BADGE_CLEAR = 0; // Get current badge count const currentBadge = await Notifications.getBadgeCountAsync(); // Set badge count (e.g., unread message count) await Notifications.setBadgeCountAsync(unreadCount); // Clear badge when user opens app await Notifications.setBadgeCountAsync(BADGE_CLEAR); ``` **Why good:** named constant for clear operation, straightforward API **Gotcha on Android:** Badge count behavior varies by Android launcher. Not all Android launchers support badge counts. Some require specific launcher APIs or notification channels to display badges correctly. --- ## Pattern 6: Dismissing Notifications from the Tray ```typescript import * as Notifications from "expo-notifications"; // Get all presented notifications currently in the notification tray const presented = await Notifications.getPresentedNotificationsAsync(); // Dismiss a specific notification await Notifications.dismissNotificationAsync(notificationId); // Dismiss all notifications (e.g., when user opens the conversation list) await Notifications.dismissAllNotificationsAsync(); ``` **Why good:** can inspect presented notifications before dismissing, targeted dismissal for specific conversations, bulk dismiss for app-wide clear --- ## Pattern 7: Topic Subscriptions Subscribe devices to topics for broadcast-style notifications (e.g., "breaking-news", "sports-scores"). #### Expo ```typescript import * as Notifications from "expo-notifications"; // Subscribe to a topic (Android only for expo-notifications) await Notifications.subscribeToTopicAsync("breaking-news"); // Unsubscribe await Notifications.unsubscribeFromTopicAsync("breaking-news"); ``` #### Firebase ```typescript import messaging from "@react-native-firebase/messaging"; // Subscribe to topic await messaging().subscribeToTopic("breaking-news"); // Unsubscribe await messaging().unsubscribeFromTopic("breaking-news"); ``` **Why good:** server sends one message to topic, all subscribers receive it, no need to track individual tokens for broadcast messages
-
-
reference.md 8.3 KB
# Push Notifications Reference > Decision frameworks, platform differences, and quick-reference checklists. See [SKILL.md](SKILL.md) for red flags and anti-patterns. --- ## Decision Framework ### Library Choice ``` Are you using Expo (managed or bare)? |-- YES -> expo-notifications (unified push + local API) | |-- Want Expo Push Service? -> getExpoPushTokenAsync() | +-- Want direct FCM/APNs? -> getDevicePushTokenAsync() +-- NO (bare React Native) |-- Firebase in your stack? -> @react-native-firebase/messaging | +-- For foreground display -> pair with local notification library +-- Want Expo API anyway? -> expo-notifications (install expo modules) ``` ### Notification State Handling ``` What state is the app in when the notification arrives? | |-- FOREGROUND (app is open) | |-- Expo: setNotificationHandler + addNotificationReceivedListener | +-- Firebase: messaging().onMessage() | +-- Must display manually (Firebase does not auto-show in foreground) | |-- BACKGROUND (app minimized) | |-- Expo: registerTaskAsync (headless JS task) | +-- Firebase: setBackgroundMessageHandler (top-level, index.js) | +-- QUIT (app was killed) |-- Expo: useLastNotificationResponse (covers all tap states) +-- Firebase: getInitialNotification() (cold launch only) +-- onNotificationOpenedApp() (background -> tap) ``` ### Trigger Type by Platform ``` When should the local notification fire? | |-- Immediately -> trigger: null (both platforms) |-- After N seconds -> TimeIntervalTrigger (both platforms) |-- At specific date -> DateTrigger (both platforms) |-- Daily at HH:MM -> DailyTrigger (Android) / CalendarTrigger (iOS) |-- Weekly on day at HH -> WeeklyTrigger (Android) / CalendarTrigger (iOS) +-- Yearly on date -> YearlyTrigger (Android) / CalendarTrigger (iOS) ``` --- ## Platform Differences | Feature | iOS | Android | | ----------------------- | ---------------------------------------------------------- | --------------------------------------------------------------- | | Permission dialog | Shown once natively; subsequent calls return cached status | Auto-granted on install (Android 13+ requires explicit request) | | Notification channels | Not applicable | Required on Android 8+ (API 26+) | | Badge count | System-wide, reliable | Depends on launcher; not universally supported | | Rich images | Via Notification Service Extension + mutable-content | Via BigPicture style or imageUrl in FCM | | Sound | Included in app bundle (.caf, .aiff, .wav) | Included in res/raw or channel default | | Daily/Weekly triggers | CalendarTrigger with dateComponents | DailyTrigger / WeeklyTrigger | | Notification grouping | Automatic by threadIdentifier | Requires notification channel groups | | Foreground presentation | Controlled by shouldShowBanner/shouldShowList | Always shown (controlled by channel importance) | | Category actions | Long-press or 3D Touch to reveal | Swipe or expand notification | | Topic subscriptions | Via APNs or Firebase | Native FCM support | --- ## Notification Lifecycle Checklist ### Initial Setup (App Startup) - [ ] `setNotificationHandler` called at module scope (foreground presentation) - [ ] Android notification channels created (before any notification) - [ ] Notification categories registered (if using interactive actions) - [ ] Background handler registered at top level of entry file ### Permission & Token Flow - [ ] Check existing permission status before prompting - [ ] Request permission at contextually appropriate moment - [ ] Retrieve push token after permission granted - [ ] Send token to backend with userId and platform - [ ] Register token refresh listener - [ ] Handle permission denial gracefully (degrade features, show Settings prompt) ### Listener Setup - [ ] Foreground received listener with cleanup - [ ] Tap/response listener with navigation logic - [ ] Token refresh listener with backend sync - [ ] All listeners cleaned up on unmount ### Testing - [ ] Tested on physical iOS device - [ ] Tested on physical Android device - [ ] Tested foreground notification display - [ ] Tested background notification delivery - [ ] Tested notification tap from killed state - [ ] Tested notification tap from background state - [ ] Tested interactive actions (if using categories) - [ ] Tested with expired/invalid token (error handling) --- ## Quick Reference: Key Imports ### expo-notifications ```typescript import * as Notifications from "expo-notifications"; import * as Device from "expo-device"; import Constants from "expo-constants"; // Key functions Notifications.getPermissionsAsync(); Notifications.requestPermissionsAsync(); Notifications.getExpoPushTokenAsync({ projectId }); Notifications.getDevicePushTokenAsync(); Notifications.setNotificationHandler({ handleNotification }); Notifications.addNotificationReceivedListener(callback); Notifications.addNotificationResponseReceivedListener(callback); Notifications.addPushTokenListener(callback); Notifications.scheduleNotificationAsync({ content, trigger }); Notifications.setNotificationChannelAsync(channelId, config); Notifications.setNotificationCategoryAsync(categoryId, actions); Notifications.getBadgeCountAsync(); Notifications.setBadgeCountAsync(count); Notifications.useLastNotificationResponse(); // React hook ``` ### @react-native-firebase/messaging ```typescript import messaging from "@react-native-firebase/messaging"; // Key functions messaging().requestPermission(); messaging().getToken(); messaging().onMessage(callback); messaging().setBackgroundMessageHandler(callback); // Top-level only messaging().onNotificationOpenedApp(callback); messaging().getInitialNotification(); messaging().subscribeToTopic(topic); messaging().unsubscribeFromTopic(topic); messaging().onTokenRefresh(callback); ``` --- ## Notification Payload Quick Reference ### Expo Push Service ```typescript { to: "ExponentPushToken[xxx]", title: "Title", body: "Body text", sound: "default", badge: 1, data: { key: "value" }, // Custom data for navigation categoryId: "message", // Links to registered category channelId: "messages", // Android channel (must exist) priority: "high", // Android delivery priority _contentAvailable: true, // iOS background processing } ``` ### FCM (Server-Side) ```typescript { token: "fcm-device-token", notification: { title: "Title", body: "Body text", imageUrl: "https://...", // Rich image }, data: { key: "value" }, // Custom data payload android: { notification: { channelId: "messages", sound: "default", priority: "high", }, }, apns: { payload: { aps: { badge: 1, sound: "default", "content-available": 1, // Background delivery "mutable-content": 1, // Rich media on iOS category: "message", // iOS category identifier }, }, }, } ``` --- ## Android Channel Importance Reference | Level | Enum | Sound | Vibration | Heads-up | Use Case | | ------- | --------------------------- | ----- | --------- | -------- | ------------------------------ | | Max | `AndroidImportance.MAX` | Yes | Yes | Yes | Incoming calls, urgent alerts | | High | `AndroidImportance.HIGH` | Yes | Yes | Yes | Messages, direct communication | | Default | `AndroidImportance.DEFAULT` | Yes | Yes | No | General notifications | | Low | `AndroidImportance.LOW` | No | No | No | Recommendations, updates | | Min | `AndroidImportance.MIN` | No | No | No | Silent, informational | -
SKILL.md 17.4 KB
--- name: mobile-notifications-push description: Push notification patterns - expo-notifications (Expo) and @react-native-firebase/messaging (bare RN), permission handling, token management, foreground/background/tap listeners, local scheduling, Android channels, notification categories and actions, badge management, rich notifications --- # Push Notification Patterns > **Quick Guide:** Two main approaches: `expo-notifications` (Expo workflow, unified API for push + local) and `@react-native-firebase/messaging` (bare RN, FCM/APNs direct). Always request permissions before retrieving tokens. Handle three notification states: foreground (app open), background (app minimized), and quit (app killed). Set up Android notification channels before displaying any notification. Push notifications require a physical device -- they do not work on emulators or simulators. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST request notification permissions BEFORE retrieving push tokens -- calling getExpoPushTokenAsync or messaging().getToken() without permission will fail or return an unusable token)** **(You MUST create an Android notification channel BEFORE displaying any notification on Android 8+ -- notifications without a channel are silently dropped)** **(You MUST handle ALL three notification states: foreground (onMessage/addNotificationReceivedListener), background (setBackgroundMessageHandler/registerTaskAsync), and tap/response (addNotificationResponseReceivedListener/onNotificationOpenedApp + getInitialNotification))** **(You MUST clean up notification listeners on unmount -- leaked listeners cause memory leaks and duplicate handlers)** **(You MUST test push notifications on a physical device -- emulators and simulators do not support push tokens)** </critical_requirements> --- **Auto-detection:** expo-notifications, @react-native-firebase/messaging, push notification, push token, getExpoPushTokenAsync, getDevicePushTokenAsync, scheduleNotificationAsync, setNotificationHandler, addNotificationReceivedListener, addNotificationResponseReceivedListener, setBackgroundMessageHandler, onMessage, onNotificationOpenedApp, getInitialNotification, notification channel, setNotificationChannelAsync, notification category, notification actions, setBadgeCountAsync, FCM, APNs, remote notification, local notification, useLastNotificationResponse **When to use:** - Sending remote push notifications to users via FCM/APNs - Requesting and managing notification permissions - Retrieving and storing push tokens (Expo push token or native FCM/APNs token) - Handling notification events in foreground, background, and quit states - Scheduling local notifications (reminders, timers, recurring alerts) - Creating Android notification channels with custom sound/vibration/importance - Adding interactive notification actions (buttons, text input) - Managing app icon badge counts - Implementing notification tap navigation (deep linking from notifications) **Key patterns covered:** - Permission request flow with status checking - Push token retrieval and refresh handling - Foreground notification presentation (setNotificationHandler) - Background message handling (headless JS tasks) - Notification tap/response handling with navigation - Local notification scheduling with trigger types - Android notification channels and channel groups - Notification categories with interactive actions - Badge count management - Rich notification content (images, sounds, data payloads) **When NOT to use:** - In-app messaging or toast/snackbar UI (those are UI components, not OS notifications) - Email or SMS notifications (server-side concern) - Web push notifications (different API entirely) **Detailed Resources:** - [examples/core.md](examples/core.md) - Permission flow, token management, foreground/background/tap listeners, notification handler setup - [examples/scheduling.md](examples/scheduling.md) - Local notification scheduling, trigger types, Android channels, categories and actions, badge management - [reference.md](reference.md) - Decision frameworks, platform differences, checklists --- <philosophy> ## Philosophy Push notifications bridge the gap between your app and users when the app is not in focus. The two main approaches in React Native serve different workflows: **expo-notifications** provides a unified API for both push and local notifications, abstracts FCM/APNs differences, and integrates with Expo's push service for simplified server-side sending. Best for Expo-managed and bare workflows that want a single library for all notification needs. **@react-native-firebase/messaging** provides direct FCM integration, pairs naturally with Firebase's backend services, and is the standard for bare React Native projects already using Firebase. For displaying foreground notifications with Firebase, pair it with a local notification display library. **Core principles:** 1. **Permission first** -- Always check and request permissions before any token or notification work. Requesting at a contextually appropriate moment (after user sees value) dramatically improves grant rates. 2. **Handle all three states** -- Notifications arrive when the app is in foreground, background, or quit. Each state requires a different listener. Missing one means silently lost notifications. 3. **Channels are mandatory on Android** -- Android 8+ (API 26+) requires notification channels. Without one, notifications are silently dropped. Create channels at app startup, not at send time. 4. **Tokens change** -- Push tokens can rotate. Register a token refresh listener and update your backend whenever the token changes. 5. **Physical device required** -- Push notification infrastructure (FCM/APNs) does not work on emulators or simulators. Local notifications may work on simulators but push tokens will not. **Mental model:** ``` Server sends push -> FCM/APNs delivers to device -> OS displays notification | | | (or Expo Push Service abstracts FCM/APNs) | | v | User taps notification | | v v App in foreground: App opens with payload: -> onMessage / notificationReceived -> response listener -> YOU decide whether to show it -> navigate to content ``` </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Permission Request Flow Always check existing permission status before prompting. On iOS, the permission dialog can only be shown ONCE natively -- if denied, you must direct users to Settings. ```typescript // expo-notifications approach import * as Notifications from "expo-notifications"; import * as Device from "expo-device"; import { Platform } from "react-native"; const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; if (existingStatus !== "granted") { const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status; } if (finalStatus !== "granted") { // Handle denial -- direct to Settings or degrade gracefully return; } ``` **Why good:** checks existing status first to avoid redundant prompts, handles denial gracefully, works on both platforms See [examples/core.md](examples/core.md) for the complete registration function with Android channel setup and error handling. --- ### Pattern 2: Push Token Retrieval Expo push tokens work with Expo's push service. Native device tokens (FCM/APNs) work with your own backend or third-party services. ```typescript // Expo push token -- for use with Expo Push Service const expoPushToken = await Notifications.getExpoPushTokenAsync({ projectId: Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId, }); // Returns: "ExponentPushToken[xxxxxx]" // Native device token -- for direct FCM/APNs integration const deviceToken = await Notifications.getDevicePushTokenAsync(); // Returns: { type: "ios" | "android", data: "native-token-string" } ``` **Why good:** projectId is explicit (not inferred), both token types available depending on backend choice See [examples/core.md](examples/core.md) for token refresh handling and Firebase token retrieval patterns. --- ### Pattern 3: Foreground Notification Handler By default, notifications received while the app is in the foreground are NOT displayed. You must explicitly opt in via `setNotificationHandler`. ```typescript // Call once at app startup (outside of any component) Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldPlaySound: true, shouldSetBadge: true, shouldShowBanner: true, // replaces deprecated shouldShowAlert shouldShowList: true, // show in notification center }), }); ``` **Why good:** explicit opt-in to foreground display, uses current API (shouldShowBanner/shouldShowList, not deprecated shouldShowAlert), called at module scope so it runs before any notification arrives **Gotcha:** `shouldShowAlert` is deprecated in recent expo-notifications versions -- use `shouldShowBanner` and `shouldShowList` instead. See [examples/core.md](examples/core.md) for conditional foreground handling (e.g., suppressing notification when user is already on that screen). --- ### Pattern 4: Notification Listeners (Foreground, Background, Tap) Three distinct handlers cover the full notification lifecycle. ```typescript // Foreground: notification arrives while app is open const receivedSub = Notifications.addNotificationReceivedListener( (notification) => { const data = notification.request.content.data; // Update UI, show in-app indicator, etc. }, ); // Tap/Response: user taps a notification (from any state) const responseSub = Notifications.addNotificationResponseReceivedListener( (response) => { const data = response.notification.request.content.data; // Navigate to relevant screen }, ); // Cleanup on unmount return () => { receivedSub.remove(); responseSub.remove(); }; ``` **Why good:** separate listeners for receiving vs tapping, cleanup prevents leaks, data extraction from correct nested path See [examples/core.md](examples/core.md) for the complete useNotificationListeners hook, background handler registration, and Firebase equivalents. --- ### Pattern 5: Android Notification Channels Required on Android 8+ (API 26+). Create channels at app startup. Users can customize channel settings (sound, vibration) in system settings -- your code cannot override user preferences after creation. ```typescript const CHANNELS = { messages: { id: "messages", name: "Messages", importance: Notifications.AndroidImportance.HIGH, }, updates: { id: "updates", name: "App Updates", importance: Notifications.AndroidImportance.DEFAULT, }, marketing: { id: "marketing", name: "Promotions", importance: Notifications.AndroidImportance.LOW, }, } as const; // Create at app startup if (Platform.OS === "android") { await Notifications.setNotificationChannelAsync(CHANNELS.messages.id, { name: CHANNELS.messages.name, importance: CHANNELS.messages.importance, vibrationPattern: [0, 250, 250, 250], lightColor: "#FF231F7C", sound: "default", }); } ``` **Why good:** channels defined as constants, importance levels match notification priority, created at startup before any notification arrives See [examples/scheduling.md](examples/scheduling.md) for channel groups and channel management patterns. --- ### Pattern 6: Local Notification Scheduling Schedule notifications for future delivery without a server. Supports one-time, repeating, and calendar-based triggers. ```typescript const REMINDER_DELAY_SECONDS = 60; await Notifications.scheduleNotificationAsync({ content: { title: "Reminder", body: "Don't forget to complete your task!", data: { screen: "tasks", taskId: "abc123" }, sound: "default", }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: REMINDER_DELAY_SECONDS, }, }); ``` **Why good:** data payload enables navigation on tap, trigger type is explicit, named constant for delay See [examples/scheduling.md](examples/scheduling.md) for daily/weekly recurring triggers, calendar triggers, and platform-specific trigger differences. --- ### Pattern 7: Notification Categories and Actions Categories define interactive buttons and text input fields on notifications. Register categories at app startup. ```typescript await Notifications.setNotificationCategoryAsync("message", [ { identifier: "reply", buttonTitle: "Reply", textInput: { submitButtonTitle: "Send", placeholder: "Type a reply..." }, }, { identifier: "mark-read", buttonTitle: "Mark as Read", options: { opensAppToForeground: false }, }, ]); ``` **Why good:** text input action for quick replies, opensAppToForeground: false for silent actions, registered at startup before notifications arrive See [examples/scheduling.md](examples/scheduling.md) for handling action responses and iOS-specific category options. </patterns> --- <decision_framework> ## Decision Framework Key decisions: which library (expo-notifications vs @react-native-firebase/messaging), which push token type (Expo vs native), which trigger type for local notifications, and which Android channel importance level. See [reference.md](reference.md) for complete decision trees, platform differences table, and channel importance reference. </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Requesting push token before checking/requesting permissions -- fails silently or returns unusable token on iOS - Missing Android notification channel creation -- notifications silently dropped on Android 8+ (API 26+) - Not handling the "quit" state -- `getInitialNotification()` (Firebase) or `useLastNotificationResponse()` (Expo) is the only way to get the notification that launched the app - Using `shouldShowAlert` instead of `shouldShowBanner`/`shouldShowList` -- deprecated API, will break in future expo-notifications versions - Not cleaning up listeners on unmount -- causes memory leaks and duplicate notification handlers - Testing only on simulator/emulator -- push tokens and remote notifications require a physical device **Medium Priority Issues:** - Hardcoding push token on the server without refresh handling -- tokens rotate and become invalid - Creating notification channels at notification send time instead of app startup -- causes race condition where first notification is dropped - Using `console.log` in background handlers -- headless JS tasks may not have console access; use your logging solution - Not sending `channelId` in Android notification payloads -- notification uses default channel, ignoring your custom channel settings - Requesting permission immediately on app launch -- users deny at higher rates without context; request after demonstrating value **Gotchas & Edge Cases:** - iOS permission dialog shows only ONCE natively -- if denied, subsequent `requestPermissionsAsync()` calls return "denied" without showing a dialog; direct users to Settings - Expo SDK 53+ dropped push notification support from Expo Go on Android -- you need a development build to test - `setBackgroundMessageHandler` (Firebase) and `registerTaskAsync` (Expo) must be called at the TOP LEVEL of your entry file (index.js), not inside a component - Firebase data-only messages require `priority: "high"` (Android) and `content-available: 1` (iOS) to trigger background handlers - Android notification icons must be white with transparent background -- colored icons render as solid white squares - `DailyTrigger` and `WeeklyTrigger` are Android-only in expo-notifications -- use `CalendarTrigger` for iOS - Notification categories/actions may not show in background/killed state on some Android devices (known limitation) - Both `expo-notifications` and `@react-native-firebase/messaging` register for the same Android FCM intents -- using both requires manual conflict resolution - `getInitialNotification()` returns null if called too late -- call it early in app initialization, not after navigation is ready </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST request notification permissions BEFORE retrieving push tokens -- calling getExpoPushTokenAsync or messaging().getToken() without permission will fail or return an unusable token)** **(You MUST create an Android notification channel BEFORE displaying any notification on Android 8+ -- notifications without a channel are silently dropped)** **(You MUST handle ALL three notification states: foreground (onMessage/addNotificationReceivedListener), background (setBackgroundMessageHandler/registerTaskAsync), and tap/response (addNotificationResponseReceivedListener/onNotificationOpenedApp + getInitialNotification))** **(You MUST clean up notification listeners on unmount -- leaked listeners cause memory leaks and duplicate handlers)** **(You MUST test push notifications on a physical device -- emulators and simulators do not support push tokens)** **Failure to follow these rules will result in silently dropped notifications, missed user interactions, and platform-specific failures that are difficult to debug.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.