Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ffb53d0
fix: 상세 웹뷰 컨테이너가 SUBSCRIBE_TOGGLE을 처리하도록 한다
seongwon030 Sep 10, 2026
d93b500
fix: 쿼리 없는 상세 URL에 &를 붙여 clubId가 오염되던 것을 고친다
seongwon030 Sep 20, 2026
9e767a4
docs: CLAUDE.md를 현재 코드 구조에 맞춘다
seongwon030 Sep 20, 2026
3e256f2
fix: 상세 웹뷰 uri를 마운트 시점 구독 상태로 고정한다
seongwon030 Sep 20, 2026
8316cf1
fix: iOS에서 푸시 탭이 화면 이동을 못 하던 것을 고친다
seongwon030 Sep 22, 2026
ddddf1c
fix: 푸시로 열리는 웹뷰에도 학생 토큰을 주입한다
seongwon030 Sep 22, 2026
aaa7028
fix: 알림 payload의 라우팅 값이 문자열일 때만 쓴다
seongwon030 Sep 22, 2026
e537a34
fix: 외부 링크에 session_id를 붙이지 않고 Android에서도 열리게 한다
seongwon030 Sep 22, 2026
0042397
fix: 웹뷰 내부 URL 판정을 prefix 대신 origin 비교로 바꾼다
seongwon030 Sep 22, 2026
d386506
fix: 홈 웹뷰의 외부 링크를 OS 브라우저로 연다
seongwon030 Sep 29, 2026
e9d9989
fix: 웹뷰 목적지를 url 파라미터로 받지 않는다
seongwon030 Sep 29, 2026
c6494e7
docs: Mixpanel 신원 통일 결정 로그를 추가한다
seongwon030 Sep 30, 2026
ebad1f8
refactor: MixpanelProvider 의 도달 불가 폴백 분기를 제거한다
seongwon030 Sep 30, 2026
fb76065
docs: Mixpanel 신원 통일을 보류로 바꾼다 (로그인이 웹뷰 안)
seongwon030 Sep 30, 2026
1a8c84d
Merge pull request #37 from Moadong/docs/sync-claude-md
seongwon030 Sep 30, 2026
3fec28f
Merge pull request #43 from Moadong/docs/mixpanel-identity-decision
seongwon030 Sep 30, 2026
10f3887
Merge pull request #39 from Moadong/fix/ios-push-notification-data
seongwon030 Sep 30, 2026
8a9e42e
Merge pull request #40 from Moadong/fix/inject-student-token-slug-web…
seongwon030 Sep 30, 2026
7064509
Merge pull request #36 from Moadong/mixpanel-identity-split-audit
seongwon030 Sep 30, 2026
2bc4864
Merge pull request #34 from Moadong/fix/club-detail-subscribe-toggle
seongwon030 Sep 30, 2026
dadf13e
chore: 호스트가 처리하지 못한 웹뷰 메시지를 관측한다
seongwon030 Sep 17, 2026
a15b80e
Merge pull request #44 from Moadong/chore/bridge-unknown-message-repo…
seongwon030 Sep 30, 2026
865deb3
chore: bump app version to 1.7.2
seongwon030 Sep 30, 2026
bd696e9
Merge pull request #45 from Moadong/chore/bump-1.7.2
seongwon030 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 29 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

## 프로젝트 개요

**모아동 (Moadong)** — 대학교 동아리 탐색 및 알림 구독을 위한 React Native + Expo 앱. 사용자는 동아리를 탐색하고 푸시 알림을 구독하며, WebView를 통해 동아리 상세 페이지를 볼 수 있습니다.
**모아동 (Moadong)** — 대학교 동아리 탐색 및 알림 구독을 위한 React Native + Expo 앱.

홈과 동아리 상세를 포함한 주요 화면은 웹(`EXPO_PUBLIC_WEBVIEW_URL`)을 WebView로 감싼 것이고, 네이티브는 부트스트랩·푸시·구독 상태·딥링크를 담당하는 셸입니다. 네이티브 화면은 웹뷰 로드 실패 시의 폴백으로 남아 있습니다.

