From a1dadea19eb3443627b5b299bbed51b831937b77 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:07:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=84=B8=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EC=97=90=EC=96=B4=EB=A6=AC=EC=96=B4=20=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BD=20=EC=83=89=EC=9D=84=20=EC=95=B1=20=EC=85=B8=EC=97=90?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱은 WebView 위아래에 인셋 높이만큼 스트립을 깔고 맞닿는 웹 화면과 같은 색으로 칠하는데, 그 색을 hex로 하드코딩하고 있었다. 모든 화면이 background-alternative 하나였던 #50 이전의 전제라, 지금은 흰 배경을 쓰는 화면 5개에서 노치 아래에 회색 띠가 보인다. 앱은 웹의 DOM을 볼 수 없으니 웹이 알려주는 수밖에 없다. ScreenLayout이 background·hasBottomNav가 바뀔 때마다 postMessage로 두 색을 보낸다. hex 상수가 아니라 getComputedStyle로 WDS 토큰의 현재 값을 읽어 보내서, 토큰이 바뀌거나 다크 테마가 붙어도 앱이 따라가게 했다. 브라우저로 열면 window.ReactNativeWebView가 없어 아무것도 하지 않는다. --- src/components/ui/ScreenLayout.tsx | 6 +- src/components/ui/useNativeSafeAreaColors.ts | 63 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/useNativeSafeAreaColors.ts diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index 0453b1e..24a6863 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -5,6 +5,7 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom"; import BottomNav, { type BottomNavValue } from "@/components/ui/BottomNav"; import { ScreenHeaderContext } from "@/components/ui/screenHeaderContext"; import { ScreenSheetPortalContext } from "@/components/ui/screenSheetPortalContext"; +import { useNativeSafeAreaColors } from "@/components/ui/useNativeSafeAreaColors"; // Bottom Nav 탭 ↔ 라우트 경로 매핑. 화면이 늘어나면 여기에 추가한다. const BOTTOM_NAV_PATHS: Record = { @@ -52,7 +53,8 @@ interface ScreenLayoutProps { // 같이 움직여야 하는 고정 px는 FeedbacksQaCard(캐러셀 카드)와 BililgeReturnConfirmModal // 둘뿐이다. 화면과 같은 배경 위에 서는 컬럼이라 그림자로 경계를 표시한다 // — App.tsx가 같은 브레이크포인트로 이 컬럼을 가운데 세운다. -// 세이프에어리어는 앱 셸이 담당하므로 여기서 env(safe-area-inset-*)를 더하지 않는다(중복 여백이 된다). +// 세이프에어리어 자리는 앱 셸이 담당하므로 여기서 env(safe-area-inset-*)를 더하지 않는다(중복 여백이 된다). +// 대신 그 자리를 무슨 색으로 칠할지는 이 화면만 알기 때문에 useNativeSafeAreaColors로 앱에 알려준다. // 배경도 같은 방식으로 라우트 handle에서 받는다 — 헤더 슬롯까지 이 루트 div가 덮기 때문에, // 화면이 헤더와 본문에 따로 배경을 깔 필요가 없다. function ScreenLayout({ @@ -67,6 +69,8 @@ function ScreenLayout({ const navigate = useNavigate(); const bottomNavValue = getBottomNavValueFromPath(location.pathname); + useNativeSafeAreaColors(background, hasBottomNav); + return ( diff --git a/src/components/ui/useNativeSafeAreaColors.ts b/src/components/ui/useNativeSafeAreaColors.ts new file mode 100644 index 0000000..21285af --- /dev/null +++ b/src/components/ui/useNativeSafeAreaColors.ts @@ -0,0 +1,63 @@ +import { useEffect } from "react"; + +import type { ScreenBackground } from "@/components/ui/ScreenLayout"; + +// stream-client-app(WebView 셸)이 주입하는 전역. 브라우저로 열면 없다. +declare global { + interface Window { + ReactNativeWebView?: { postMessage: (message: string) => void }; + } +} + +// 앱이 자기 메시지인지 가려내는 표식. 바꾸면 stream-client-app의 수신부도 같이 바꿔야 한다. +const SAFE_AREA_COLORS_MESSAGE_TYPE = "safeAreaColors"; + +// 화면 배경 ↔ WDS 시맨틱 토큰. index.css가 Tailwind 색으로 별칭 연결해 둔 그 변수들이다. +const BACKGROUND_CSS_VARIABLES: Record = { + alternative: "--semantic-background-normal-alternative", + normal: "--semantic-background-normal-normal", +}; + +function readCssVariable(name: string): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); +} + +// 앱은 WebView 위아래에 세이프에어리어 인셋 높이만큼 스트립을 깔고, 맞닿는 웹 화면과 같은 색으로 +// 칠해 경계선을 없앤다. 앱은 웹의 DOM을 볼 수 없으므로(별도 저장소·별도 배포, WebView 안은 +// 픽셀만 보인다) 웹이 자기 색을 알려주지 않으면 알 방법이 없다. +// +// hex를 앱에 복사해 두면 화면이 늘거나 토큰이 바뀔 때마다 두 저장소가 따로 어긋난다(#56) — +// 그래서 상수가 아니라 getComputedStyle로 지금 적용된 값을 읽어서 보낸다. 다크 테마가 붙어도 +// 같은 경로로 따라간다. +// +// 어느 색을 보낼지는 라우트 11개를 실측해서 나온 규칙 그대로다. +// top = 컬럼 배경 +// bottom = Bottom Nav가 있으면 Nav 배경(bg-background-normal), 없으면 컬럼 배경이 곧 바닥 +export function useNativeSafeAreaColors( + background: ScreenBackground, + hasBottomNav: boolean, +) { + useEffect(() => { + const bridge = window.ReactNativeWebView; + if (!bridge) { + return; + } + + const top = readCssVariable(BACKGROUND_CSS_VARIABLES[background]); + const bottom = hasBottomNav + ? readCssVariable(BACKGROUND_CSS_VARIABLES.normal) + : top; + + // 토큰을 못 읽었으면(WDS 로드 전 등) 보내지 않는다 — 앱이 자기 기본값을 유지하는 편이 + // 빈 문자열을 받아 검정으로 칠하는 것보다 낫다. + if (!top || !bottom) { + return; + } + + bridge.postMessage( + JSON.stringify({ bottom, top, type: SAFE_AREA_COLORS_MESSAGE_TYPE }), + ); + }, [background, hasBottomNav]); +} From 4615e23a8fd93159328b49bb4672d21e1ba85fb7 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:59:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=95=B1=20=EC=85=B8=20?= =?UTF-8?q?=EB=B8=8C=EB=A6=AC=EC=A7=80=EB=A5=BC=20{=20type,=20payload=20}?= =?UTF-8?q?=20=EB=B4=89=ED=88=AC=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/useNativeSafeAreaColors.ts | 19 ++------ src/lib/bridge/bridge.ts | 48 ++++++++++++++++++++ src/lib/bridge/messages/safeAreaColors.ts | 16 +++++++ 3 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 src/lib/bridge/bridge.ts create mode 100644 src/lib/bridge/messages/safeAreaColors.ts diff --git a/src/components/ui/useNativeSafeAreaColors.ts b/src/components/ui/useNativeSafeAreaColors.ts index 21285af..77004cf 100644 --- a/src/components/ui/useNativeSafeAreaColors.ts +++ b/src/components/ui/useNativeSafeAreaColors.ts @@ -1,16 +1,8 @@ import { useEffect } from "react"; import type { ScreenBackground } from "@/components/ui/ScreenLayout"; - -// stream-client-app(WebView 셸)이 주입하는 전역. 브라우저로 열면 없다. -declare global { - interface Window { - ReactNativeWebView?: { postMessage: (message: string) => void }; - } -} - -// 앱이 자기 메시지인지 가려내는 표식. 바꾸면 stream-client-app의 수신부도 같이 바꿔야 한다. -const SAFE_AREA_COLORS_MESSAGE_TYPE = "safeAreaColors"; +import { isInAppShell, postBridgeMessage } from "@/lib/bridge/bridge"; +import { SAFE_AREA_COLORS_MESSAGE_TYPE } from "@/lib/bridge/messages/safeAreaColors"; // 화면 배경 ↔ WDS 시맨틱 토큰. index.css가 Tailwind 색으로 별칭 연결해 둔 그 변수들이다. const BACKGROUND_CSS_VARIABLES: Record = { @@ -40,8 +32,7 @@ export function useNativeSafeAreaColors( hasBottomNav: boolean, ) { useEffect(() => { - const bridge = window.ReactNativeWebView; - if (!bridge) { + if (!isInAppShell()) { return; } @@ -56,8 +47,6 @@ export function useNativeSafeAreaColors( return; } - bridge.postMessage( - JSON.stringify({ bottom, top, type: SAFE_AREA_COLORS_MESSAGE_TYPE }), - ); + postBridgeMessage(SAFE_AREA_COLORS_MESSAGE_TYPE, { bottom, top }); }, [background, hasBottomNav]); } diff --git a/src/lib/bridge/bridge.ts b/src/lib/bridge/bridge.ts new file mode 100644 index 0000000..7d44d7e --- /dev/null +++ b/src/lib/bridge/bridge.ts @@ -0,0 +1,48 @@ +/** + * 웹 → 앱 postMessage 브리지. + * + * `window.ReactNativeWebView.postMessage(JSON.stringify({ type, payload }))`로 보낸다. + * `type`은 메시지 표식, `payload`는 메시지별 본문이다. 송신하는 쪽은 봉투 형식을 모르고 + * `postBridgeMessage(type, payload)`만 부른다. + * + * 수신부: stream-client-app의 `src/features/webview/bridge/bridge.ts`. + * + * 메시지 추가 절차: + * 1. `messages/<이름>.ts`에 type 상수 · payload 인터페이스 (앱과 같은 이름·같은 필드) + * 2. 아래 `BridgePayloads`에 한 줄 등록 — `postBridgeMessage`의 타입은 여기서 파생된다 + * 3. stream-client-app에 같은 type 상수로 파서·핸들러 작성 (PR을 서로 링크) + */ + +import { + SAFE_AREA_COLORS_MESSAGE_TYPE, + type SafeAreaColorsPayload, +} from "@/lib/bridge/messages/safeAreaColors"; + +// stream-client-app(WebView 셸)이 주입하는 전역. 브라우저로 열면 없다. +declare global { + interface Window { + ReactNativeWebView?: { postMessage: (message: string) => void }; + } +} + +interface BridgePayloads { + [SAFE_AREA_COLORS_MESSAGE_TYPE]: SafeAreaColorsPayload; +} + +type BridgeMessageType = keyof BridgePayloads; + +/** 앱 셸 안에서 열렸는지. 브라우저·개발 뷰에서는 `false`. */ +export function isInAppShell(): boolean { + return window.ReactNativeWebView !== undefined; +} + +/** + * 앱 셸에 메시지를 보낸다. 셸 밖(브라우저)이면 아무것도 하지 않는다. + * 단방향이라 앱이 받았는지는 알 수 없다 — 앱은 모르는 메시지·형식 불일치를 조용히 버린다. + */ +export function postBridgeMessage( + type: K, + payload: BridgePayloads[K], +): void { + window.ReactNativeWebView?.postMessage(JSON.stringify({ payload, type })); +} diff --git a/src/lib/bridge/messages/safeAreaColors.ts b/src/lib/bridge/messages/safeAreaColors.ts new file mode 100644 index 0000000..979d31f --- /dev/null +++ b/src/lib/bridge/messages/safeAreaColors.ts @@ -0,0 +1,16 @@ +/** + * 세이프에어리어 스트립 색 메시지. + * + * 앱은 WebView 위아래에 세이프에어리어 인셋 높이만큼 스트립을 깔고, 맞닿는 웹 화면과 같은 색으로 + * 칠해 경계선을 없앤다. 그 색은 화면마다 다르고 웹만 알기 때문에 웹이 보낸다. + * + * 수신부: stream-client-app의 `src/features/webview/bridge/messages/safeAreaColors.ts`. + * 표식 문자열과 payload 필드 이름은 양쪽이 맞춰야 한다. + */ + +export const SAFE_AREA_COLORS_MESSAGE_TYPE = "safeAreaColors"; + +export interface SafeAreaColorsPayload { + bottom: string; + top: string; +}