From 811984b87b0b8b8418e4b21f7163c8ddc376073d Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:45:06 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9B=B9=EB=B7=B0=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=88=98=EC=8B=A0=EC=9D=84=20=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/webview/WebViewScreen.tsx | 17 ++--- src/features/webview/bridge/bridge.ts | 71 +++++++++++++++++++ .../{ => bridge/messages}/safeAreaColors.ts | 22 ++---- 3 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 src/features/webview/bridge/bridge.ts rename src/features/webview/{ => bridge/messages}/safeAreaColors.ts (69%) diff --git a/src/features/webview/WebViewScreen.tsx b/src/features/webview/WebViewScreen.tsx index 429ceee..971ee66 100644 --- a/src/features/webview/WebViewScreen.tsx +++ b/src/features/webview/WebViewScreen.tsx @@ -5,11 +5,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { WebView, type WebViewMessageEvent, type WebViewNavigation } from "react-native-webview"; import type { ShouldStartLoadRequest } from "react-native-webview/lib/WebViewTypes"; import { WEB_URL } from "@/constants/config"; +import { dispatchBridgeMessage } from "@/features/webview/bridge/bridge"; +import { DEFAULT_SAFE_AREA_COLORS } from "@/features/webview/bridge/messages/safeAreaColors"; import WebViewMessage from "@/features/webview/components/WebViewMessage"; -import { - DEFAULT_SAFE_AREA_COLORS, - parseSafeAreaColorsMessage, -} from "@/features/webview/safeAreaColors"; import { isSameOrigin } from "@/utils/url"; // WebView는 서드파티 컴포넌트라 NativeWind의 className이 적용되지 않는다. style로 채운다. @@ -55,13 +53,12 @@ export default function WebViewScreen() { setCanGoBack(navigation.canGoBack); }, []); - // 웹은 화면 배경이 바뀔 때마다 세이프에어리어 스트립 색을 보낸다. 아는 메시지만 반영한다. + // 웹이 보내는 메시지는 브리지가 가려내고, 여기서는 type별로 무엇을 할지만 적는다. const handleMessage = useCallback((event: WebViewMessageEvent) => { - const colors = parseSafeAreaColorsMessage(event.nativeEvent.data); - - if (colors) { - setSafeAreaColors(colors); - } + dispatchBridgeMessage(event.nativeEvent.data, { + // 웹은 화면 배경이 바뀔 때마다 세이프에어리어 스트립 색을 보낸다. + safeAreaColors: setSafeAreaColors, + }); }, []); // 서비스 바깥 주소는 웹뷰 안에서 열지 않고 시스템 브라우저·기본 앱으로 넘긴다. diff --git a/src/features/webview/bridge/bridge.ts b/src/features/webview/bridge/bridge.ts new file mode 100644 index 0000000..f8ce4eb --- /dev/null +++ b/src/features/webview/bridge/bridge.ts @@ -0,0 +1,71 @@ +/** + * 웹 → 앱 postMessage 브리지. + * + * 웹은 `window.ReactNativeWebView.postMessage(JSON.stringify({ type, payload }))`로 보낸다. + * `type`은 메시지 표식, `payload`는 메시지별 본문이다. 화면은 JSON 파싱·`type` 분기를 모르고 + * `type`별 핸들러만 넘긴다. + * + * 메시지 추가 절차: + * 1. `messages/<이름>.ts`에 type 상수 · payload 인터페이스 · `(payload: unknown) => T | null` 파서 + * 2. 아래 `parsers`에 한 줄 등록 — 나머지 타입은 여기서 파생된다 + * 3. 화면의 `dispatchBridgeMessage` 핸들러에 항목 추가 + * 4. stream-client-web에 같은 type 상수·같은 payload 필드로 송신부 작성 (PR을 서로 링크) + */ + +import { + parseSafeAreaColorsPayload, + SAFE_AREA_COLORS_MESSAGE_TYPE, +} from "@/features/webview/bridge/messages/safeAreaColors"; + +// 파서는 형식이 안 맞으면 `null`을 돌려준다. 던지지 않는다 — onMessage는 WebView 안의 어떤 +// 스크립트든 부를 수 있어, 이상한 값은 오류가 아니라 "우리 메시지가 아님"으로 다룬다. +const parsers = { + [SAFE_AREA_COLORS_MESSAGE_TYPE]: parseSafeAreaColorsPayload, +} satisfies Record unknown>; + +type BridgeMessageType = keyof typeof parsers; +type BridgePayload = NonNullable>; + +export type BridgeHandlers = Partial<{ + [K in BridgeMessageType]: (payload: BridgePayload) => void; +}>; + +function isBridgeMessageType(type: unknown): type is BridgeMessageType { + return typeof type === "string" && Object.hasOwn(parsers, type); +} + +/** + * WebView `onMessage` 원문을 받아, 아는 메시지이고 핸들러가 있으면 부른다. + * 모르는 메시지·형식 불일치는 조용히 버린다 — 웹이 다른 용도로 postMessage를 쓸 수 있다. + */ +export function dispatchBridgeMessage(data: string, handlers: BridgeHandlers): void { + let message: unknown; + + try { + message = JSON.parse(data); + } catch { + // JSON이 아니면 우리 메시지가 아니다. + return; + } + + if (typeof message !== "object" || message === null) { + return; + } + + const { type, payload } = message as Record; + + if (!isBridgeMessageType(type)) { + return; + } + + const parsed = parsers[type](payload); + + if (parsed === null) { + return; + } + + // 레지스트리 키로 좁혀진 type과 그 파서 결과는 짝이 맞지만, TS는 인덱스 접근에서 이 상관관계를 + // 추적하지 못한다. 핸들러 쪽 시그니처는 BridgeHandlers로 보장되므로 여기서만 넓힌다. + const handler = handlers[type] as ((payload: unknown) => void) | undefined; + handler?.(parsed); +} diff --git a/src/features/webview/safeAreaColors.ts b/src/features/webview/bridge/messages/safeAreaColors.ts similarity index 69% rename from src/features/webview/safeAreaColors.ts rename to src/features/webview/bridge/messages/safeAreaColors.ts index cb10f35..4944fcc 100644 --- a/src/features/webview/safeAreaColors.ts +++ b/src/features/webview/bridge/messages/safeAreaColors.ts @@ -9,10 +9,10 @@ * 구조라 값을 앱이 들고 있지 않도록 바꿨다. * * 송신부: stream-client-web의 `src/components/ui/useNativeSafeAreaColors.ts`. - * 표식 문자열과 필드 이름은 양쪽이 맞춰야 한다. + * 표식 문자열과 payload 필드 이름은 양쪽이 맞춰야 한다. */ -const SAFE_AREA_COLORS_MESSAGE_TYPE = "safeAreaColors"; +export const SAFE_AREA_COLORS_MESSAGE_TYPE = "safeAreaColors"; export interface SafeAreaColors { bottom: string; @@ -34,25 +34,15 @@ function isColor(value: unknown): value is string { return typeof value === "string" && CSS_COLOR_PATTERN.test(value); } -/** 웹이 보낸 원문에서 스트립 색을 꺼낸다. 이 앱이 아는 메시지가 아니면 `null`. */ -export function parseSafeAreaColorsMessage(data: string): SafeAreaColors | null { - let payload: unknown; - - try { - payload = JSON.parse(data); - } catch { - // JSON이 아니면 우리 메시지가 아니다. 웹이 다른 용도로 postMessage를 쓸 수 있으므로 - // 오류로 다루지 않고 흘려보낸다. - return null; - } - +/** payload에서 스트립 색을 꺼낸다. 형식이 맞지 않으면 `null`. */ +export function parseSafeAreaColorsPayload(payload: unknown): SafeAreaColors | null { if (typeof payload !== "object" || payload === null) { return null; } - const { bottom, top, type } = payload as Record; + const { bottom, top } = payload as Record; - if (type !== SAFE_AREA_COLORS_MESSAGE_TYPE || !isColor(top) || !isColor(bottom)) { + if (!isColor(top) || !isColor(bottom)) { return null; }