import { StyleSheet, View, BackHandler, Linking, StatusBar, PermissionsAndroid, Platform, Alert, } from 'react-native'; import { WebView } from 'react-native-webview'; import { useRef, useEffect, useState, useCallback } from 'react'; import Pushy from 'pushy-react-native'; const APP_URL = 'https://iistwin.ru'; const APP_ORIGIN = new URL(APP_URL).origin; // Injected before every page load: // 1. sessionStorage → localStorage so session survives app restarts. // 2. Intercept setItem('accessToken') → login event for SPA login detection. // 3. Intercept removeItem('accessToken') → logout event. const BASE_INJECTED_JS = ` (function() { try { Object.defineProperty(window, 'sessionStorage', { get: function() { return window.localStorage; }, configurable: true }); } catch(e) {} try { var _origSet = localStorage.setItem.bind(localStorage); var _origRemove = localStorage.removeItem.bind(localStorage); localStorage.setItem = function(key, value) { _origSet(key, value); if (key === 'accessToken' && value && window.ReactNativeWebView) { window.ReactNativeWebView.postMessage( JSON.stringify({ type: 'login', token: value }) ); } }; localStorage.removeItem = function(key) { if (key === 'accessToken') { var tok = localStorage.getItem('accessToken'); if (tok && window.ReactNativeWebView) { window.ReactNativeWebView.postMessage( JSON.stringify({ type: 'logout', token: tok }) ); } } return _origRemove(key); }; } catch(e) {} })(); true; `; async function requestNotificationPermission() { if (Platform.OS !== 'android') return; if (Platform.Version >= 33) { await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, { title: 'Разрешение на уведомления', message: 'iistwin хочет отправлять вам уведомления о задачах и сообщениях.', buttonPositive: 'Разрешить', buttonNegative: 'Не сейчас', } ); } } export default function App() { const webViewRef = useRef(null); const [canGoBack, setCanGoBack] = useState(false); const [pushyToken, setPushyToken] = useState(null); // true once the WebView fires onLoadEnd at least once const [webViewReady, setWebViewReady] = useState(false); // Auth token received via login event before pushyToken was available (race guard) const [pendingAuthToken, setPendingAuthToken] = useState(null); // URL from notification tap while app was killed (cold-start) const [pendingUrl, setPendingUrl] = useState(null); // ── helpers ──────────────────────────────────────────────────────────────── // Register using an auth token passed explicitly (login event / replay) const registerDevice = useCallback((deviceToken, authToken) => { if (!deviceToken || !authToken || !webViewRef.current) return; const dt = JSON.stringify(deviceToken); const at = JSON.stringify(authToken); webViewRef.current.injectJavaScript(` (function() { try { fetch('/api/devices/register', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} }, body: JSON.stringify({ token: ${dt}, platform: 'android' }) }).catch(function() {}); } catch(e) {} })(); true; `); }, []); // Register by reading accessToken from localStorage (existing sessions / page reload) const registerFromStorage = useCallback((deviceToken) => { if (!deviceToken || !webViewRef.current) return; const dt = JSON.stringify(deviceToken); webViewRef.current.injectJavaScript(` (function() { try { var at = localStorage.getItem('accessToken'); if (at) { fetch('/api/devices/register', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + at }, body: JSON.stringify({ token: ${dt}, platform: 'android' }) }).catch(function() {}); } } catch(e) {} })(); true; `); }, []); const unregisterDevice = useCallback((deviceToken, authToken) => { if (!deviceToken || !authToken || !webViewRef.current) return; const dt = JSON.stringify(deviceToken); const at = JSON.stringify(authToken); webViewRef.current.injectJavaScript(` (function() { try { fetch('/api/devices/unregister', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} }, body: JSON.stringify({ token: ${dt} }) }).catch(function() {}); } catch(e) {} })(); true; `); }, []); const navigateTo = useCallback((path) => { if (!path || !webViewRef.current) return; const url = path.startsWith('http') ? path : APP_URL + path; webViewRef.current.injectJavaScript( `window.location.href = ${JSON.stringify(url)}; true;` ); }, []); // ── Coordination effect: fires whenever pushyToken or webViewReady changes ── // Ensures registration always happens once BOTH sides are ready, // regardless of which arrives first. useEffect(() => { if (!pushyToken || !webViewReady) return; if (pendingAuthToken) { // Login event arrived before pushyToken – replay it now registerDevice(pushyToken, pendingAuthToken); setPendingAuthToken(null); } else { // Normal start: read token from existing session in localStorage registerFromStorage(pushyToken); } }, [pushyToken, webViewReady]); // eslint-disable-line react-hooks/exhaustive-deps // ── One-time effect: Pushy setup ───────────────────────────────────────── useEffect(() => { requestNotificationPermission(); Pushy.register() .then((token) => { console.log('Pushy device token:', token); setPushyToken(token); }) .catch((err) => { console.error('Pushy registration error:', err.message); }); Pushy.setNotificationHandler((data, callback) => { const title = data.title || 'iistwin'; const body = data.body || ''; if (body) { Alert.alert(title, body, [ { text: 'Открыть', onPress: () => { if (data.url) navigateTo(data.url); } }, { text: 'Закрыть', style: 'cancel' }, ]); } callback(); }); Pushy.setNotificationClickListener((data) => { if (data.url) { if (webViewRef.current) { navigateTo(data.url); } else { setPendingUrl(data.url); } } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── Back button ─────────────────────────────────────────────────────────── useEffect(() => { const onBackPress = () => { if (canGoBack && webViewRef.current) { webViewRef.current.goBack(); return true; } return false; }; const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress); return () => sub.remove(); }, [canGoBack]); // ── WebView callbacks ───────────────────────────────────────────────────── const handleLoadEnd = () => { setWebViewReady(true); // triggers coordination effect above if (pendingUrl) { navigateTo(pendingUrl); setPendingUrl(null); } }; const handleMessage = useCallback((event) => { try { const msg = JSON.parse(event.nativeEvent.data); if (msg.type === 'login' && msg.token) { if (pushyToken) { // Both pieces ready — register immediately registerDevice(pushyToken, msg.token); } else { // pushyToken not yet available — cache for coordination effect setPendingAuthToken(msg.token); } } else if (msg.type === 'logout' && msg.token && pushyToken) { unregisterDevice(pushyToken, msg.token); } } catch (e) { // ignore malformed messages } }, [pushyToken, registerDevice, unregisterDevice]); return ( setCanGoBack(navState.canGoBack)} onPermissionRequest={(request) => { request.grant(request.resources); }} onShouldStartLoadWithRequest={(request) => { try { if (new URL(request.url).origin === APP_ORIGIN) return true; } catch {} Linking.openURL(request.url); return false; }} /> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#3b82f6' }, webview: { flex: 1 }, });