- Added functionality to generate a set of images for posting to X (formerly Twitter) with multiple images. - Introduced a new button for creating X投稿向け画像, which generates up to 4 PNG images. - The first image serves as a cover with a route map and summary, while subsequent images contain detailed operation information. - Ensured images maintain a fixed size of 1080 x 1440 px and follow specific naming conventions. - Implemented error handling for image generation failures and ensured compatibility with existing sharing features. - Updated UI elements and styles for the new functionality, including button states and layout adjustments. - Added necessary TypeScript type checks and validation for the new features.
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
import React, { useCallback, useEffect, useRef } from "react";
|
|
import { Platform } from "react-native";
|
|
import { WebView } from "react-native-webview";
|
|
|
|
import {
|
|
lineList,
|
|
lineList_LineWebID,
|
|
lineListPair,
|
|
stationIDPair,
|
|
stationNamePair,
|
|
} from "../../lib/getStationList";
|
|
import { checkDuplicateTrainData } from "../../lib/checkDuplicateTrainData";
|
|
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
|
|
import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
|
|
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
|
|
import { SheetManager } from "react-native-actions-sheet";
|
|
|
|
import { useNavigation, useIsFocused } from "@react-navigation/native";
|
|
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
|
import { useStationList } from "../../stateBox/useStationList";
|
|
import { useThemeColors } from "@/lib/theme";
|
|
import { useWebViewRemount } from "@/lib/useWebViewRemount";
|
|
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
|
|
import * as Sentry from "@sentry/react-native";
|
|
import { setAppLifecycleWebViewActive } from "@/lib/observability/appLifecycleCrashSentinel";
|
|
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
|
|
const initialPositionsUrl = "https://train.jr-shikoku.co.jp/sp.html";
|
|
const { webview, currentTrain } = useCurrentTrain();
|
|
const { navigate } = useNavigation<any>();
|
|
const isFocused = useIsFocused();
|
|
const { favoriteStation } = useFavoriteStation();
|
|
const { isLandscape } = useDeviceOrientationChange();
|
|
const { isDark } = useThemeColors();
|
|
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
|
|
const { originalStationList, stationList, getInjectJavascriptAddress } =
|
|
useStationList();
|
|
const {
|
|
setSelectedLine,
|
|
mapsStationData: stationData,
|
|
setLoadError,
|
|
setTrainInfo,
|
|
injectJavascript,
|
|
injectJavascriptBeforeContentLoaded,
|
|
mockApiFeatureEnabled,
|
|
mockTrainPositions,
|
|
} = useTrainMenu();
|
|
const addWebViewBreadcrumb = (
|
|
message: string,
|
|
data?: Record<string, string | number | boolean | null | undefined>
|
|
) => {
|
|
Sentry.addBreadcrumb({
|
|
category: "positions.webview",
|
|
level: "info",
|
|
message,
|
|
data,
|
|
});
|
|
};
|
|
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
|
|
pingEnabled: Platform.OS === "ios",
|
|
backgroundThresholdMs: null,
|
|
isFocused,
|
|
pauseWatchdogWhenUnfocused: Platform.OS === "ios",
|
|
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
|
|
onRemount: (reason, data) => {
|
|
addWebViewBreadcrumb("webview remount requested", {
|
|
reason,
|
|
...(data ?? {}),
|
|
});
|
|
},
|
|
});
|
|
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
|
|
|
|
useEffect(() => {
|
|
setAppLifecycleWebViewActive("positions_main", true);
|
|
return () => setAppLifecycleWebViewActive("positions_main", false);
|
|
}, []);
|
|
|
|
// コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画
|
|
const mountedRef = useRef(false);
|
|
const urlCacheRef = useRef("");
|
|
const initialInjectDoneRef = useRef(false);
|
|
const pendingInitialInjectRef = useRef<string | null>(null);
|
|
const loadEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const focusedRef = useRef(isFocused);
|
|
const initialLoadReadyNotifiedRef = useRef(false);
|
|
const previousMockApiFeatureEnabledRef = useRef(mockApiFeatureEnabled);
|
|
|
|
useEffect(() => {
|
|
const previousEnabled = previousMockApiFeatureEnabledRef.current;
|
|
if (previousEnabled === mockApiFeatureEnabled) return;
|
|
|
|
previousMockApiFeatureEnabledRef.current = mockApiFeatureEnabled;
|
|
addWebViewBreadcrumb("mock mode changed; remounting webview", {
|
|
previousEnabled,
|
|
enabled: mockApiFeatureEnabled,
|
|
focused: focusedRef.current,
|
|
});
|
|
remount();
|
|
}, [mockApiFeatureEnabled, remount]);
|
|
|
|
useEffect(() => {
|
|
focusedRef.current = isFocused;
|
|
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
|
|
landscape: isLandscape,
|
|
mockApi: mockApiFeatureEnabled,
|
|
});
|
|
if (!isFocused) return;
|
|
if (!pendingInitialInjectRef.current) return;
|
|
addWebViewBreadcrumb("webview pending initial inject resumed on focus");
|
|
webview?.current?.injectJavaScript(pendingInitialInjectRef.current);
|
|
pendingInitialInjectRef.current = null;
|
|
initialInjectDoneRef.current = true;
|
|
}, [isFocused, isLandscape, mockApiFeatureEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (lastRemountKeyRef.current !== remountKey) {
|
|
addWebViewBreadcrumb("webview remount key", { remountKey });
|
|
lastRemountKeyRef.current = remountKey;
|
|
initialInjectDoneRef.current = false;
|
|
pendingInitialInjectRef.current = null;
|
|
initialLoadReadyNotifiedRef.current = false;
|
|
if (loadEndTimeoutRef.current) {
|
|
clearTimeout(loadEndTimeoutRef.current);
|
|
loadEndTimeoutRef.current = null;
|
|
}
|
|
}
|
|
}, [remountKey]);
|
|
|
|
useEffect(() => {
|
|
Sentry.setContext("positions_webview", {
|
|
focused: isFocused,
|
|
landscape: isLandscape,
|
|
mockApi: mockApiFeatureEnabled,
|
|
remountKey,
|
|
currentUrl: urlCacheRef.current || null,
|
|
});
|
|
}, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
addWebViewBreadcrumb("webview cleanup start", {
|
|
focused: focusedRef.current,
|
|
currentUrl: urlCacheRef.current || null,
|
|
});
|
|
if (loadEndTimeoutRef.current) {
|
|
clearTimeout(loadEndTimeoutRef.current);
|
|
loadEndTimeoutRef.current = null;
|
|
}
|
|
addWebViewBreadcrumb("webview unmounted");
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
// 初回マウント時はスキップ(beforeContentLoaded で既に正しいデータが入っている)
|
|
if (!mountedRef.current) {
|
|
mountedRef.current = true;
|
|
addWebViewBreadcrumb("mock positions effect skipped initial mount", {
|
|
mockApi: mockApiFeatureEnabled,
|
|
});
|
|
return;
|
|
}
|
|
if (!mockApiFeatureEnabled || !mockTrainPositions) return;
|
|
addWebViewBreadcrumb("mock positions injected", {
|
|
positionCount: mockTrainPositions.length,
|
|
});
|
|
const script = generateMockUpdateScript(mockTrainPositions);
|
|
webview?.current?.injectJavaScript(script);
|
|
}, [mockApiFeatureEnabled, mockTrainPositions, webview]);
|
|
|
|
const attachWebViewRefs = useCallback((instance) => {
|
|
webview.current = instance;
|
|
webViewRef.current = instance;
|
|
}, [webViewRef, webview]);
|
|
|
|
const onNavigationStateChange = ({ url }) => {
|
|
if (url == urlCacheRef.current) return;
|
|
//URL二重判定回避
|
|
urlCacheRef.current = url;
|
|
addWebViewBreadcrumb("webview navigation", { url });
|
|
switch (true) {
|
|
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
|
|
if (Platform.OS === "android") navigate("howto", { info: url });
|
|
webview?.current.goBack();
|
|
//Actions.howto();
|
|
break;
|
|
case url.includes("https://train.jr-shikoku.co.jp/train.html"):
|
|
//Actions.trainbase({info: url});
|
|
if (Platform.OS === "android") navigate("trainbase", { info: url });
|
|
webview?.current.goBack();
|
|
break;
|
|
}
|
|
};
|
|
const getRestorableUrl = () => {
|
|
const cachedUrl = urlCacheRef.current;
|
|
if (!cachedUrl) return initialPositionsUrl;
|
|
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/usage.htm")) {
|
|
return initialPositionsUrl;
|
|
}
|
|
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/train.html")) {
|
|
return initialPositionsUrl;
|
|
}
|
|
return cachedUrl;
|
|
};
|
|
|
|
const onMessage = (event) => {
|
|
pingHandlers.onMessage?.(event);
|
|
const { data } = event.nativeEvent;
|
|
/**
|
|
* {type,trainNum,limited}
|
|
* {type,currentLines}
|
|
* {type,event,id,name,pdf,map,url,chk}
|
|
*/
|
|
if (data.includes("train.html")) {
|
|
addWebViewBreadcrumb("webview train link", { data });
|
|
navigate("trainbase", { info: data, from: "Train" });
|
|
return;
|
|
}
|
|
if (!originalStationList) {
|
|
addWebViewBreadcrumb("webview message blocked waiting station data");
|
|
alert("駅名標データを取得中...");
|
|
return;
|
|
}
|
|
let dataSet;
|
|
try {
|
|
dataSet = JSON.parse(data);
|
|
} catch (error) {
|
|
addWebViewBreadcrumb("webview message parse failed", {
|
|
dataPreview: data.slice(0, 120),
|
|
});
|
|
throw error;
|
|
}
|
|
addWebViewBreadcrumb("webview message", {
|
|
type: dataSet.type ?? "unknown",
|
|
});
|
|
switch (dataSet.type) {
|
|
case "LoadError": {
|
|
setLoadError(true);
|
|
return;
|
|
}
|
|
case "PopUpMenu":
|
|
{
|
|
const findStationEachLine = (selectLine) =>
|
|
selectLine.filter((d) => d.StationTimeTable == dataSet.pdf);
|
|
let returnDataBase = lineList
|
|
.map((d) => findStationEachLine(originalStationList[d]))
|
|
.filter((d) => d.length > 0)
|
|
.reduce((pre, current) => {
|
|
pre.push(...current);
|
|
return pre;
|
|
}, []);
|
|
|
|
if (returnDataBase.length) {
|
|
const payload = {
|
|
currentStation: returnDataBase,
|
|
navigate: navigate,
|
|
goTo: "Apps",
|
|
useShow: () =>
|
|
SheetManager.show("StationDetailView", { payload }),
|
|
onExit: () => SheetManager.hide("StationDetailView"),
|
|
};
|
|
SheetManager.show("StationDetailView", { payload });
|
|
}
|
|
}
|
|
return;
|
|
case "ShowTrainTimeInfo": {
|
|
const { trainNum, limited } = dataSet;
|
|
//alert(trainNum, limited);
|
|
setTrainInfo({
|
|
trainNum,
|
|
limited,
|
|
trainData: checkDuplicateTrainData(
|
|
currentTrain.filter((a) => a.num == trainNum),
|
|
stationList
|
|
),
|
|
}); //遅延情報は未実装
|
|
if (isLandscape) return;
|
|
const payload = {
|
|
data: { trainNum, limited },
|
|
navigate,
|
|
openStationACFromEachTrainInfo,
|
|
};
|
|
SheetManager.show("EachTrainInfo", { payload });
|
|
return;
|
|
}
|
|
case "currentLines": {
|
|
const lineInfo = dataSet.currentLines.split("\n")[0];
|
|
const lineID = stationNamePair[lineInfo];
|
|
|
|
setSelectedLine(lineID);
|
|
return;
|
|
}
|
|
default: {
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
const onLoadStart = () => {
|
|
pingHandlers.onLoadStart?.();
|
|
addWebViewBreadcrumb("webview loadStart", {
|
|
focused: focusedRef.current,
|
|
currentUrl: urlCacheRef.current || null,
|
|
});
|
|
};
|
|
|
|
const onLoadEnd = () => {
|
|
pingHandlers.onLoadEnd?.();
|
|
addWebViewBreadcrumb("webview loadEnd", {
|
|
focused: focusedRef.current,
|
|
hasStationData: !!stationData,
|
|
hasOriginalStationList: !!originalStationList,
|
|
favoriteCount: favoriteStation.length,
|
|
});
|
|
if (!initialLoadReadyNotifiedRef.current) {
|
|
initialLoadReadyNotifiedRef.current = true;
|
|
addWebViewBreadcrumb("webview initial load ready", {
|
|
focused: focusedRef.current,
|
|
});
|
|
onInitialLoadReady?.();
|
|
}
|
|
if (initialInjectDoneRef.current) return () => {};
|
|
if (!stationData) return () => {};
|
|
if (!originalStationList) return () => {};
|
|
if (favoriteStation.length < 1) return () => {};
|
|
const string = getInjectJavascriptAddress(
|
|
favoriteStation[0][0].StationNumber
|
|
);
|
|
if (!string) return () => {};
|
|
if (loadEndTimeoutRef.current) {
|
|
clearTimeout(loadEndTimeoutRef.current);
|
|
}
|
|
addWebViewBreadcrumb("webview initial inject scheduled", {
|
|
focused: focusedRef.current,
|
|
});
|
|
loadEndTimeoutRef.current = setTimeout(() => {
|
|
addWebViewBreadcrumb(
|
|
focusedRef.current
|
|
? "webview initial inject run"
|
|
: "webview initial inject run while blurred",
|
|
{
|
|
focused: focusedRef.current,
|
|
}
|
|
);
|
|
webview?.current?.injectJavaScript(string);
|
|
pendingInitialInjectRef.current = null;
|
|
initialInjectDoneRef.current = true;
|
|
loadEndTimeoutRef.current = null;
|
|
}, focusedRef.current ? 500 : 700);
|
|
};
|
|
|
|
const handleRenderProcessGone = (event) => {
|
|
addWebViewBreadcrumb("webview render process gone", {
|
|
didCrash: event.nativeEvent.didCrash,
|
|
});
|
|
processHandlers.onRenderProcessGone?.(event);
|
|
};
|
|
|
|
const handleContentProcessDidTerminate = () => {
|
|
addWebViewBreadcrumb("webview content process terminated");
|
|
processHandlers.onContentProcessDidTerminate?.();
|
|
};
|
|
|
|
return (
|
|
<WebView
|
|
key={`positions-webview-${remountKey}`}
|
|
ref={attachWebViewRefs}
|
|
source={{ uri: getRestorableUrl() }}
|
|
originWhitelist={[
|
|
"https://train.jr-shikoku.co.jp",
|
|
"https://train.jr-shikoku.co.jp/sp.html",
|
|
]}
|
|
mixedContentMode={"compatibility"}
|
|
javaScriptEnabled
|
|
allowsBackForwardNavigationGestures
|
|
setSupportMultipleWindows
|
|
contentMode="mobile"
|
|
style={{ backgroundColor: bgColor }}
|
|
onLoadStart={onLoadStart}
|
|
onError={(event) => {
|
|
addWebViewBreadcrumb("webview error", {
|
|
description: event.nativeEvent.description || null,
|
|
code: event.nativeEvent.code ?? null,
|
|
});
|
|
}}
|
|
onHttpError={(event) => {
|
|
addWebViewBreadcrumb("webview http error", {
|
|
statusCode: event.nativeEvent.statusCode ?? null,
|
|
});
|
|
}}
|
|
onRenderProcessGone={handleRenderProcessGone}
|
|
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
|
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
|
|
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
|
|
injectedJavaScript={injectJavascript}
|
|
/>
|
|
);
|
|
};
|