- **Bundle ID**: `com.moadong.moadong`
- **딥링크 스킴**: `moadongapp://`, associated domain: `www.moadong.com`
- **React Native New Architecture** 활성화 (`newArchEnabled: true`)
- **React Compiler** (실험적 기능) 활성화
- **React Compiler** / **typed routes** (실험적 기능) 활성화

## 명령어

Expand All @@ -21,33 +23,37 @@ npm run lint # ESLint 실행 (expo lint)
npx expo start --dev-client # 개발 클라이언트 빌드로 시작
```

환경 변수: API 기본 URL은 `.env`에 `EXPO_PUBLIC_BASE_URL`로 설정합니다.
환경 변수 (`.env`):
- `EXPO_PUBLIC_BASE_URL` — API 서버
- `EXPO_PUBLIC_WEBVIEW_URL` — 웹뷰가 로드할 웹 오리진 (기본값 `https://moadong.com`)
- `EXPO_PUBLIC_MIXPANEL_TOKEN` — 없으면 Mixpanel 비활성화

## 아키텍처

### 라우팅 (Expo Router 파일 기반)
```
app/
_layout.tsx # 루트 레이아웃: 부트스트랩, 스플래시, 강제 업데이트, Context 프로바이더
(tabs)/ # 하단 탭 네비게이터
index.tsx # 홈 탭
more.tsx # 더보기 탭
club/[id].tsx # 동아리 상세 (WebView)
clubDetail/[id].tsx # 동아리 상세 (네이티브)
webview/[slug].tsx # 범용 WebView 화면
_layout.tsx # 루트 레이아웃: 강제 업데이트 체크 → 부트스트랩 → 스플래시, Context 프로바이더
index.tsx # 홈. HomeWebViewScreen 우선, 로드 실패 시 ui/home/home-screen 네이티브 폴백
club/[id].tsx # 동아리 상세 (WebView). ui/club-detail/club-detail-screen 재export
clubDetail/[id].tsx # 위와 동일한 ClubDetailScreen 재export. FCM 딥링크가 이 경로로 들어옴
webview/[slug].tsx # 범용 WebView. pageConfig / path / url 파라미터로 목적지 결정
modal.tsx # 모달 화면
```

하단 탭 네비게이터(`(tabs)/`)는 없습니다. 홈이 웹뷰로 전환되면서 탭 UI가 웹으로 넘어갔습니다.

### 부트스트랩 순서 (app/_layout.tsx)
앱 시작 시 루트 레이아웃이 다음 순서로 실행됩니다:
1. Firebase Remote Config를 통한 강제 업데이트 체크
2. iOS ATT (앱 추적 투명성) 권한 요청
3. 액세스 토큰 조회/생성 (`auth-token-storage`)
4. FCM 토큰 등록
5. 서버에서 구독 동아리 목록 동기화
6. Mixpanel 애널리틱스 초기화
실제 실행 순서 (`services/app-bootstrap.service.ts`):
1. Firebase Remote Config로 강제 업데이트 체크 (`force-update.service.ts`). 업데이트가 필요하면 여기서 멈춥니다
2. 액세스 토큰 조회/발급 (`ensureAccessToken`). 이후 단계의 선행 조건이라 순차 실행입니다
3. 아래 셋을 **병렬**로 실행합니다 (`Promise.all`) — 서로 순서가 없습니다
- 서버에서 구독 동아리 목록 동기화
- Mixpanel identify (`user:<JWT sub>`)
- FCM 토큰 등록
4. 부트스트랩 성공 **이후, 스플래시가 내려간 뒤에** iOS ATT(앱 추적 투명성) 권한 요청

