🔔 Notifications System

Index

  1. Notification Options
  2. Local Notifications
  3. Push Notifications with Expo
  4. Push Notifications with Firebase
  5. Notifications Context
  6. Permission Handling
  7. Deep Linking from Notifications

Notification Options

OptionTypeBest forProsCons
Local NotificationsLocalReminders, alarmsNo server, offlineNot remote
Expo PushPushExpo apps, prototypingEasy setup, freeExpo only
Firebase FCMPushProduction appsScalable, analyticsMore setup
OneSignalPushMarketing, segmentationPowerful dashboardCost at scale

Local Notifications

Installation

npx expo install expo-notifications

Basic Configuration

// lib/notifications.ts
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function requestPermissions() {
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  return finalStatus === 'granted';
}

Programar Notificación Local

// lib/notifications.ts
import * as Notifications from 'expo-notifications';

export async function scheduleLocalNotification({
  title,
  body,
  data,
  trigger,
}: {
  title: string;
  body: string;
  data?: Record<string, unknown>;
  trigger?: Notifications.NotificationTriggerInput;
}) {
  const id = await Notifications.scheduleNotificationAsync({
    content: {
      title,
      body,
      data,
      sound: true,
    },
    trigger: trigger ?? null,
  });

  return id;
}

export async function scheduleDelayedNotification(
  title: string,
  body: string,
  seconds: number
) {
  return scheduleLocalNotification({
    title,
    body,
    trigger: { seconds, repeats: false },
  });
}

export async function scheduleDailyNotification(
  title: string,
  body: string,
  hour: number,
  minute: number
) {
  return scheduleLocalNotification({
    title,
    body,
    trigger: {
      hour,
      minute,
      repeats: true,
    },
  });
}

export async function cancelNotification(id: string) {
  await Notifications.cancelScheduledNotificationAsync(id);
}

export async function cancelAllNotifications() {
  await Notifications.cancelAllScheduledNotificationsAsync();
}

Hook para Notificaciones Locales

// hooks/useLocalNotifications.ts
import { useEffect, useRef, useState } from 'react';
import * as Notifications from 'expo-notifications';
import { useMutation } from '@tanstack/react-query';
import { 
  requestPermissions, 
  scheduleLocalNotification,
  cancelNotification,
  cancelAllNotifications 
} from '@/lib/notifications';

export function useLocalNotifications() {
  const [hasPermission, setHasPermission] = useState(false);
  const notificationListener = useRef<Notifications.Subscription>();
  const responseListener = useRef<Notifications.Subscription>();

  useEffect(() => {
    requestPermissions().then(setHasPermission);

    notificationListener.current = Notifications.addNotificationReceivedListener(
      (notification) => {
        console.log('Notification received:', notification);
      }
    );

    responseListener.current = Notifications.addNotificationResponseReceivedListener(
      (response) => {
        console.log('Notification tapped:', response);
        const data = response.notification.request.content.data;
        // Handle navigation based on data
      }
    );

    return () => {
      if (notificationListener.current) {
        Notifications.removeNotificationSubscription(notificationListener.current);
      }
      if (responseListener.current) {
        Notifications.removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);

  const scheduleMutation = useMutation({
    mutationFn: (params: { title: string; body: string; seconds?: number }) =>
      scheduleLocalNotification({
        title: params.title,
        body: params.body,
        trigger: params.seconds ? { seconds: params.seconds } : null,
      }),
  });

  const cancelMutation = useMutation({
    mutationFn: cancelNotification,
  });

  return {
    hasPermission,
    schedule: scheduleMutation.mutate,
    cancel: cancelMutation.mutate,
    cancelAll: cancelAllNotifications,
    isScheduling: scheduleMutation.isPending,
  };
}

Push Notifications con Expo

Obtener Push Token

// lib/pushNotifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import Constants from 'expo-constants';

export async function registerForPushNotifications(): Promise<string | null> {
  if (!Device.isDevice) {
    console.log('Push notifications require a physical device');
    return null;
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.log('Permission not granted for push notifications');
    return null;
  }

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#FF6B35',
    });
  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  const token = await Notifications.getExpoPushTokenAsync({ projectId });

  return token.data;
}

Enviar Push desde Backend

// backend/lib/push.ts
interface PushMessage {
  to: string;
  title: string;
  body: string;
  data?: Record<string, unknown>;
  sound?: 'default' | null;
  badge?: number;
}

export async function sendPushNotification(message: PushMessage) {
  const response = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      to: message.to,
      title: message.title,
      body: message.body,
      data: message.data,
      sound: message.sound ?? 'default',
      badge: message.badge,
    }),
  });

  return response.json();
}

export async function sendPushToMultiple(tokens: string[], message: Omit<PushMessage, 'to'>) {
  const messages = tokens.map(token => ({
    ...message,
    to: token,
  }));

  const response = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(messages),
  });

  return response.json();
}

Push Notifications con Firebase

Instalación

npx expo install @react-native-firebase/app @react-native-firebase/messaging

Configuración

