Files
jrshikoku/components/Apps/WebView.tsx
harukin-expo-dev-env ca045ca3a3 feat: Implement X投稿向け運行情報画像セット generation in ndView.tsx
- 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.
2026-07-12 19:01:51 +00:00

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