부트스트랩이 완료될 때까지 커스텀 스플래시 화면이 UI를 차단합니다.
부트스트랩이 완료될 때까지 커스텀 스플래시 화면이 UI를 차단합니다. 실패하면 재시도 다이얼로그(`BootstrapErrorDialog`)가 뜹니다.

### API 레이어 (services/api.ts)
두 가지 Axios 클라이언트 인스턴스:
Expand All @@ -59,11 +65,12 @@ app/
### 상태 관리
Redux/Zustand 미사용. React Context 사용:
- `SubscribedClubsProvider` (`contexts/subscribed-clubs-context.tsx`) — 구독 동아리 ID 목록, 구독 토글, 서버 동기화
- `MixpanelProvider` (`contexts/mixpanel-context.tsx`) — 애널리틱스
- `MixpanelProvider` (`contexts/mixpanel-context.tsx`) — 애널리틱스 세션 ID. 부트스트랩 결과를 props로 받습니다
- `HomeWebViewPreloadProvider` (`contexts/home-webview-preload-context.tsx`) — 홈 웹뷰 프리로드 상태. 스플래시 종료 시점을 결정합니다

### UI 레이어 패턴 (`ui/`)
`ui/` 하위 기능별 폴더 구조:
- `hook/` — 데이터 페칭 훅 (예: `useClubs`, `useSubscribedClubs`)
`ui/` 하위 기능별 폴더 구조. 현재 `ui/home`과 `ui/club-detail` 둘입니다:
- `hook/` — 데이터 페칭 훅 (`use-clubs`, `use-subscribed-clubs`). 현재 `ui/home`에만 있습니다
- `model/` — 파생 상태 / 데이터 변환
- `components/` — 기능별 컴포넌트
- `index.ts` — barrel export
Expand All @@ -75,7 +82,7 @@ Redux/Zustand 미사용. React Context 사용:
- `Spacing` — 4px 기준 스케일: `xs`(4) `sm`(8) `md`(16) `lg`(24) `xl`(32) `xxl`(40) `xxxl`(48)
- `BorderRadius` — `xs`(4) `sm`(8) `md`(12) `lg`(16) `xl`(20) `full`(9999)

폰트: **Pretendard** (Regular/Medium/SemiBold/Bold). React Native의 `Text` 대신 `@/components/moa-text`의 `<Text type="...">` 사용.
폰트: **Pretendard** (Regular/Medium/SemiBold/Bold). React Native의 `Text` 대신 `@/components/moa-text`의 `<MoaText type="...">` 사용.

타이포그래피 변형: `heading1-3`, `title1-3`, `body1SemiBold`, `body1Medium`, `body1Regular`, `body2Regular`, `caption1SemiBold`, `caption1Medium`.

Expand Down
6 changes: 3 additions & 3 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
"expo": {
"name": "모아동",
"slug": "moadong-app",
"version": "1.7.1",
"version": "1.7.2",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "moadongapp",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": false,
"buildNumber": "18",
"buildNumber": "19",
"googleServicesFile": "./GoogleService-Info.plist",
"bundleIdentifier": "com.moadong.moadong",
"associatedDomains": [
Expand All @@ -26,7 +26,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 18,
"versionCode": 19,
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
Expand Down
121 changes: 85 additions & 36 deletions app/webview/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
import { MoaText } from "@/components/moa-text";
import { useMixpanelContext } from "@/contexts/mixpanel-context";
import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler";
import { appendSessionId, getWebViewUserAgent } from "@/utils/webview";
import { ensureAccessToken } from "@/services/auth-token.service";
import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin } from "@/utils/webview";
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { WebView } from "react-native-webview";
import styled from "styled-components/native";

Check warning on line 17 in app/webview/[slug].tsx

View workflow job for this annotation

GitHub Actions / android-check

Using exported name 'styled' as identifier for default import

Check warning on line 17 in app/webview/[slug].tsx

View workflow job for this annotation

GitHub Actions / android-check

Using exported name 'styled' as identifier for default import

const webviewUrl =
process.env.EXPO_PUBLIC_WEBVIEW_URL || "https://develop.moadong.com";
Expand Down Expand Up @@ -49,13 +50,11 @@
const {
slug,
path,
url: urlParam,
title,
hideHeader,
} = useLocalSearchParams<{
slug?: string;
path?: string;
url?: string;
title?: string;
hideHeader?: string;
}>();
Expand All @@ -67,15 +66,61 @@

const config = pageConfig[slug || ""];

const baseUrl = urlParam
? String(urlParam)
: path
? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}`
: config
? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : ""))
: "";
/**
* 목적지는 path 또는 pageConfig 로만 정한다. 예전에는 url 파라미터를 그대로 받았는데,
* 그러면 moadongapp://webview/x?url=... 딥링크로 임의 사이트를 이 화면에 띄울 수 있다.
* 이 화면은 onMessage 가 붙어 있어 그 페이지가 앱 브리지를 그대로 쓴다.
*/
const baseUrl = path
? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}`
: config
? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : ""))
: "";

