Files
iistwin/mobile/App.js
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

283 lines
9.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 },
});