hooks/useNotifications.ts

hook·mobile·7.7 KB · 221 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

1 export

useNotifications

Code source· typescript· tronqué à 200 lignes sur 221

import { useEffect, useRef } from "react";
import { Platform } from "react-native";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { router } from "expo-router";
import { useAuthStore } from "@/store/authStore";
import { useGerantStore } from "@/store/gerantStore";
import { apiPost } from "@/lib/api";
import { API_MOBILE } from "@/lib/constants";
import { captureError } from "@/lib/sentry";

// DEC-185 + BUG-118 : expo-notifications throw "Push removed from Expo Go SDK 53+"
// au moment de l'import (avant tout useEffect). On le require uniquement
// hors Expo Go pour silence complet des logs en dev.
const isExpoGo = Constants.executionEnvironment === "storeClient";
type NotificationsModule = typeof import("expo-notifications");
const Notifications: NotificationsModule | null = (() => {
  if (isExpoGo) return null;
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  return require("expo-notifications") as NotificationsModule;
})();

// Foreground handler — affiche bannière + son + reset badge même app ouverte.
// iOS 14+ utilise Banner+List ; `shouldShowAlert` deprecated SDK 53+.
if (Notifications) {
  Notifications.setNotificationHandler({
    handleNotification: async () => ({
      shouldPlaySound: true,
      shouldSetBadge: true,
      shouldShowBanner: true,
      shouldShowList: true,
    }),
  });
}

type NotifData = Record<string, unknown> | undefined;

/**
 * Route une notif tappée → bonne destination. Extrait pour réutilisation
 * cold start (`getLastNotificationResponseAsync`) ET warm (listener).
 */
function routeNotificationTap(
  data: NotifData,
  helpers: {
    activerModeGerant: () => void;
    setTab: (tab: string) => void;
    setSelectedCommande: (id: string | null) => void;
  },
) {
  if (!data) return;

  // Gérant : nouvelle commande → mode gérant + activité
  if (data.type === "commande" && typeof data.commandeId === "string") {
    helpers.activerModeGerant();
    helpers.setTab("activite");
    helpers.setSelectedCommande(data.commandeId);
    router.push("/(tabs)/compte" as never);
    return;
  }

  // Client : commande/paiement → onglet Activité
  if (
    data.type === "commande.confirmee"
    || data.type === "commande.statut"
    || data.type === "paiement.valide"
  ) {
    router.push("/(tabs)/activite" as never);
    return;
  }

  // Client : accès SUR_DEMANDE approuvé → fiche vitrine
  if (data.type === "acces.approuve" && typeof data.slug === "string") {
    router.push(`/vitrine/${data.slug}` as never);
    return;
  }

  // Gérant/Client : broadcast vitrine → fiche vitrine si slug fourni
  if (data.type === "broadcast" && typeof data.slug === "string") {
    router.push(`/vitrine/${data.slug}` as never);
    return;
  }
}

/**
 * Register le token push avec retry exponentiel — survit aux glitches réseau 4G BF.
 * Retry 3× (500ms, 2s, 8s) puis abandon silencieux.
 */
async function registerTokenWithRetry(expoPushToken: string, attempt = 0): Promise<boolean> {
  const { error } = await apiPost(API_MOBILE.PUSH_TOKENS_REGISTER, {
    token: expoPushToken,
    platform: Platform.OS,
  });
  if (!error) return true;
  if (attempt >= 2) {
    if (__DEV__) console.warn("[NOTIF] Token register failed after 3 attempts:", error);
    return false;
  }
  const delay = 500 * Math.pow(4, attempt);
  await new Promise((r) => setTimeout(r, delay));
  return registerTokenWithRetry(expoPushToken, attempt + 1);
}

export function useNotifications() {
  // WP-227 — enregistrer le token push pour TOUT user authentifié (gérant
  // OU client). Le backend discrimine via session.role (BUG-120 fix).
  const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
  const token = useAuthStore((s) => s.token);
  const activerModeGerant = useAuthStore((s) => s.activerModeGerant);
  const setTab = useGerantStore((s) => s.setTab);
  const setSelectedCommande = useGerantStore((s) => s.setSelectedCommande);
  const registered = useRef(false);

  useEffect(() => {
    if (!isAuthenticated || !token || registered.current) return;
    registered.current = true;

    let cancelled = false;

    // DEC-185 + BUG-118 : skip total en Expo Go (require échoue à l'import en SDK 53+)
    if (!Notifications) {
      if (__DEV__) console.log("[NOTIF] Skip — Expo Go ne supporte plus push remote (DEC-185, requiert EAS Dev Build)");
      return;
    }

    const helpers = { activerModeGerant, setTab, setSelectedCommande };

    (async () => {
      if (!Device.isDevice) {
        if (__DEV__) console.log("[NOTIF] Skip — pas un device physique");
        return;
      }

      // Android — channel obligatoire
      if (Platform.OS === "android") {
        await Notifications!.setNotificationChannelAsync("default", {
          name: "Commandes",
          importance: Notifications!.AndroidImportance.MAX,
          vibrationPattern: [0, 250, 250, 250],
          lightColor: "#C9A227",
          sound: "default",
        });
      }

      // Permissions iOS — options explicites + allowProvisional pour notifs
      // silencieuses sans demander (meilleur engagement Apple HIG).
      const { status: existing } = await Notifications!.getPermissionsAsync();
      let final = existing;
      if (existing !== "granted") {
        const req = await Notifications!.requestPermissionsAsync({
          ios: {
            allowAlert: true,
            allowBadge: true,
            allowSound: true,
            allowCriticalAlerts: false,
            provideAppNotificationSettings: true,
            allowProvisional: true,
          },
        });
        final = req.status;
      }
      if (final !== "granted") {
        if (__DEV__) console.log("[NOTIF] Permission refusée");
        return;
      }

      // Récup token Expo
      const projectId =
        Constants.expoConfig?.extra?.eas?.projectId ??
        Constants.easConfig?.projectId;
      if (!projectId) {
        if (__DEV__) console.warn("[NOTIF] projectId manquant dans app.json extra.eas");
        return;
      }

      try {
        const tokenData = await Notifications!.getExpoPushTokenAsync({ projectId });
        const expoPushToken = tokenData.data;

        if (cancelled) return;

        const ok = await registerTokenWithRetry(expoPushToken);
        if (__DEV__ && ok) console.log("[NOTIF] Token registered");
      } catch (e) {
        if (__DEV__) console.error("[NOTIF] Token error:", e);
        captureError(e, { feature: 'notifications', step: 'getExpoPushTokenAsync' });
      }

      // Cold start — si app lancée depuis tap notif, router maintenant
      try {
        const last = await Notifications!.getLastNotificationResponseAsync();
        if (last && !cancelled) {
          const data = last.notification.request.content.data as NotifData;
          routeNotificationTap(data, helpers);
          // Clear badge après prise en compte du tap
          await Notifications!.setBadgeCountAsync(0).catch(() => {});
        }
      } catch {}
    })();

    // APNs token rotation (réinstall, restore, key change) → re-sync backend