/** * useNotifications — bootstrap expo-notifications (permission + handler) * и routing при tap'е на notification → открыть конкретный чат. * * ВАЖНО: expo-notifications в Expo Go (SDK 53+) валит error при САМОМ * import'е модуля ("Android Push notifications ... removed from Expo Go"). * Поэтому мы НЕ делаем static `import * as Notifications from ...` — * вместо этого lazy `require()` внутри функций, только если мы вне Expo Go. * На dev-build / production APK всё работает штатно. * * Privacy: notification содержит ТОЛЬКО имя отправителя и счётчик * непрочитанных. Тело сообщения НЕ показывается — для E2E-мессенджера * это критично (push нотификации проходят через OS / APNs и могут * логироваться). */ import { useEffect } from 'react'; import { Platform } from 'react-native'; import Constants, { ExecutionEnvironment } from 'expo-constants'; import { router } from 'expo-router'; // В Expo Go push-нотификации отключены с SDK 53. Любое обращение к // expo-notifications (включая import) пишет error в консоль. Детектим // среду один раз на module-load. const IS_EXPO_GO = Constants.executionEnvironment === ExecutionEnvironment.StoreClient; /** * Lazy-load expo-notifications. Возвращает модуль или null в Expo Go. * Кешируем результат, чтобы не делать require повторно. */ let _cached: any | null | undefined = undefined; function getNotifications(): any | null { if (_cached !== undefined) return _cached; if (IS_EXPO_GO) { _cached = null; return null; } try { // eslint-disable-next-line @typescript-eslint/no-require-imports _cached = require('expo-notifications'); } catch { _cached = null; } return _cached; } // Handler ставим лениво при первом обращении (а не на module-top'е), // т.к. require сам по себе подтянет модуль — в Expo Go его не дергаем. let _handlerInstalled = false; function installHandler() { if (_handlerInstalled) return; const N = getNotifications(); if (!N) return; N.setNotificationHandler({ handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: true, }), }); _handlerInstalled = true; } export function useNotifications() { useEffect(() => { const N = getNotifications(); if (!N) return; // Expo Go — no-op installHandler(); (async () => { try { const existing = await N.getPermissionsAsync(); if (existing.status !== 'granted' && existing.canAskAgain !== false) { await N.requestPermissionsAsync(); } if (Platform.OS === 'android') { // Channel — обязателен для Android 8+ чтобы уведомления показывались. await N.setNotificationChannelAsync('messages', { name: 'Messages', importance: N.AndroidImportance.HIGH, vibrationPattern: [0, 200, 100, 200], lightColor: '#1d9bf0', sound: undefined, }); } } catch { // fail-safe — notifications не критичны } })(); // Tap-to-open listener. const sub = N.addNotificationResponseReceivedListener((resp: any) => { const data = resp?.notification?.request?.content?.data as { contactAddress?: string } | undefined; if (data?.contactAddress) { router.push(`/(app)/chats/${data.contactAddress}` as never); } }); return () => { try { sub.remove(); } catch { /* ignore */ } }; }, []); } /** * Показать локальное уведомление о новом сообщении. Вызывается из * global-inbox-listener'а когда приходит envelope от peer'а. * * content не содержит текста — только "New message" как generic label * (см. privacy-note в doc'е выше). */ export async function notifyIncoming(params: { contactAddress: string; senderName: string; unreadCount: number; }) { const N = getNotifications(); if (!N) return; // Expo Go — no-op const { contactAddress, senderName, unreadCount } = params; try { await N.scheduleNotificationAsync({ identifier: `inbox:${contactAddress}`, // replaces previous for same contact content: { title: senderName, body: unreadCount === 1 ? 'New message' : `${unreadCount} new messages`, data: { contactAddress }, }, trigger: null, // immediate }); } catch { // Fail silently — если OS не дала permission, notification не // покажется. Не ломаем send-flow. } } /** Dismiss notification для контакта (вызывается когда чат открыт). */ export async function clearContactNotifications(contactAddress: string) { const N = getNotifications(); if (!N) return; try { await N.dismissNotificationAsync(`inbox:${contactAddress}`); } catch { /* ignore */ } }