// lib/firebasePush.ts
import messaging from '@react-native-firebase/messaging';
import { Platform } from 'react-native';

export async function requestFirebasePermission() {
  const authStatus = await messaging().requestPermission();
  const enabled =
    authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
    authStatus === messaging.AuthorizationStatus.PROVISIONAL;

  return enabled;
}

export async function getFirebaseToken(): Promise<string | null> {
  const hasPermission = await requestFirebasePermission();
  
  if (!hasPermission) {
    return null;
  }

  const token = await messaging().getToken();
  return token;
}

export function onTokenRefresh(callback: (token: string) => void) {
  return messaging().onTokenRefresh(callback);
}

export function onForegroundMessage(callback: (message: any) => void) {
  return messaging().onMessage(callback);
}

export function setBackgroundMessageHandler(handler: (message: any) => Promise<void>) {
  messaging().setBackgroundMessageHandler(handler);
}

Hook para Firebase Push

// hooks/useFirebasePush.ts
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import {
  getFirebaseToken,
  onTokenRefresh,
  onForegroundMessage,
} from '@/lib/firebasePush';

export function useFirebasePush(onMessage?: (message: any) => void) {
  const [token, setToken] = useState<string | null>(null);
  const [hasPermission, setHasPermission] = useState(false);

  useEffect(() => {
    getFirebaseToken().then((t) => {
      if (t) {
        setToken(t);
        setHasPermission(true);
      }
    });

    const unsubscribeRefresh = onTokenRefresh((newToken) => {
      setToken(newToken);
    });

    const unsubscribeMessage = onForegroundMessage((message) => {
      console.log('FCM Message:', message);
      onMessage?.(message);
    });

    return () => {
      unsubscribeRefresh();
      unsubscribeMessage();
    };
  }, [onMessage]);

  const registerMutation = useMutation({
    mutationFn: async () => {
      const newToken = await getFirebaseToken();
      if (newToken) {
        setToken(newToken);
        setHasPermission(true);
        // Save token to your backend
        // await api.saveToken(newToken);
      }
      return newToken;
    },
  });

  return {
    token,
    hasPermission,
    register: registerMutation.mutate,
    isRegistering: registerMutation.isPending,
  };
}

Contexto de Notificaciones

// contexts/NotificationsContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import { useRouter } from 'expo-router';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

interface NotificationItem {
  id: string;
  title: string;
  body: string;
  data?: Record<string, unknown>;
  read: boolean;
  createdAt: string;
}

const STORAGE_KEY = 'app_notifications';
const PUSH_TOKEN_KEY = 'push_token';

