- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
283 lines
9.6 KiB
JavaScript
283 lines
9.6 KiB
JavaScript
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 (
|
||
<View style={styles.container}>
|
||
<StatusBar backgroundColor="#3b82f6" barStyle="light-content" translucent={false} />
|
||
<WebView
|
||
ref={webViewRef}
|
||
source={{ uri: APP_URL }}
|
||
style={styles.webview}
|
||
javaScriptEnabled={true}
|
||
domStorageEnabled={true}
|
||
startInLoadingState={true}
|
||
sharedCookiesEnabled={true}
|
||
thirdPartyCookiesEnabled={true}
|
||
injectedJavaScriptBeforeContentLoaded={BASE_INJECTED_JS}
|
||
onLoadEnd={handleLoadEnd}
|
||
onMessage={handleMessage}
|
||
onNavigationStateChange={(navState) => 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;
|
||
}}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: '#3b82f6' },
|
||
webview: { flex: 1 },
|
||
});
|