From 7b224dd48a78067b6a2845e6f6bd334174c6413a Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:43:08 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EB=B8=8C=EB=A6=AC=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8C=8C=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/webview/bridge/bridge.ts | 18 ++ .../webview/bridge/messages/navigation.ts | 174 ++++++++++++++++++ .../webview/bridge/messages/safeAreaColors.ts | 2 +- 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/features/webview/bridge/messages/navigation.ts diff --git a/src/features/webview/bridge/bridge.ts b/src/features/webview/bridge/bridge.ts index f8ce4eb..333c7ba 100644 --- a/src/features/webview/bridge/bridge.ts +++ b/src/features/webview/bridge/bridge.ts @@ -7,11 +7,24 @@ * * 메시지 추가 절차: * 1. `messages/<이름>.ts`에 type 상수 · payload 인터페이스 · `(payload: unknown) => T | null` 파서 + * type은 `영역.동작`으로 짓는다(예: `navigation.push`). `safeAreaColors`는 이 규칙 전에 생긴 + * 이름이라 그대로 둔다 * 2. 아래 `parsers`에 한 줄 등록 — 나머지 타입은 여기서 파생된다 * 3. 화면의 `dispatchBridgeMessage` 핸들러에 항목 추가 * 4. stream-client-web에 같은 type 상수·같은 payload 필드로 송신부 작성 (PR을 서로 링크) */ +import { + NAVIGATION_BACK_GUARD_MESSAGE_TYPE, + NAVIGATION_CLOSE_MESSAGE_TYPE, + NAVIGATION_POP_MESSAGE_TYPE, + NAVIGATION_PUSH_MESSAGE_TYPE, + NAVIGATION_REPLACE_MESSAGE_TYPE, + parseNavigationBackGuardPayload, + parseNavigationClosePayload, + parseNavigationPopPayload, + parseNavigationScreenPayload, +} from "@/features/webview/bridge/messages/navigation"; import { parseSafeAreaColorsPayload, SAFE_AREA_COLORS_MESSAGE_TYPE, @@ -21,6 +34,11 @@ import { // 스크립트든 부를 수 있어, 이상한 값은 오류가 아니라 "우리 메시지가 아님"으로 다룬다. const parsers = { [SAFE_AREA_COLORS_MESSAGE_TYPE]: parseSafeAreaColorsPayload, + [NAVIGATION_PUSH_MESSAGE_TYPE]: parseNavigationScreenPayload, + [NAVIGATION_REPLACE_MESSAGE_TYPE]: parseNavigationScreenPayload, + [NAVIGATION_POP_MESSAGE_TYPE]: parseNavigationPopPayload, + [NAVIGATION_CLOSE_MESSAGE_TYPE]: parseNavigationClosePayload, + [NAVIGATION_BACK_GUARD_MESSAGE_TYPE]: parseNavigationBackGuardPayload, } satisfies Record unknown>; type BridgeMessageType = keyof typeof parsers; diff --git a/src/features/webview/bridge/messages/navigation.ts b/src/features/webview/bridge/messages/navigation.ts new file mode 100644 index 0000000..86c4b86 --- /dev/null +++ b/src/features/webview/bridge/messages/navigation.ts @@ -0,0 +1,174 @@ +/** + * 스택 화면 이동 — stream-client-web이 postMessage로 알려준다. + * + * 하단 탭이 없는 화면(상세·신청서·신청 결과)은 웹뷰 안에서 넘기지 않고 앱 스택에 + * "앱 헤더 + 새 웹뷰"로 쌓는다. 네이티브 전환 애니메이션과 iOS 스와이프 뒤로가기는 앱 화면이어야 + * 생기기 때문이다. 어디로 갈지·헤더에 무엇을 붙일지는 웹 라우트가 알고 있으므로 웹이 정해서 + * 보내고, 앱은 받은 대로 스택만 움직인다. + * + * 송신부: stream-client-web (아직 없음 — 웹 쪽 이슈에서 작성한다). + * 표식 문자열과 payload 필드 이름은 양쪽이 맞춰야 한다. + */ + +import { isColor } from "@/features/webview/bridge/messages/safeAreaColors"; + +/** 스택에 화면을 새로 쌓는다. */ +export const NAVIGATION_PUSH_MESSAGE_TYPE = "navigation.push"; +/** 지금 스택 화면을 바꿔 끼운다. 제출이 끝난 신청서처럼 돌아가면 안 되는 화면을 치울 때 쓴다. */ +export const NAVIGATION_REPLACE_MESSAGE_TYPE = "navigation.replace"; +/** 지금 스택 화면을 닫는다. */ +export const NAVIGATION_POP_MESSAGE_TYPE = "navigation.pop"; +/** 스택을 전부 닫고 루트(탭 화면)로 돌아간다. */ +export const NAVIGATION_CLOSE_MESSAGE_TYPE = "navigation.close"; +/** 뒤로가기를 막을지 알린다. 작성 중인 신청서처럼 나가기 전에 웹이 확인해야 하는 화면에서 켠다. */ +export const NAVIGATION_BACK_GUARD_MESSAGE_TYPE = "navigation.backGuard"; + +/** 스택 화면 위에 앱이 그리는 헤더. */ +export interface NavigationHeader { + /** 없으면 제목 없이 버튼만 그린다. */ + title?: string; + /** `back`은 왼쪽 ←(이 화면만 닫기), `close`는 오른쪽 X(스택 전체 닫기). */ + button: "back" | "close"; + /** 목적지 화면의 배경. 웹뷰가 뜨기 전에 헤더를 먼저 칠하려고 미리 받는다. 없으면 기본값. */ + backgroundColor?: string; +} + +/** push·replace 공통 — 스택에 올릴 화면. */ +export interface NavigationScreenPayload { + /** 웹 경로(`/events/1/apply`). 앱이 `WEB_URL`의 origin에 붙여 웹뷰 주소를 만든다. */ + path: string; + /** `null`이면 앱 헤더 없이 웹이 직접 그린다. 행사 상세처럼 이미지 위에 ←를 띄우는 화면. */ + header: NavigationHeader | null; +} + +export type NavigationPopPayload = Record; + +export interface NavigationClosePayload { + /** 있으면 스택을 닫은 뒤 루트 웹뷰를 이 경로로 보낸다. 없으면 루트는 있던 화면 그대로다. */ + path?: string; +} + +export interface NavigationBackGuardPayload { + /** 켜져 있으면 ←·스와이프·안드로이드 백을 막고, 나갈지는 웹에 물어본다. */ + enabled: boolean; +} + +// 웹은 경로만 보낸다. 웹뷰에 띄울 주소의 origin은 앱이 WEB_URL로 정하고, 웹은 그 안의 경로만 +// 고른다. `//host`·`/\host`는 URL로 풀면 다른 호스트를 가리키게 되므로 경로로 치지 않는다. +const PATH_PATTERN = /^\/(?![/\\])/; + +function isPath(value: unknown): value is string { + return typeof value === "string" && PATH_PATTERN.test(value); +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function isHeaderButton(value: unknown): value is NavigationHeader["button"] { + return value === "back" || value === "close"; +} + +// 선택 필드는 아예 없거나, 있으면 형식이 맞아야 한다. +function isOptional( + value: unknown, + isType: (value: unknown) => value is T, +): value is T | undefined { + return value === undefined || isType(value); +} + +// payload는 필드가 없어도 항상 객체로 온다(`{}`). 객체가 아니면 우리 메시지가 아니다. +function toRecord(payload: unknown): Record | null { + return typeof payload === "object" && payload !== null + ? (payload as Record) + : null; +} + +function parseNavigationHeader(value: unknown): NavigationHeader | null { + const record = toRecord(value); + + if (record === null) { + return null; + } + + const { backgroundColor, button, title } = record; + + if ( + !isHeaderButton(button) || + !isOptional(title, isString) || + !isOptional(backgroundColor, isColor) + ) { + return null; + } + + return { backgroundColor, button, title }; +} + +/** push·replace payload에서 올릴 화면을 꺼낸다. 형식이 맞지 않으면 `null`. */ +export function parseNavigationScreenPayload(payload: unknown): NavigationScreenPayload | null { + const record = toRecord(payload); + + if (record === null) { + return null; + } + + const { header, path } = record; + + if (!isPath(path)) { + return null; + } + + // 헤더를 빠뜨린 것과 "앱 헤더 없음"을 구분한다 — 없음은 `null`로 적어 보내야 한다. + if (header === null) { + return { header: null, path }; + } + + const parsedHeader = parseNavigationHeader(header); + + if (parsedHeader === null) { + return null; + } + + return { header: parsedHeader, path }; +} + +/** pop payload를 확인한다. 실어 오는 값은 없다. 형식이 맞지 않으면 `null`. */ +export function parseNavigationPopPayload(payload: unknown): NavigationPopPayload | null { + return toRecord(payload) === null ? null : {}; +} + +/** close payload에서 루트가 돌아갈 경로를 꺼낸다. 형식이 맞지 않으면 `null`. */ +export function parseNavigationClosePayload(payload: unknown): NavigationClosePayload | null { + const record = toRecord(payload); + + if (record === null) { + return null; + } + + const { path } = record; + + if (!isOptional(path, isPath)) { + return null; + } + + return { path }; +} + +/** backGuard payload에서 가드를 켤지 꺼낸다. 형식이 맞지 않으면 `null`. */ +export function parseNavigationBackGuardPayload( + payload: unknown, +): NavigationBackGuardPayload | null { + const record = toRecord(payload); + + if (record === null) { + return null; + } + + const { enabled } = record; + + if (typeof enabled !== "boolean") { + return null; + } + + return { enabled }; +} diff --git a/src/features/webview/bridge/messages/safeAreaColors.ts b/src/features/webview/bridge/messages/safeAreaColors.ts index 4944fcc..9537fa1 100644 --- a/src/features/webview/bridge/messages/safeAreaColors.ts +++ b/src/features/webview/bridge/messages/safeAreaColors.ts @@ -30,7 +30,7 @@ export const DEFAULT_SAFE_AREA_COLORS: SafeAreaColors = { // 스트립이 검게 칠해지는 일이 없도록 표기를 확인하고 통과시킨다. const CSS_COLOR_PATTERN = /^#[0-9a-f]{3,8}$|^rgba?\([\d\s.,%/]+\)$/i; -function isColor(value: unknown): value is string { +export function isColor(value: unknown): value is string { return typeof value === "string" && CSS_COLOR_PATTERN.test(value); } From 6998482906da63eec2e4d3214f2eb535cda901fe Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:23:26 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=83=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20payload=EB=A5=BC=20=ED=97=A4=EB=8D=94=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20=EC=A4=84?= =?UTF-8?q?=EC=9D=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webview/bridge/messages/navigation.ts | 73 +++++-------------- .../webview/bridge/messages/safeAreaColors.ts | 2 +- 2 files changed, 18 insertions(+), 57 deletions(-) diff --git a/src/features/webview/bridge/messages/navigation.ts b/src/features/webview/bridge/messages/navigation.ts index 86c4b86..931a173 100644 --- a/src/features/webview/bridge/messages/navigation.ts +++ b/src/features/webview/bridge/messages/navigation.ts @@ -1,17 +1,16 @@ /** * 스택 화면 이동 — stream-client-web이 postMessage로 알려준다. * - * 하단 탭이 없는 화면(상세·신청서·신청 결과)은 웹뷰 안에서 넘기지 않고 앱 스택에 - * "앱 헤더 + 새 웹뷰"로 쌓는다. 네이티브 전환 애니메이션과 iOS 스와이프 뒤로가기는 앱 화면이어야 - * 생기기 때문이다. 어디로 갈지·헤더에 무엇을 붙일지는 웹 라우트가 알고 있으므로 웹이 정해서 - * 보내고, 앱은 받은 대로 스택만 움직인다. + * 하단 탭이 없는 화면(상세·신청서·신청 결과)은 웹뷰 안에서 넘기지 않고 앱 스택에 새 웹뷰로 + * 쌓는다. 네이티브 전환 애니메이션과 iOS 스와이프 뒤로가기는 앱 화면이어야 생기기 때문이다. + * 헤더 바·제목은 웹이 그대로 그리고, 앱은 그 위에 ←/X 버튼만 겹쳐 그린다 — 웹뷰가 뜨기 전이나 + * 오류가 났을 때도 나갈 수 있게. 어디로 갈지·어떤 버튼을 둘지는 웹 라우트가 알고 있으므로 + * 웹이 정해서 보내고, 앱은 받은 대로 스택만 움직인다. * * 송신부: stream-client-web (아직 없음 — 웹 쪽 이슈에서 작성한다). * 표식 문자열과 payload 필드 이름은 양쪽이 맞춰야 한다. */ -import { isColor } from "@/features/webview/bridge/messages/safeAreaColors"; - /** 스택에 화면을 새로 쌓는다. */ export const NAVIGATION_PUSH_MESSAGE_TYPE = "navigation.push"; /** 지금 스택 화면을 바꿔 끼운다. 제출이 끝난 신청서처럼 돌아가면 안 되는 화면을 치울 때 쓴다. */ @@ -23,22 +22,18 @@ export const NAVIGATION_CLOSE_MESSAGE_TYPE = "navigation.close"; /** 뒤로가기를 막을지 알린다. 작성 중인 신청서처럼 나가기 전에 웹이 확인해야 하는 화면에서 켠다. */ export const NAVIGATION_BACK_GUARD_MESSAGE_TYPE = "navigation.backGuard"; -/** 스택 화면 위에 앱이 그리는 헤더. */ -export interface NavigationHeader { - /** 없으면 제목 없이 버튼만 그린다. */ - title?: string; - /** `back`은 왼쪽 ←(이 화면만 닫기), `close`는 오른쪽 X(스택 전체 닫기). */ - button: "back" | "close"; - /** 목적지 화면의 배경. 웹뷰가 뜨기 전에 헤더를 먼저 칠하려고 미리 받는다. 없으면 기본값. */ - backgroundColor?: string; -} +/** + * 앱이 웹 헤더 위에 겹쳐 그리는 버튼. + * `back`은 왼쪽 ←(이 화면만 닫기), `close`는 오른쪽 X(스택 전체 닫기). + */ +export type NavigationButton = "back" | "close"; /** push·replace 공통 — 스택에 올릴 화면. */ export interface NavigationScreenPayload { /** 웹 경로(`/events/1/apply`). 앱이 `WEB_URL`의 origin에 붙여 웹뷰 주소를 만든다. */ path: string; - /** `null`이면 앱 헤더 없이 웹이 직접 그린다. 행사 상세처럼 이미지 위에 ←를 띄우는 화면. */ - header: NavigationHeader | null; + /** `null`이면 앱은 버튼을 그리지 않고 웹이 직접 그린다. 행사 상세처럼 이미지 위에 ←를 띄우는 화면. */ + button: NavigationButton | null; } export type NavigationPopPayload = Record; @@ -61,11 +56,7 @@ function isPath(value: unknown): value is string { return typeof value === "string" && PATH_PATTERN.test(value); } -function isString(value: unknown): value is string { - return typeof value === "string"; -} - -function isHeaderButton(value: unknown): value is NavigationHeader["button"] { +function isNavigationButton(value: unknown): value is NavigationButton { return value === "back" || value === "close"; } @@ -84,26 +75,6 @@ function toRecord(payload: unknown): Record | null { : null; } -function parseNavigationHeader(value: unknown): NavigationHeader | null { - const record = toRecord(value); - - if (record === null) { - return null; - } - - const { backgroundColor, button, title } = record; - - if ( - !isHeaderButton(button) || - !isOptional(title, isString) || - !isOptional(backgroundColor, isColor) - ) { - return null; - } - - return { backgroundColor, button, title }; -} - /** push·replace payload에서 올릴 화면을 꺼낸다. 형식이 맞지 않으면 `null`. */ export function parseNavigationScreenPayload(payload: unknown): NavigationScreenPayload | null { const record = toRecord(payload); @@ -112,24 +83,14 @@ export function parseNavigationScreenPayload(payload: unknown): NavigationScreen return null; } - const { header, path } = record; - - if (!isPath(path)) { - return null; - } - - // 헤더를 빠뜨린 것과 "앱 헤더 없음"을 구분한다 — 없음은 `null`로 적어 보내야 한다. - if (header === null) { - return { header: null, path }; - } - - const parsedHeader = parseNavigationHeader(header); + const { button, path } = record; - if (parsedHeader === null) { + // 버튼을 빠뜨린 것과 "앱 버튼 없음"을 구분한다 — 없음은 `null`로 적어 보내야 한다. + if (!isPath(path) || (button !== null && !isNavigationButton(button))) { return null; } - return { header: parsedHeader, path }; + return { button, path }; } /** pop payload를 확인한다. 실어 오는 값은 없다. 형식이 맞지 않으면 `null`. */ diff --git a/src/features/webview/bridge/messages/safeAreaColors.ts b/src/features/webview/bridge/messages/safeAreaColors.ts index 9537fa1..4944fcc 100644 --- a/src/features/webview/bridge/messages/safeAreaColors.ts +++ b/src/features/webview/bridge/messages/safeAreaColors.ts @@ -30,7 +30,7 @@ export const DEFAULT_SAFE_AREA_COLORS: SafeAreaColors = { // 스트립이 검게 칠해지는 일이 없도록 표기를 확인하고 통과시킨다. const CSS_COLOR_PATTERN = /^#[0-9a-f]{3,8}$|^rgba?\([\d\s.,%/]+\)$/i; -export function isColor(value: unknown): value is string { +function isColor(value: unknown): value is string { return typeof value === "string" && CSS_COLOR_PATTERN.test(value); } From 6d3f5921b70dd258600ade4d2783b82743d408da Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:23:26 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=8A=A4=ED=83=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=97=A4=EB=8D=94=EC=97=90=20=EA=B2=B9=EC=B9=A0=20?= =?UTF-8?q?=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=C2=B7=EB=8B=AB=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 108 +++++++++++++++++- .../webview/components/HeaderButton.tsx | 65 +++++++++++ tailwind.config.js | 10 +- 4 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 src/features/webview/components/HeaderButton.tsx diff --git a/package.json b/package.json index 0e08f3f..811e784 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "react-native-webview": "13.16.1", "react-native-worklets": "0.10.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efb66cb..949611f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) react-native-css-interop: specifier: 0.2.6 - version: 0.2.6(abd0f6a6c4d6fd78c134ecc758b3b8be) + version: 0.2.6(1a2857450f162cb538215b429cec862c) react-native-gesture-handler: specifier: ~2.32.0 version: 2.32.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) @@ -56,6 +56,9 @@ importers: react-native-screens: specifier: ~4.26.0 version: 4.26.2(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) + react-native-svg: + specifier: 15.15.4 + version: 15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) react-native-web: specifier: ~0.21.0 version: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -74,7 +77,7 @@ importers: version: 19.2.18 nativewind: specifier: ^4.2.6 - version: 4.2.6(abd0f6a6c4d6fd78c134ecc758b3b8be) + version: 4.2.6(1a2857450f162cb538215b429cec862c) tailwindcss: specifier: ^3.4.19 version: 3.4.19(yaml@2.9.0) @@ -1251,6 +1254,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -1414,6 +1420,17 @@ packages: css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1487,6 +1504,19 @@ packages: dnssd-advertise@1.1.6: resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1504,6 +1534,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -2138,6 +2172,9 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -2314,6 +2351,9 @@ packages: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} @@ -2556,6 +2596,12 @@ packages: react: '*' react-native: '*' + react-native-svg@15.15.4: + resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==} + peerDependencies: + react: '*' + react-native: '*' + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -4591,6 +4637,8 @@ snapshots: binary-extensions@2.3.0: {} + boolbase@1.0.0: {} + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -4779,6 +4827,21 @@ snapshots: dependencies: hyphenate-style-name: 1.1.0 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -4825,6 +4888,24 @@ snapshots: dnssd-advertise@1.1.6: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + ee-first@1.1.1: {} electron-to-chromium@1.5.418: {} @@ -4835,6 +4916,8 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -5433,6 +5516,8 @@ snapshots: marky@1.3.0: {} + mdn-data@2.0.14: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: {} @@ -5657,11 +5742,11 @@ snapshots: nanoid@3.3.18: {} - nativewind@4.2.6(abd0f6a6c4d6fd78c134ecc758b3b8be): + nativewind@4.2.6(1a2857450f162cb538215b429cec862c): dependencies: comment-json: 4.6.2 debug: 4.4.3(supports-color@8.1.1) - react-native-css-interop: 0.2.6(abd0f6a6c4d6fd78c134ecc758b3b8be) + react-native-css-interop: 0.2.6(1a2857450f162cb538215b429cec862c) tailwindcss: 3.4.19(yaml@2.9.0) transitivePeerDependencies: - react @@ -5698,6 +5783,10 @@ snapshots: semver: 7.8.5 validate-npm-package-name: 5.0.1 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nullthrows@1.1.1: {} ob1@0.84.5: @@ -5864,7 +5953,7 @@ snapshots: react-is@19.2.8: {} - react-native-css-interop@0.2.6(abd0f6a6c4d6fd78c134ecc758b3b8be): + react-native-css-interop@0.2.6(1a2857450f162cb538215b429cec862c): dependencies: '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/traverse': 7.29.8(supports-color@8.1.1) @@ -5878,6 +5967,7 @@ snapshots: tailwindcss: 3.4.19(yaml@2.9.0) optionalDependencies: react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) transitivePeerDependencies: - supports-color @@ -5924,6 +6014,14 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) warn-once: 0.1.1 + react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) + warn-once: 0.1.1 + react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.29.7 diff --git a/src/features/webview/components/HeaderButton.tsx b/src/features/webview/components/HeaderButton.tsx new file mode 100644 index 0000000..74316ca --- /dev/null +++ b/src/features/webview/components/HeaderButton.tsx @@ -0,0 +1,65 @@ +import { cssInterop } from "nativewind"; +import { Pressable } from "react-native"; +import Svg, { Path } from "react-native-svg"; +import type { NavigationButton } from "@/features/webview/bridge/messages/navigation"; + +// 아이콘 path는 @wanteddev/wds-icon 3.12.0의 IconChevronLeft·IconClose를 그대로 옮겼다. +// WDS에는 React Native 패키지가 없어 컴포넌트를 가져다 쓸 수 없다. +// @wanteddev/wds-icon — MIT License, Copyright (c) 2026 Wanted Lab, Inc. +const ICON_PATHS: Record = { + back: "M16.1363 3.36297C16.4878 3.71444 16.4878 4.28429 16.1363 4.63576L8.77271 11.9994L16.1363 19.363C16.4878 19.7144 16.4878 20.2843 16.1363 20.6358C15.7848 20.9872 15.215 20.9872 14.8635 20.6358L6.86352 12.6358C6.51205 12.2843 6.51205 11.7144 6.86352 11.363L14.8635 3.36297C15.215 3.0115 15.7848 3.0115 16.1363 3.36297Z", + close: + "M4.86349 4.86346C5.21496 4.51199 5.78481 4.51199 6.13628 4.86346L11.9999 10.7271L17.8634 4.86346C18.2149 4.51199 18.7848 4.51199 19.1362 4.86346C19.4877 5.21493 19.4877 5.78478 19.1362 6.13625L13.2726 11.9999L19.1362 17.8635C19.4877 18.2149 19.4877 18.7848 19.1362 19.1363C18.7848 19.4877 18.2149 19.4877 17.8634 19.1363L11.9999 13.2727L6.13628 19.1363C5.78481 19.4877 5.21496 19.4877 4.86349 19.1363C4.51202 18.7848 4.51202 18.2149 4.86349 17.8635L10.7271 11.9999L4.86349 6.13625C4.51202 5.78478 4.51202 5.21493 4.86349 4.86346Z", +}; + +// 웹 헤더의 aria-label과 같게 둔다. +const ACCESSIBILITY_LABELS: Record = { + back: "뒤로가기", + close: "닫기", +}; + +// WDS 헤더는 ←를 왼쪽, X를 오른쪽에 둔다. +// 크기·위치는 웹과 픽셀 단위로 맞춰야 해서 px로 적는다. NativeWind는 네이티브에서 1rem을 14px로 +// 계산해(`inlineRem`) `left-2`·`h-10` 같은 간격 클래스가 웹의 8px·40px와 달라진다. +const POSITION_CLASS_NAMES: Record = { + back: "left-[8px]", + close: "right-[8px]", +}; + +// WDS 아이콘은 fill="currentColor"로 글자색을 따른다. 같은 방식으로 className의 글자색 토큰을 +// Svg의 color로 넘긴다(NativeWind가 ActivityIndicator의 color를 넘기는 것과 같은 매핑). +const IconSvg = cssInterop(Svg, { + className: { nativeStyleToProp: { color: true }, target: "style" }, +}); + +interface HeaderButtonProps { + button: NavigationButton; + onPress: () => void; +} + +/** + * 스택 화면에서 웹 헤더 위에 겹쳐 그리는 ←/X 버튼. + * + * 헤더 바·제목은 웹이 그리고 앱은 버튼만 그린다 — 웹뷰가 뜨기 전이나 오류 화면에서도 나갈 수 있게. + * 자리·크기·색은 웹 헤더(WDS 3.12.0 `TopNavigation` normal + `TopNavigationButton` icon)에 맞춘다. + * - 버튼은 헤더 왼쪽(←)·오른쪽(X) 16px, 위 16px에 놓이는 24px 아이콘이다 + * - 누르면 아이콘을 가운데 둔 40px 원에 Label/Normal이 9% 불투명도로 깔린다 + * 그래서 40px 버튼을 8px 안쪽에 두면 아이콘이 웹과 같은 자리에 오고, 터치 영역도 눌림 원과 같아진다. + * 웹 헤더 배치가 바뀌면 여기도 같이 맞춰야 한다. + * + * 부모는 웹뷰 영역(위 세이프에어리어 스트립 바로 아래)이고, 웹뷰보다 뒤에 렌더링해 위에 겹친다. + */ +export default function HeaderButton({ button, onPress }: HeaderButtonProps) { + return ( + + + + + + ); +} diff --git a/tailwind.config.js b/tailwind.config.js index 407a91f..6404706 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,7 +3,15 @@ module.exports = { content: ["./src/**/*.{js,jsx,ts,tsx}"], presets: [require("nativewind/preset")], theme: { - extend: {}, + extend: { + // WDS(원티드 디자인 시스템) 시맨틱 토큰 — @wanteddev/wds-theme 3.12.0 라이트 테마 값. + // 웹은 WDS 패키지를 그대로 쓰고, 앱은 네이티브로 그리는 데 필요한 것만 옮겨 둔다. + colors: { + label: { + normal: "#171719", + }, + }, + }, }, plugins: [], }; From 8ee67b330d5be8a7106c47b8a35ed003eee6ce1e Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:31:15 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=EC=9B=B9=EB=B7=B0=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EB=8F=99=EC=9E=91=EC=9D=84=20ShellWebView=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/webview/WebViewScreen.tsx | 92 +++------------- .../webview/components/ShellWebView.tsx | 101 ++++++++++++++++++ 2 files changed, 115 insertions(+), 78 deletions(-) create mode 100644 src/features/webview/components/ShellWebView.tsx diff --git a/src/features/webview/WebViewScreen.tsx b/src/features/webview/WebViewScreen.tsx index 971ee66..fd73552 100644 --- a/src/features/webview/WebViewScreen.tsx +++ b/src/features/webview/WebViewScreen.tsx @@ -1,25 +1,15 @@ -import * as WebBrowser from "expo-web-browser"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ActivityIndicator, BackHandler, Linking, Platform, StyleSheet, View } from "react-native"; +import { BackHandler, Platform, View } from "react-native"; 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 type { WebView, WebViewNavigation } from "react-native-webview"; 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 ShellWebView from "@/features/webview/components/ShellWebView"; import WebViewMessage from "@/features/webview/components/WebViewMessage"; -import { isSameOrigin } from "@/utils/url"; - -// WebView는 서드파티 컴포넌트라 NativeWind의 className이 적용되지 않는다. style로 채운다. -const styles = StyleSheet.create({ - webView: { flex: 1 }, -}); export default function WebViewScreen() { const insets = useSafeAreaInsets(); const webViewRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); const [canGoBack, setCanGoBack] = useState(false); const [safeAreaColors, setSafeAreaColors] = useState(DEFAULT_SAFE_AREA_COLORS); @@ -41,46 +31,15 @@ export default function WebViewScreen() { }, [canGoBack]); const handleRetry = useCallback(() => { - setHasError(false); - setIsLoading(true); // 다시 띄우는 웹이 색을 알려줄 때까지는 기본값으로 돌아간다 — 실패 직전 화면 색이 남으면 // 엉뚱한 화면 위에 그 색 스트립이 얹힌다. setSafeAreaColors(DEFAULT_SAFE_AREA_COLORS); - webViewRef.current?.reload(); }, []); const handleNavigationStateChange = useCallback((navigation: WebViewNavigation) => { setCanGoBack(navigation.canGoBack); }, []); - // 웹이 보내는 메시지는 브리지가 가려내고, 여기서는 type별로 무엇을 할지만 적는다. - const handleMessage = useCallback((event: WebViewMessageEvent) => { - dispatchBridgeMessage(event.nativeEvent.data, { - // 웹은 화면 배경이 바뀔 때마다 세이프에어리어 스트립 색을 보낸다. - safeAreaColors: setSafeAreaColors, - }); - }, []); - - // 서비스 바깥 주소는 웹뷰 안에서 열지 않고 시스템 브라우저·기본 앱으로 넘긴다. - const handleShouldStartLoad = useCallback((request: ShouldStartLoadRequest) => { - const { url } = request; - - if (url.startsWith("about:") || isSameOrigin(url, WEB_URL)) { - return true; - } - - const open = - url.startsWith("http://") || url.startsWith("https://") - ? WebBrowser.openBrowserAsync(url) - : Linking.openURL(url); - - open.catch((error) => { - console.warn(`외부 링크를 열지 못했습니다: ${url}`, error); - }); - - return false; - }, []); - if (!WEB_URL) { return ( - ); - } - return ( // 세이프에어리어는 네이티브가 담당하고 웹은 주어진 영역을 100%로 채우기만 한다. // 위아래 스트립은 맞닿는 웹 화면과 같은 색으로 칠해야 경계선이 보이지 않는데, @@ -107,30 +56,17 @@ export default function WebViewScreen() { // 인셋을 직접 재서 나눠 칠하고, 색은 웹이 알려준 값을 쓴다(safeAreaColors.ts 참고). - - setHasError(true)} - // 이미지·스크립트 같은 하위 리소스 실패까지 오류 화면으로 넘기지 않도록 본문 요청만 본다. - onHttpError={({ nativeEvent }) => { - if (nativeEvent.url === WEB_URL) { - setHasError(true); - } - }} - onLoadEnd={() => setIsLoading(false)} - onLoadStart={() => setIsLoading(true)} - onMessage={handleMessage} - onNavigationStateChange={handleNavigationStateChange} - onShouldStartLoadWithRequest={handleShouldStartLoad} - ref={webViewRef} - source={{ uri: WEB_URL }} - style={styles.webView} - /> - {isLoading && ( - - - - )} - + ); diff --git a/src/features/webview/components/ShellWebView.tsx b/src/features/webview/components/ShellWebView.tsx new file mode 100644 index 0000000..fac5e6c --- /dev/null +++ b/src/features/webview/components/ShellWebView.tsx @@ -0,0 +1,101 @@ +import * as WebBrowser from "expo-web-browser"; +import { type Ref, useCallback, useState } from "react"; +import { ActivityIndicator, Linking, StyleSheet, View } from "react-native"; +import { WebView, type WebViewNavigation } from "react-native-webview"; +import type { ShouldStartLoadRequest } from "react-native-webview/lib/WebViewTypes"; +import { WEB_URL } from "@/constants/config"; +import { type BridgeHandlers, dispatchBridgeMessage } from "@/features/webview/bridge/bridge"; +import WebViewMessage from "@/features/webview/components/WebViewMessage"; +import { isSameOrigin } from "@/utils/url"; + +// WebView는 서드파티 컴포넌트라 NativeWind의 className이 적용되지 않는다. style로 채운다. +const styles = StyleSheet.create({ + webView: { flex: 1 }, +}); + +interface ShellWebViewProps { + /** 띄울 주소. 서비스(`WEB_URL`) 안의 주소다. */ + url: string; + /** 웹이 보내는 브리지 메시지를 type별로 받는다. */ + handlers: BridgeHandlers; + onNavigationStateChange?: (navigation: WebViewNavigation) => void; + /** 오류 화면에서 다시 시도를 눌렀을 때. 화면이 웹에서 받아 둔 상태를 되돌릴 때 쓴다. */ + onRetry?: () => void; + ref?: Ref; +} + +/** 서비스 웹을 띄우는 웹뷰. 로딩 표시·오류 화면·외부 링크 처리·브리지 메시지 수신을 맡는다. */ +export default function ShellWebView({ + url, + handlers, + onNavigationStateChange, + onRetry, + ref, +}: ShellWebViewProps) { + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + // 오류 화면을 띄우는 동안 WebView는 내려가 있다. 오류를 지우면 새로 마운트되면서 다시 불러온다. + const handleRetry = useCallback(() => { + setHasError(false); + setIsLoading(true); + onRetry?.(); + }, [onRetry]); + + // 서비스 바깥 주소는 웹뷰 안에서 열지 않고 시스템 브라우저·기본 앱으로 넘긴다. + const handleShouldStartLoad = useCallback((request: ShouldStartLoadRequest) => { + const { url: requestUrl } = request; + + if (requestUrl.startsWith("about:") || isSameOrigin(requestUrl, WEB_URL)) { + return true; + } + + const open = + requestUrl.startsWith("http://") || requestUrl.startsWith("https://") + ? WebBrowser.openBrowserAsync(requestUrl) + : Linking.openURL(requestUrl); + + open.catch((error) => { + console.warn(`외부 링크를 열지 못했습니다: ${requestUrl}`, error); + }); + + return false; + }, []); + + if (hasError) { + return ( + + ); + } + + return ( + + setHasError(true)} + // 이미지·스크립트 같은 하위 리소스 실패까지 오류 화면으로 넘기지 않도록 본문 요청만 본다. + onHttpError={({ nativeEvent }) => { + if (nativeEvent.url === url) { + setHasError(true); + } + }} + onLoadEnd={() => setIsLoading(false)} + onLoadStart={() => setIsLoading(true)} + onMessage={(event) => dispatchBridgeMessage(event.nativeEvent.data, handlers)} + onNavigationStateChange={onNavigationStateChange} + onShouldStartLoadWithRequest={handleShouldStartLoad} + ref={ref} + source={{ uri: url }} + style={styles.webView} + /> + {isLoading && ( + + + + )} + + ); +} From fea3f24862b681e545d731d593f7fccb1ec0bd96 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:34:55 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EC=8A=A4=ED=83=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/stack.tsx | 3 + src/features/webview/StackWebViewScreen.tsx | 79 +++++++++++++++++++ .../webview/bridge/messages/navigation.ts | 4 +- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/app/stack.tsx create mode 100644 src/features/webview/StackWebViewScreen.tsx diff --git a/src/app/stack.tsx b/src/app/stack.tsx new file mode 100644 index 0000000..0472469 --- /dev/null +++ b/src/app/stack.tsx @@ -0,0 +1,3 @@ +import StackWebViewScreen from "@/features/webview/StackWebViewScreen"; + +export default StackWebViewScreen; diff --git a/src/features/webview/StackWebViewScreen.tsx b/src/features/webview/StackWebViewScreen.tsx new file mode 100644 index 0000000..545e26d --- /dev/null +++ b/src/features/webview/StackWebViewScreen.tsx @@ -0,0 +1,79 @@ +import { router, useLocalSearchParams } from "expo-router"; +import { useCallback, useState } from "react"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { WEB_URL } from "@/constants/config"; +import { + isNavigationButton, + isPath, + type NavigationButton, +} from "@/features/webview/bridge/messages/navigation"; +import { DEFAULT_SAFE_AREA_COLORS } from "@/features/webview/bridge/messages/safeAreaColors"; +import HeaderButton from "@/features/webview/components/HeaderButton"; +import ShellWebView from "@/features/webview/components/ShellWebView"; +import WebViewMessage from "@/features/webview/components/WebViewMessage"; +import { getOrigin } from "@/utils/url"; + +/** + * 스택에 쌓이는 웹 화면(상세·신청서·신청 결과). 웹이 `navigation.push`로 연다. + * + * 화면마다 웹뷰를 새로 띄워 네이티브 전환 애니메이션과 iOS 스와이프 뒤로가기를 얻는다. + * 헤더 바·제목은 웹이 그리고, 앱은 그 위에 ←/X 버튼만 겹쳐 그린다(HeaderButton 참고). + */ +export default function StackWebViewScreen() { + const insets = useSafeAreaInsets(); + const params = useLocalSearchParams<{ button?: string; path?: string }>(); + const [safeAreaColors, setSafeAreaColors] = useState(DEFAULT_SAFE_AREA_COLORS); + + // expo-router는 라우트를 앱 스킴 링크(streamclientapp://stack?path=...)로도 열어 준다. 웹이 보낸 + // 값만 들어온다고 볼 수 없어서 브리지 메시지와 같은 기준으로 다시 확인한다. 경로가 `/`로 시작해야 + // origin 뒤에 붙였을 때 `@host` 같은 값으로 다른 호스트를 가리키지 못한다. + const origin = getOrigin(WEB_URL); + const url = origin !== "" && isPath(params.path) ? `${origin}${params.path}` : null; + const button: NavigationButton | null = isNavigationButton(params.button) ? params.button : null; + + const handleButtonPress = useCallback((pressed: NavigationButton) => { + if (pressed === "back") { + router.back(); + return; + } + // X는 이 화면만이 아니라 스택을 전부 닫는다. 루트(탭 화면)는 있던 화면 그대로 보인다. + router.dismissAll(); + }, []); + + const handleRetry = useCallback(() => { + // 루트와 같은 이유로, 다시 띄우는 웹이 색을 알려줄 때까지는 기본값으로 돌아간다. + setSafeAreaColors(DEFAULT_SAFE_AREA_COLORS); + }, []); + + // 열 수 없는 주소면 안내만 띄우고, 빠져나갈 수 있게 ←는 항상 둔다. + const visibleButton = url === null ? "back" : button; + + return ( + // 세이프에어리어 스트립은 루트(WebViewScreen)와 같은 방식으로 칠한다. + + + + {url === null ? ( + + ) : ( + + )} + {/* 웹뷰보다 뒤에 둬야 위에 겹친다. 버튼이 없는 화면(null)은 웹이 직접 그린다. */} + {visibleButton !== null && ( + handleButtonPress(visibleButton)} /> + )} + + + + ); +} diff --git a/src/features/webview/bridge/messages/navigation.ts b/src/features/webview/bridge/messages/navigation.ts index 931a173..f3c9dc5 100644 --- a/src/features/webview/bridge/messages/navigation.ts +++ b/src/features/webview/bridge/messages/navigation.ts @@ -52,11 +52,11 @@ export interface NavigationBackGuardPayload { // 고른다. `//host`·`/\host`는 URL로 풀면 다른 호스트를 가리키게 되므로 경로로 치지 않는다. const PATH_PATTERN = /^\/(?![/\\])/; -function isPath(value: unknown): value is string { +export function isPath(value: unknown): value is string { return typeof value === "string" && PATH_PATTERN.test(value); } -function isNavigationButton(value: unknown): value is NavigationButton { +export function isNavigationButton(value: unknown): value is NavigationButton { return value === "back" || value === "close"; } From 55682caf609bacc1e2e6da1e1b4eb0262f97920e Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:42:19 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=9B=B9=EB=B7=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=98=A8=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/webview/StackWebViewScreen.tsx | 69 +++++++++++++++---- src/features/webview/WebViewScreen.tsx | 46 ++++++++----- src/features/webview/bridge/bridge.ts | 67 ++++++++++++++++-- .../webview/bridge/messages/navigation.ts | 11 +++ .../webview/components/ShellWebView.tsx | 11 ++- src/features/webview/stackNavigation.ts | 16 +++++ 6 files changed, 184 insertions(+), 36 deletions(-) create mode 100644 src/features/webview/stackNavigation.ts diff --git a/src/features/webview/StackWebViewScreen.tsx b/src/features/webview/StackWebViewScreen.tsx index 545e26d..00449fa 100644 --- a/src/features/webview/StackWebViewScreen.tsx +++ b/src/features/webview/StackWebViewScreen.tsx @@ -1,19 +1,27 @@ -import { router, useLocalSearchParams } from "expo-router"; +import { router, Stack, useFocusEffect, useLocalSearchParams, useNavigation } from "expo-router"; import { useCallback, useState } from "react"; -import { View } from "react-native"; +import { BackHandler, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { WEB_URL } from "@/constants/config"; +import { sendBridgeMessage } from "@/features/webview/bridge/bridge"; import { isNavigationButton, isPath, + NAVIGATION_NAVIGATE_MESSAGE_TYPE, type NavigationButton, } from "@/features/webview/bridge/messages/navigation"; import { DEFAULT_SAFE_AREA_COLORS } from "@/features/webview/bridge/messages/safeAreaColors"; import HeaderButton from "@/features/webview/components/HeaderButton"; import ShellWebView from "@/features/webview/components/ShellWebView"; import WebViewMessage from "@/features/webview/components/WebViewMessage"; +import { rootWebViewRef, toStackHref } from "@/features/webview/stackNavigation"; import { getOrigin } from "@/utils/url"; +// X는 이 화면만이 아니라 스택을 전부 닫는다. 루트(탭 화면)는 있던 화면 그대로 보인다. +function closeStack() { + router.dismissAll(); +} + /** * 스택에 쌓이는 웹 화면(상세·신청서·신청 결과). 웹이 `navigation.push`로 연다. * @@ -22,6 +30,7 @@ import { getOrigin } from "@/utils/url"; */ export default function StackWebViewScreen() { const insets = useSafeAreaInsets(); + const navigation = useNavigation(); const params = useLocalSearchParams<{ button?: string; path?: string }>(); const [safeAreaColors, setSafeAreaColors] = useState(DEFAULT_SAFE_AREA_COLORS); @@ -32,26 +41,47 @@ export default function StackWebViewScreen() { const url = origin !== "" && isPath(params.path) ? `${origin}${params.path}` : null; const button: NavigationButton | null = isNavigationButton(params.button) ? params.button : null; - const handleButtonPress = useCallback((pressed: NavigationButton) => { - if (pressed === "back") { - router.back(); - return; - } - // X는 이 화면만이 아니라 스택을 전부 닫는다. 루트(탭 화면)는 있던 화면 그대로 보인다. - router.dismissAll(); - }, []); + // 열 수 없는 주소면 안내만 띄우고, 빠져나갈 수 있게 ←는 항상 둔다. + const visibleButton = url === null ? "back" : button; + + // X만 있는 화면(신청 결과)은 나가는 길이 X와 웹의 하단 버튼뿐이다. 안드로이드 백 버튼은 X와 같이 + // 스택을 닫는다. 보일 때만 걸어서, 위에 다른 스택 화면이 있으면 그 화면이 백 버튼을 받는다. + useFocusEffect( + useCallback(() => { + if (Platform.OS !== "android" || visibleButton !== "close") { + return; + } + + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + closeStack(); + return true; + }); + + return () => subscription.remove(); + }, [visibleButton]), + ); + + const handleButtonPress = useCallback( + (pressed: NavigationButton) => { + if (pressed === "back") { + navigation.goBack(); + return; + } + closeStack(); + }, + [navigation], + ); const handleRetry = useCallback(() => { // 루트와 같은 이유로, 다시 띄우는 웹이 색을 알려줄 때까지는 기본값으로 돌아간다. setSafeAreaColors(DEFAULT_SAFE_AREA_COLORS); }, []); - // 열 수 없는 주소면 안내만 띄우고, 빠져나갈 수 있게 ←는 항상 둔다. - const visibleButton = url === null ? "back" : button; - return ( // 세이프에어리어 스트립은 루트(WebViewScreen)와 같은 방식으로 칠한다. + {/* X만 있는 화면은 스와이프 뒤로가기도 끈다. */} + {url === null ? ( @@ -62,9 +92,22 @@ export default function StackWebViewScreen() { ) : ( { + closeStack(); + if (path !== undefined) { + sendBridgeMessage(rootWebViewRef.current, NAVIGATION_NAVIGATE_MESSAGE_TYPE, { + path, + }); + } + }, + // 이 화면의 navigation으로 닫아야, 메시지를 늦게 보낸 아래 화면이 맨 위 화면을 닫지 않는다. + "navigation.pop": () => navigation.goBack(), + "navigation.push": (screen) => router.push(toStackHref(screen)), + "navigation.replace": (screen) => router.replace(toStackHref(screen)), safeAreaColors: setSafeAreaColors, }} onRetry={handleRetry} + shell={{ button, navigation: 1, screen: "stack" }} url={url} /> )} diff --git a/src/features/webview/WebViewScreen.tsx b/src/features/webview/WebViewScreen.tsx index fd73552..f5386b9 100644 --- a/src/features/webview/WebViewScreen.tsx +++ b/src/features/webview/WebViewScreen.tsx @@ -1,34 +1,43 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { router, useFocusEffect } from "expo-router"; +import { useCallback, useState } from "react"; import { BackHandler, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import type { WebView, WebViewNavigation } from "react-native-webview"; +import type { WebViewNavigation } from "react-native-webview"; import { WEB_URL } from "@/constants/config"; +import type { ShellInfo } from "@/features/webview/bridge/bridge"; import { DEFAULT_SAFE_AREA_COLORS } from "@/features/webview/bridge/messages/safeAreaColors"; import ShellWebView from "@/features/webview/components/ShellWebView"; import WebViewMessage from "@/features/webview/components/WebViewMessage"; +import { rootWebViewRef, toStackHref } from "@/features/webview/stackNavigation"; + +// 탭 화면 웹뷰에는 앱 버튼이 없다. 하단 탭으로 오가는 화면이라 뒤로가기를 두지 않는다. +const ROOT_SHELL: ShellInfo = { button: null, navigation: 1, screen: "root" }; export default function WebViewScreen() { const insets = useSafeAreaInsets(); - const webViewRef = useRef(null); const [canGoBack, setCanGoBack] = useState(false); const [safeAreaColors, setSafeAreaColors] = useState(DEFAULT_SAFE_AREA_COLORS); // 안드로이드 하드웨어 백 버튼은 기본적으로 앱을 종료한다. 웹 히스토리가 남아 있으면 뒤로 보낸다. - useEffect(() => { - if (Platform.OS !== "android") { - return; - } - - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - if (!canGoBack) { - return false; + // 루트가 보일 때만 건다 — 리스너는 나중에 등록된 것부터 불리는데, 루트 리스너가 스택 화면 위에서도 + // 살아 있으면 스택 화면을 닫지 않고 가려진 루트 웹뷰가 대신 뒤로 간다. + useFocusEffect( + useCallback(() => { + if (Platform.OS !== "android") { + return; } - webViewRef.current?.goBack(); - return true; - }); - return () => subscription.remove(); - }, [canGoBack]); + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + if (!canGoBack) { + return false; + } + rootWebViewRef.current?.goBack(); + return true; + }); + + return () => subscription.remove(); + }, [canGoBack]), + ); const handleRetry = useCallback(() => { // 다시 띄우는 웹이 색을 알려줄 때까지는 기본값으로 돌아간다 — 실패 직전 화면 색이 남으면 @@ -59,12 +68,15 @@ export default function WebViewScreen() { router.push(toStackHref(screen)), // 웹은 화면 배경이 바뀔 때마다 세이프에어리어 스트립 색을 보낸다. safeAreaColors: setSafeAreaColors, }} onNavigationStateChange={handleNavigationStateChange} onRetry={handleRetry} - ref={webViewRef} + ref={rootWebViewRef} + shell={ROOT_SHELL} url={WEB_URL} /> diff --git a/src/features/webview/bridge/bridge.ts b/src/features/webview/bridge/bridge.ts index 333c7ba..b716a5e 100644 --- a/src/features/webview/bridge/bridge.ts +++ b/src/features/webview/bridge/bridge.ts @@ -1,25 +1,40 @@ /** - * 웹 → 앱 postMessage 브리지. + * 웹 ↔ 앱 브리지. * - * 웹은 `window.ReactNativeWebView.postMessage(JSON.stringify({ type, payload }))`로 보낸다. - * `type`은 메시지 표식, `payload`는 메시지별 본문이다. 화면은 JSON 파싱·`type` 분기를 모르고 - * `type`별 핸들러만 넘긴다. + * 봉투는 양방향 모두 `{ type, payload }`다. `type`은 메시지 표식, `payload`는 메시지별 본문이다. + * 화면은 JSON 파싱·`type` 분기·주입 스크립트를 모르고, 받을 때는 `type`별 핸들러만 넘기고 + * 보낼 때는 `sendBridgeMessage`만 부른다. * - * 메시지 추가 절차: + * 웹 → 앱: 웹은 `window.ReactNativeWebView.postMessage(JSON.stringify({ type, payload }))`로 보낸다. + * 앱 → 웹: 앱은 웹뷰의 `window`에 `streamapp` CustomEvent를 쏘고 `detail`에 봉투를 싣는다. + * `webViewRef.postMessage`는 쓰지 않는다 — 이벤트가 iOS는 `window`, Android는 `document`에 걸려 + * 웹이 양쪽을 들어야 하고, 다른 출처의 `message` 이벤트와 섞인다. + * + * 메시지 추가 절차 (웹 → 앱): * 1. `messages/<이름>.ts`에 type 상수 · payload 인터페이스 · `(payload: unknown) => T | null` 파서 * type은 `영역.동작`으로 짓는다(예: `navigation.push`). `safeAreaColors`는 이 규칙 전에 생긴 * 이름이라 그대로 둔다 * 2. 아래 `parsers`에 한 줄 등록 — 나머지 타입은 여기서 파생된다 * 3. 화면의 `dispatchBridgeMessage` 핸들러에 항목 추가 * 4. stream-client-web에 같은 type 상수·같은 payload 필드로 송신부 작성 (PR을 서로 링크) + * + * 메시지 추가 절차 (앱 → 웹): + * 1. `messages/<이름>.ts`에 type 상수 · payload 인터페이스 + * 2. 아래 `BridgeOutgoingMessages`에 한 줄 등록 + * 3. stream-client-web에 같은 type·payload로 수신부 작성 (PR을 서로 링크) */ +import type { WebView } from "react-native-webview"; + import { NAVIGATION_BACK_GUARD_MESSAGE_TYPE, NAVIGATION_CLOSE_MESSAGE_TYPE, + NAVIGATION_NAVIGATE_MESSAGE_TYPE, NAVIGATION_POP_MESSAGE_TYPE, NAVIGATION_PUSH_MESSAGE_TYPE, NAVIGATION_REPLACE_MESSAGE_TYPE, + type NavigationButton, + type NavigationNavigatePayload, parseNavigationBackGuardPayload, parseNavigationClosePayload, parseNavigationPopPayload, @@ -87,3 +102,45 @@ export function dispatchBridgeMessage(data: string, handlers: BridgeHandlers): v const handler = handlers[type] as ((payload: unknown) => void) | undefined; handler?.(parsed); } + +// 앱 → 웹으로 보내는 메시지의 type ↔ payload 짝. 받는 쪽 검증은 웹이 하므로 여기서는 타입만 묶는다. +interface BridgeOutgoingMessages { + [NAVIGATION_NAVIGATE_MESSAGE_TYPE]: NavigationNavigatePayload; +} + +// 웹이 앱 메시지를 받는 CustomEvent 이름. 바꾸면 stream-client-web의 수신부도 같이 바꿔야 한다. +const APP_TO_WEB_EVENT = "streamapp"; + +/** 웹뷰에 메시지를 보낸다. 웹뷰가 없으면(오류 화면으로 내려가 있는 등) 아무것도 하지 않는다. */ +export function sendBridgeMessage( + webView: WebView | null, + type: K, + payload: BridgeOutgoingMessages[K], +): void { + // JSON은 그대로 JS 식이라 스크립트에 끼워 넣어도 따옴표·줄바꿈이 깨지지 않는다. 끝의 `true;`는 + // 주입 스크립트가 값을 남기지 않으면 iOS에서 조용히 실패하는 걸 막는 react-native-webview 관례다. + const detail = JSON.stringify({ payload, type }); + webView?.injectJavaScript( + `window.dispatchEvent(new CustomEvent(${JSON.stringify(APP_TO_WEB_EVENT)}, { detail: ${detail} })); true;`, + ); +} + +/** + * 웹에 알리는 셸 정보. 페이지가 뜰 때 `window.__STREAM_SHELL__`로 넣는다. + * 웹은 이걸 보고 ① 브리지 네비게이션을 써도 되는지(없으면 옛 앱이라 react-router로만 움직인다) + * ② 앱이 헤더 버튼을 그리는지(그리면 자기 헤더에서 그 버튼을 뺀다) 판단한다. + * 필드 이름은 stream-client-web과 맞춰야 한다. + */ +export interface ShellInfo { + /** 앱이 `navigation.*` 메시지를 처리한다는 표시. 값은 계약 버전이다. */ + navigation: 1; + /** 탭 화면 웹뷰(`root`)인지, 스택에 쌓인 웹뷰(`stack`)인지. */ + screen: "root" | "stack"; + /** 앱이 웹 헤더 위에 겹쳐 그린 버튼. 없으면 `null`. */ + button: NavigationButton | null; +} + +/** 셸 정보를 웹뷰에 넣는 스크립트. `injectedJavaScriptBeforeContentLoaded`에 넘긴다. */ +export function createShellScript(info: ShellInfo): string { + return `window.__STREAM_SHELL__ = ${JSON.stringify(info)}; true;`; +} diff --git a/src/features/webview/bridge/messages/navigation.ts b/src/features/webview/bridge/messages/navigation.ts index f3c9dc5..999f290 100644 --- a/src/features/webview/bridge/messages/navigation.ts +++ b/src/features/webview/bridge/messages/navigation.ts @@ -11,6 +11,8 @@ * 표식 문자열과 payload 필드 이름은 양쪽이 맞춰야 한다. */ +// 웹 → 앱 + /** 스택에 화면을 새로 쌓는다. */ export const NAVIGATION_PUSH_MESSAGE_TYPE = "navigation.push"; /** 지금 스택 화면을 바꿔 끼운다. 제출이 끝난 신청서처럼 돌아가면 안 되는 화면을 치울 때 쓴다. */ @@ -22,6 +24,11 @@ export const NAVIGATION_CLOSE_MESSAGE_TYPE = "navigation.close"; /** 뒤로가기를 막을지 알린다. 작성 중인 신청서처럼 나가기 전에 웹이 확인해야 하는 화면에서 켠다. */ export const NAVIGATION_BACK_GUARD_MESSAGE_TYPE = "navigation.backGuard"; +// 앱 → 웹 + +/** 루트 웹뷰를 이 경로로 보낸다. 스택을 닫으면서 루트를 다른 화면으로 보낼 때(`close`의 `path`) 쓴다. */ +export const NAVIGATION_NAVIGATE_MESSAGE_TYPE = "navigation.navigate"; + /** * 앱이 웹 헤더 위에 겹쳐 그리는 버튼. * `back`은 왼쪽 ←(이 화면만 닫기), `close`는 오른쪽 X(스택 전체 닫기). @@ -43,6 +50,10 @@ export interface NavigationClosePayload { path?: string; } +export interface NavigationNavigatePayload { + path: string; +} + export interface NavigationBackGuardPayload { /** 켜져 있으면 ←·스와이프·안드로이드 백을 막고, 나갈지는 웹에 물어본다. */ enabled: boolean; diff --git a/src/features/webview/components/ShellWebView.tsx b/src/features/webview/components/ShellWebView.tsx index fac5e6c..9016351 100644 --- a/src/features/webview/components/ShellWebView.tsx +++ b/src/features/webview/components/ShellWebView.tsx @@ -4,7 +4,12 @@ import { ActivityIndicator, Linking, StyleSheet, View } from "react-native"; import { WebView, type WebViewNavigation } from "react-native-webview"; import type { ShouldStartLoadRequest } from "react-native-webview/lib/WebViewTypes"; import { WEB_URL } from "@/constants/config"; -import { type BridgeHandlers, dispatchBridgeMessage } from "@/features/webview/bridge/bridge"; +import { + type BridgeHandlers, + createShellScript, + dispatchBridgeMessage, + type ShellInfo, +} from "@/features/webview/bridge/bridge"; import WebViewMessage from "@/features/webview/components/WebViewMessage"; import { isSameOrigin } from "@/utils/url"; @@ -18,6 +23,8 @@ interface ShellWebViewProps { url: string; /** 웹이 보내는 브리지 메시지를 type별로 받는다. */ handlers: BridgeHandlers; + /** 웹에 알리는 셸 정보. 페이지가 뜰 때마다 `window.__STREAM_SHELL__`로 넣는다. */ + shell: ShellInfo; onNavigationStateChange?: (navigation: WebViewNavigation) => void; /** 오류 화면에서 다시 시도를 눌렀을 때. 화면이 웹에서 받아 둔 상태를 되돌릴 때 쓴다. */ onRetry?: () => void; @@ -28,6 +35,7 @@ interface ShellWebViewProps { export default function ShellWebView({ url, handlers, + shell, onNavigationStateChange, onRetry, ref, @@ -75,6 +83,7 @@ export default function ShellWebView({ return ( setHasError(true)} // 이미지·스크립트 같은 하위 리소스 실패까지 오류 화면으로 넘기지 않도록 본문 요청만 본다. onHttpError={({ nativeEvent }) => { diff --git a/src/features/webview/stackNavigation.ts b/src/features/webview/stackNavigation.ts new file mode 100644 index 0000000..a84a50e --- /dev/null +++ b/src/features/webview/stackNavigation.ts @@ -0,0 +1,16 @@ +import type { Href } from "expo-router"; +import { createRef } from "react"; +import type { WebView } from "react-native-webview"; +import type { NavigationScreenPayload } from "@/features/webview/bridge/messages/navigation"; + +/** + * 루트(탭 화면) 웹뷰. 스택 화면이 닫히면서 루트를 다른 경로로 보낼 때(`navigation.close`의 + * `path`) 스택 화면이 이 웹뷰에 메시지를 보낸다. 루트 웹뷰는 하나뿐이라 모듈에 둔다 — React + * Navigation이 화면 밖에서 이동할 때 navigationRef를 모듈에 두는 것과 같은 방식이다. + */ +export const rootWebViewRef = createRef(); + +/** 스택 화면 라우트 주소. 앱 버튼이 없으면(`null`) 파라미터에서 뺀다. */ +export function toStackHref({ button, path }: NavigationScreenPayload): Href { + return { params: button === null ? { path } : { button, path }, pathname: "/stack" }; +} From b8bc8788803cee66d480a83ad347151b47471116 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:18:25 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EC=9E=91=EC=84=B1=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EB=A7=89=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/webview/StackWebViewScreen.tsx | 42 +++++++++++++++++-- src/features/webview/bridge/bridge.ts | 3 ++ .../webview/bridge/messages/navigation.ts | 4 ++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/features/webview/StackWebViewScreen.tsx b/src/features/webview/StackWebViewScreen.tsx index 00449fa..8585c86 100644 --- a/src/features/webview/StackWebViewScreen.tsx +++ b/src/features/webview/StackWebViewScreen.tsx @@ -1,12 +1,15 @@ import { router, Stack, useFocusEffect, useLocalSearchParams, useNavigation } from "expo-router"; -import { useCallback, useState } from "react"; +import { usePreventRemove } from "expo-router/react-navigation"; +import { useCallback, useRef, useState } from "react"; import { BackHandler, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { WebView } from "react-native-webview"; import { WEB_URL } from "@/constants/config"; import { sendBridgeMessage } from "@/features/webview/bridge/bridge"; import { isNavigationButton, isPath, + NAVIGATION_BACK_REQUESTED_MESSAGE_TYPE, NAVIGATION_NAVIGATE_MESSAGE_TYPE, type NavigationButton, } from "@/features/webview/bridge/messages/navigation"; @@ -32,7 +35,10 @@ export default function StackWebViewScreen() { const insets = useSafeAreaInsets(); const navigation = useNavigation(); const params = useLocalSearchParams<{ button?: string; path?: string }>(); + const webViewRef = useRef(null); const [safeAreaColors, setSafeAreaColors] = useState(DEFAULT_SAFE_AREA_COLORS); + const [isBackGuarded, setIsBackGuarded] = useState(false); + const allowWebNavigationRef = useRef(false); // expo-router는 라우트를 앱 스킴 링크(streamclientapp://stack?path=...)로도 열어 준다. 웹이 보낸 // 값만 들어온다고 볼 수 없어서 브리지 메시지와 같은 기준으로 다시 확인한다. 경로가 `/`로 시작해야 @@ -61,6 +67,29 @@ export default function StackWebViewScreen() { }, [visibleButton]), ); + // 작성 중인 신청서처럼 웹이 가드를 켠 화면은 ←·스와이프·안드로이드 백을 막고 나갈지 웹에 묻는다. + // native-stack이 iOS 스와이프까지 네이티브에서 막고, 셋 다 이 콜백으로 모인다. + usePreventRemove(isBackGuarded, ({ data }) => { + if (allowWebNavigationRef.current) { + allowWebNavigationRef.current = false; + // 막힌 동작에는 이 화면을 이미 확인했다는 표시가 붙어 있어, 다시 보내면 가드를 지나간다. + // 지금 처리 중인 이동이 끝난 뒤에 보낸다. + queueMicrotask(() => navigation.dispatch(data.action)); + return; + } + sendBridgeMessage(webViewRef.current, NAVIGATION_BACK_REQUESTED_MESSAGE_TYPE, {}); + }); + + // 웹이 스스로 보내는 이동(pop·replace·close)은 웹이 이미 판단한 것이라 가드를 건너뛴다. 가드는 + // 사용자가 앱 쪽 조작으로 나가는 것만 막는다 — 제출이 끝나 결과 화면으로 replace할 때처럼 작성 + // 내용이 남아 있어도 웹이 옮기는 건 막으면 안 된다. + // router.replace·dismissAll은 expo-router 큐에 쌓였다가 다음 렌더 뒤에 실행되지만, 셋 다 결국 이 + // 화면을 스택에서 빼는 이동이라 막히면 위 콜백이 플래그를 소비하고, 안 막히면 화면이 사라진다. + const navigateFromWeb = (navigate: () => void) => { + allowWebNavigationRef.current = isBackGuarded; + navigate(); + }; + const handleButtonPress = useCallback( (pressed: NavigationButton) => { if (pressed === "back") { @@ -75,6 +104,8 @@ export default function StackWebViewScreen() { const handleRetry = useCallback(() => { // 루트와 같은 이유로, 다시 띄우는 웹이 색을 알려줄 때까지는 기본값으로 돌아간다. setSafeAreaColors(DEFAULT_SAFE_AREA_COLORS); + // 다시 띄운 웹에는 작성하던 내용이 없다. 웹이 다시 켤 때까지 가드를 끈다. + setIsBackGuarded(false); }, []); return ( @@ -92,8 +123,9 @@ export default function StackWebViewScreen() { ) : ( setIsBackGuarded(enabled), "navigation.close": ({ path }) => { - closeStack(); + navigateFromWeb(closeStack); if (path !== undefined) { sendBridgeMessage(rootWebViewRef.current, NAVIGATION_NAVIGATE_MESSAGE_TYPE, { path, @@ -101,12 +133,14 @@ export default function StackWebViewScreen() { } }, // 이 화면의 navigation으로 닫아야, 메시지를 늦게 보낸 아래 화면이 맨 위 화면을 닫지 않는다. - "navigation.pop": () => navigation.goBack(), + "navigation.pop": () => navigateFromWeb(() => navigation.goBack()), "navigation.push": (screen) => router.push(toStackHref(screen)), - "navigation.replace": (screen) => router.replace(toStackHref(screen)), + "navigation.replace": (screen) => + navigateFromWeb(() => router.replace(toStackHref(screen))), safeAreaColors: setSafeAreaColors, }} onRetry={handleRetry} + ref={webViewRef} shell={{ button, navigation: 1, screen: "stack" }} url={url} /> diff --git a/src/features/webview/bridge/bridge.ts b/src/features/webview/bridge/bridge.ts index b716a5e..b3e7464 100644 --- a/src/features/webview/bridge/bridge.ts +++ b/src/features/webview/bridge/bridge.ts @@ -28,11 +28,13 @@ import type { WebView } from "react-native-webview"; import { NAVIGATION_BACK_GUARD_MESSAGE_TYPE, + NAVIGATION_BACK_REQUESTED_MESSAGE_TYPE, NAVIGATION_CLOSE_MESSAGE_TYPE, NAVIGATION_NAVIGATE_MESSAGE_TYPE, NAVIGATION_POP_MESSAGE_TYPE, NAVIGATION_PUSH_MESSAGE_TYPE, NAVIGATION_REPLACE_MESSAGE_TYPE, + type NavigationBackRequestedPayload, type NavigationButton, type NavigationNavigatePayload, parseNavigationBackGuardPayload, @@ -105,6 +107,7 @@ export function dispatchBridgeMessage(data: string, handlers: BridgeHandlers): v // 앱 → 웹으로 보내는 메시지의 type ↔ payload 짝. 받는 쪽 검증은 웹이 하므로 여기서는 타입만 묶는다. interface BridgeOutgoingMessages { + [NAVIGATION_BACK_REQUESTED_MESSAGE_TYPE]: NavigationBackRequestedPayload; [NAVIGATION_NAVIGATE_MESSAGE_TYPE]: NavigationNavigatePayload; } diff --git a/src/features/webview/bridge/messages/navigation.ts b/src/features/webview/bridge/messages/navigation.ts index 999f290..40783f9 100644 --- a/src/features/webview/bridge/messages/navigation.ts +++ b/src/features/webview/bridge/messages/navigation.ts @@ -28,6 +28,8 @@ export const NAVIGATION_BACK_GUARD_MESSAGE_TYPE = "navigation.backGuard"; /** 루트 웹뷰를 이 경로로 보낸다. 스택을 닫으면서 루트를 다른 화면으로 보낼 때(`close`의 `path`) 쓴다. */ export const NAVIGATION_NAVIGATE_MESSAGE_TYPE = "navigation.navigate"; +/** 가드가 켜진 화면에서 사용자가 ←·스와이프·안드로이드 백으로 나가려 했다. 웹이 나갈지 정해 `pop`을 보낸다. */ +export const NAVIGATION_BACK_REQUESTED_MESSAGE_TYPE = "navigation.backRequested"; /** * 앱이 웹 헤더 위에 겹쳐 그리는 버튼. @@ -50,6 +52,8 @@ export interface NavigationClosePayload { path?: string; } +export type NavigationBackRequestedPayload = Record; + export interface NavigationNavigatePayload { path: string; }