export const [NotificationsProvider, useNotifications] = createContextHook(() => {
  const queryClient = useQueryClient();
  const router = useRouter();
  const [pushToken, setPushToken] = useState<string | null>(null);
  const [hasPermission, setHasPermission] = useState(false);
  const notificationListener = useRef<Notifications.Subscription>();
  const responseListener = useRef<Notifications.Subscription>();

  const notificationsQuery = useQuery({
    queryKey: ['notifications'],
    queryFn: async (): Promise<NotificationItem[]> => {
      const stored = await AsyncStorage.getItem(STORAGE_KEY);
      return stored ? JSON.parse(stored) : [];
    },
  });

  const saveMutation = useMutation({
    mutationFn: async (notifications: NotificationItem[]) => {
      await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
      return notifications;
    },
    onSuccess: (data) => {
      queryClient.setQueryData(['notifications'], data);
    },
  });

  const registerPushMutation = useMutation({
    mutationFn: async () => {
      const { status: existingStatus } = await Notifications.getPermissionsAsync();
      let finalStatus = existingStatus;

      if (existingStatus !== 'granted') {
        const { status } = await Notifications.requestPermissionsAsync();
        finalStatus = status;
      }

      if (finalStatus !== 'granted') {
        return null;
      }

      setHasPermission(true);

      if (Platform.OS === 'android') {
        await Notifications.setNotificationChannelAsync('default', {
          name: 'default',
          importance: Notifications.AndroidImportance.MAX,
        });
      }

      const tokenData = await Notifications.getExpoPushTokenAsync();
      const token = tokenData.data;

      await AsyncStorage.setItem(PUSH_TOKEN_KEY, token);
      setPushToken(token);

      return token;
    },
  });

  useEffect(() => {
    AsyncStorage.getItem(PUSH_TOKEN_KEY).then((token) => {
      if (token) {
        setPushToken(token);
        setHasPermission(true);
      }
    });

    notificationListener.current = Notifications.addNotificationReceivedListener(
      (notification) => {
        const { title, body, data } = notification.request.content;
        addNotification({
          title: title || 'Notificación',
          body: body || '',
          data: data as Record<string, unknown>,
        });
      }
    );

    responseListener.current = Notifications.addNotificationResponseReceivedListener(
      (response) => {
        const data = response.notification.request.content.data;
        if (data?.route) {
          router.push(data.route as string);
        }
      }
    );

    return () => {
      if (notificationListener.current) {
        Notifications.removeNotificationSubscription(notificationListener.current);
      }
      if (responseListener.current) {
        Notifications.removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);

  const notifications = notificationsQuery.data || [];
  const unreadCount = notifications.filter((n) => !n.read).length;

  const addNotification = useCallback(
    (notification: Omit<NotificationItem, 'id' | 'read' | 'createdAt'>) => {
      const newNotification: NotificationItem = {
        id: Date.now().toString(),
        ...notification,
        read: false,
        createdAt: new Date().toISOString(),
      };
      const updated = [newNotification, ...notifications];
      saveMutation.mutate(updated);
    },
    [notifications, saveMutation]
  );

  const markAsRead = useCallback(
    (id: string) => {
      const updated = notifications.map((n) =>
        n.id === id ? { ...n, read: true } : n
      );
      saveMutation.mutate(updated);
    },
    [notifications, saveMutation]
  );

  const markAllAsRead = useCallback(() => {
    const updated = notifications.map((n) => ({ ...n, read: true }));
    saveMutation.mutate(updated);
  }, [notifications, saveMutation]);

  const clearAll = useCallback(() => {
    saveMutation.mutate([]);
  }, [saveMutation]);

  const scheduleLocal = useCallback(
    async (title: string, body: string, trigger?: Notifications.NotificationTriggerInput) => {
      return Notifications.scheduleNotificationAsync({
        content: { title, body, sound: true },
        trigger: trigger ?? null,
      });
    },
    []
  );

  return {
    notifications,
    unreadCount,
    hasPermission,
    pushToken,
    isLoading: notificationsQuery.isLoading,

    registerPush: registerPushMutation.mutateAsync,
    isRegistering: registerPushMutation.isPending,

    addNotification,
    markAsRead,
    markAllAsRead,
    clearAll,
    scheduleLocal,
  };
});

Manejo de Permisos

Componente de Solicitud de Permisos

// components/NotificationPermissionPrompt.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Bell } from 'lucide-react-native';
import { Button } from '@/components/Button';
import { useTheme } from '@/contexts/ThemeContext';
import { useNotifications } from '@/contexts/NotificationsContext';

interface Props {
  onDismiss?: () => void;
}

export function NotificationPermissionPrompt({ onDismiss }: Props) {
  const { colors } = useTheme();
  const { registerPush, isRegistering, hasPermission } = useNotifications();

  if (hasPermission) return null;

  const handleEnable = async () => {
    await registerPush();
    onDismiss?.();
  };

  return (
    <View style={[styles.container, { backgroundColor: colors.surface }]}>
      <View style={[styles.iconContainer, { backgroundColor: colors.primaryLight }]}>
        <Bell size={32} color={colors.primary} />
      </View>
      <Text style={[styles.title, { color: colors.text }]}>
        Mantente al día
      </Text>
      <Text style={[styles.description, { color: colors.textSecondary }]}>
        Activa las notificaciones para recibir actualizaciones importantes
      </Text>
      <View style={styles.buttons}>
        <Button
          title="Activar"
          onPress={handleEnable}
          loading={isRegistering}
        />
        <Button
          title="Ahora no"
          variant="ghost"
          onPress={onDismiss}
        />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 24,
    borderRadius: 16,
    alignItems: 'center',
    margin: 16,
  },
  iconContainer: {
    width: 64,
    height: 64,
    borderRadius: 32,
    alignItems: 'center',
    justifyContent: 'center',
    marginBottom: 16,
  },
  title: {
    fontSize: 20,
    fontWeight: '600' as const,
    marginBottom: 8,
  },
  description: {
    fontSize: 14,
    textAlign: 'center',
    marginBottom: 24,
    lineHeight: 20,
  },
  buttons: {
    width: '100%',
    gap: 8,
  },
});

Deep Linking desde Notificaciones

// app/_layout.tsx
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';

function useNotificationDeepLink() {
  const router = useRouter();

  useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(
      (response) => {
        const data = response.notification.request.content.data;
        
        if (data?.type === 'message') {
          router.push(`/(tabs)/messages/${data.chatId}`);
        } else if (data?.type === 'item') {
          router.push({
            pathname: '/(tabs)/(home)/item-detail',
            params: { id: data.itemId },
          });
        } else if (data?.route) {
          router.push(data.route as string);
        }
      }
    );

    return () => subscription.remove();
  }, [router]);
}
// Desde backend o localmente
await Notifications.scheduleNotificationAsync({
  content: {
    title: 'Nuevo mensaje',
    body: 'Juan te envió un mensaje',
    data: {
      type: 'message',
      chatId: 'chat-123',
    },
  },
  trigger: null,
});

Checklist de Implementación

  • Instalar expo-notifications
  • Configurar NotificationHandler
  • Solicitar permisos
  • Implementar notificaciones locales
  • Configurar push notifications (Expo o Firebase)
  • Guardar token en backend
  • Implementar deep linking
  • Manejar notificaciones en foreground
  • Manejar notificaciones en background
  • Probar en dispositivo físico (iOS y Android)