const url = useMemo(() => appendSessionId(baseUrl, sessionId), [baseUrl, sessionId]);
/**
* session_id 는 웹 Mixpanel 의 distinct_id 라 모아동 밖으로 나가면 안 된다.
* 이 화면은 slug=external 로 임의 외부 URL 도 로드하므로 오리진을 확인하고 붙인다.
*/
const url = useMemo(
() => (isWebViewOrigin(baseUrl) ? appendSessionId(baseUrl, sessionId) : baseUrl),
[baseUrl, sessionId],
);

/**
* 우체통은 앱이 주입한 학생 토큰을 먼저 쓴다(웹 studentFetch). 주입이 없으면 웹이
* 자체 토큰을 발급해 앱과 신원이 갈리고, 답장 푸시로 열린 편지함이 비어 보인다.
* 답장 푸시의 path가 /feedback/letters/... 라 이 화면으로 들어온다.
*
* 주입은 content load 이전에 끝나야 하므로 토큰이 정해질 때까지 웹뷰를 렌더하지 않는다.
* 외부 URL로 진입한 경우에는 주입할 일이 없으니 기다리지도 않는다.
*/
const [studentToken, setStudentToken] = useState<string | null>(null);
const [tokenResolved, setTokenResolved] = useState(false);

useEffect(() => {
if (!isWebViewOrigin(baseUrl)) {
setTokenResolved(true);
return;
}

let cancelled = false;
ensureAccessToken()
.then((token) => {
if (!cancelled) setStudentToken(token);
})
.catch(() => {
if (!cancelled) setStudentToken(null);
})
.finally(() => {
if (!cancelled) setTokenResolved(true);
});

return () => {
cancelled = true;
};
}, [baseUrl]);

const injectedToken = buildStudentTokenInjection(baseUrl, studentToken);

const userAgent = getWebViewUserAgent();

Expand All @@ -98,11 +143,12 @@
};

const { handleMessage } = useWebViewMessageHandler({
host: 'webview_slug',
onNavigateBack: handleBack,
onNavigateWebview: handleNavigateWebview,
});

if (!config && !path && !urlParam) {
if (!config && !path) {
return (
<Container edges={["top", "bottom"]}>
<Header>
Expand Down Expand Up @@ -150,29 +196,32 @@
</LoadingContainer>
)}

<StyledWebView
source={{ uri: url }}
userAgent={userAgent}
onMessage={handleMessage}
onLoadStart={() => {
if (!hasLoadedOnce) {
setLoading(true);
setError(false);
}
}}
onLoadEnd={() => {
setLoading(false);
if (!hasLoadedOnce) {
setHasLoadedOnce(true);
}
}}
onError={() => {
setError(true);
setLoading(false);
}}
javaScriptEnabled={true}
domStorageEnabled={true}
/>
{tokenResolved && (
<StyledWebView
source={{ uri: url }}
userAgent={userAgent}
injectedJavaScriptBeforeContentLoaded={injectedToken}
onMessage={handleMessage}
onLoadStart={() => {
if (!hasLoadedOnce) {
setLoading(true);
setError(false);
}
}}
onLoadEnd={() => {
setLoading(false);
if (!hasLoadedOnce) {
setHasLoadedOnce(true);
}
}}
onError={() => {
setError(true);
setLoading(false);
}}
javaScriptEnabled={true}
domStorageEnabled={true}
/>
)}
</WebViewWrapper>

</Container>
Expand Down
7 changes: 6 additions & 1 deletion constants/eventname.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ export const PAGE_VIEW_EVENT = {
MAIN_PAGE: 'MainPage',
SUBSCRIBE_PAGE: 'SubscribePage',
MORE_PAGE: 'MorePage',
}
}

// 앱 자체 진단용 — 사용자 행동이 아니라 웹↔앱 브리지 상태를 관측한다.
export const DIAGNOSTIC_EVENT = {
BRIDGE_UNKNOWN_MESSAGE: 'Bridge UnknownMessage',
}
71 changes: 15 additions & 56 deletions contexts/mixpanel-context.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { getJwtSubject, getStoredAccessToken } from '@/services/auth-token-storage';
import { getOrCreateMixpanelSessionId, identifyMixpanel } from '@/utils/mixpanel';
import React, { createContext, useContext, useEffect, useState } from 'react';
import React, { createContext, useContext } from 'react';

interface MixpanelContextType {
sessionId: string;
Expand All @@ -20,62 +18,23 @@ export const useMixpanelContext = () => {
interface MixpanelProviderProps {
children: React.ReactNode;
initialSessionId?: string;
initialReady?: boolean;
}

async function getMixpanelDistinctId(sessionId: string): Promise<string> {
const accessToken = await getStoredAccessToken();
if (accessToken) {
const subject = getJwtSubject(accessToken);
if (subject) {
return `user:${subject}`;
}
}

return sessionId;
/**
* 부트스트랩 성공 여부. 필수로 둔다 - optional 이던 시절에는 넘기지 않는 경우를 위한
* 폴백 분기가 있었고, 그 분기가 프로바이더 안에서 따로 identify 를 했다. 실제로는
* 마운트 지점(app/_layout.tsx)이 항상 넘겨서 도달하지 않는 코드였는데, 신원 결정
* 로직이 두 곳에 있는 것처럼 읽혔다. 신원은 부트스트랩에서만 정한다.
*/
initialReady: boolean;
}

export const MixpanelProvider: React.FC<MixpanelProviderProps> = ({
children,
initialSessionId,
initialReady,
}) => {
const usesBootstrapState = initialReady !== undefined;
const [sessionId, setSessionId] = useState<string>(initialSessionId ?? '');
const [isLoading, setIsLoading] = useState<boolean>(
usesBootstrapState ? !initialReady : true,
);

useEffect(() => {
if (usesBootstrapState) {
setSessionId(initialSessionId ?? '');
setIsLoading(!initialReady);
return;
}

const initializeMixpanel = async () => {
try {
const id = await getOrCreateMixpanelSessionId();
setSessionId(id);

const distinctId = await getMixpanelDistinctId(id);
const identified = await identifyMixpanel(distinctId);
if (identified) {
console.log('[MixpanelProvider] Mixpanel identified with:', distinctId);
}
} catch (error) {
console.error('[MixpanelProvider] 초기화 실패:', error);
} finally {
setIsLoading(false);
}
};

initializeMixpanel();
}, [initialReady, initialSessionId, usesBootstrapState]);

return (
<MixpanelContext.Provider value={{ sessionId, isLoading }}>
{children}
</MixpanelContext.Provider>
);
};
}) => (
<MixpanelContext.Provider
value={{ sessionId: initialSessionId ?? '', isLoading: !initialReady }}
>
{children}
</MixpanelContext.Provider>
);
Loading
Loading