From f7169098986cbf10b9df9b2ca6288171eba662bc Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sat, 24 Jan 2026 13:42:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20WebView=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=95=B8=EB=93=A4=EB=9F=AC=20Hook=20=EB=B0=8F=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20+=20=EC=9B=B9?= =?UTF-8?q?=EB=B7=B0=EC=99=80=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20?= =?UTF-8?q?=EA=B0=84=20=ED=86=B5=EC=8B=A0=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=84=B1=20=EC=A6=9D=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/index.ts | 1 + hooks/use-webview-message-handler.ts | 49 ++++++++++++++++++++++++++++ types/webview-message.types.ts | 21 ++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 hooks/use-webview-message-handler.ts create mode 100644 types/webview-message.types.ts diff --git a/hooks/index.ts b/hooks/index.ts index af560c4..82191f5 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -3,4 +3,5 @@ export { useFcm } from './use-fcm'; export { useMixpanelTrack } from './use-mixpanel-track'; export { useThemeColor } from './use-theme-color'; export { useTrackScreenView } from './use-track-screen-view'; +export { useWebViewMessageHandler } from './use-webview-message-handler'; diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts new file mode 100644 index 0000000..10be86e --- /dev/null +++ b/hooks/use-webview-message-handler.ts @@ -0,0 +1,49 @@ +import { WebViewMessage, WebViewMessageEvent, WebViewMessageTypes } from '@/types/webview-message.types'; +import { useCallback } from 'react'; + +interface UseWebViewMessageHandlerOptions { + // 뒤로가기 요청 시 호출 + onNavigateBack?: () => void; + // 알림 구독 요청 시 호출 + onSubscribe?: (clubId: string, clubName?: string) => Promise | void; + // 알림 구독 해제 요청 시 호출 + onUnsubscribe?: (clubId: string) => Promise | void; +} + +// WebView 메시지를 처리하는 Hook +export const useWebViewMessageHandler = ({ + onNavigateBack, + onSubscribe, + onUnsubscribe, +}: UseWebViewMessageHandlerOptions) => { + const handleMessage = useCallback((event: WebViewMessageEvent) => { + try { + const data = event.nativeEvent.data; + if (!data) return; + + const message: WebViewMessage = JSON.parse(data); + + switch (message.type) { + case WebViewMessageTypes.NAVIGATE_BACK: + onNavigateBack?.(); + break; + case WebViewMessageTypes.NOTIFICATION_SUBSCRIBE: + if (message.payload?.clubId) { + onSubscribe?.(message.payload.clubId, message.payload.clubName); + } + break; + case WebViewMessageTypes.NOTIFICATION_UNSUBSCRIBE: + if (message.payload?.clubId) { + onUnsubscribe?.(message.payload.clubId); + } + break; + default: + console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); + } + } catch (error) { + console.error('[WebViewHandler] 메시지 파싱 오류:', error); + } + }, [onNavigateBack, onSubscribe, onUnsubscribe]); + + return { handleMessage }; +}; diff --git a/types/webview-message.types.ts b/types/webview-message.types.ts new file mode 100644 index 0000000..4343668 --- /dev/null +++ b/types/webview-message.types.ts @@ -0,0 +1,21 @@ + +//WebView 메시지 타입 상수 +export const WebViewMessageTypes = { + NAVIGATE_BACK: 'NAVIGATE_BACK', + NOTIFICATION_SUBSCRIBE: 'NOTIFICATION_SUBSCRIBE', + NOTIFICATION_UNSUBSCRIBE: 'NOTIFICATION_UNSUBSCRIBE', +} as const; + +// WebView 메시지 Discriminated Union 타입 + +export type WebViewMessage = + | { type: 'NAVIGATE_BACK' } + | { type: 'NOTIFICATION_SUBSCRIBE'; payload: { clubId: string; clubName?: string } } + | { type: 'NOTIFICATION_UNSUBSCRIBE'; payload: { clubId: string } }; + +// WebView 메시지 이벤트 타입 (react-native-webview) +export interface WebViewMessageEvent { + nativeEvent: { + data: string; + }; +} From dacef973d5174d1fe962ce7d0d3f0fb2f4520fbe Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sat, 24 Jan 2026 13:49:49 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(WebView=20=EC=97=B0=EB=8F=99=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=88=EC=A0=84=20=EC=98=81=EC=97=AD=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/club-detail/club-detail-screen.tsx | 84 +++++++++++++++++++++------ 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index fe06a9c..1a5fbe7 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -4,10 +4,11 @@ import { PermissionDialog } from '@/components/permission-dialog'; import { USER_EVENT } from '@/constants/eventname'; import { useMixpanelContext } from '@/contexts'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; -import { useMixpanelTrack } from '@/hooks'; +import { useMixpanelTrack, useWebViewMessageHandler } from '@/hooks'; import { Ionicons } from '@expo/vector-icons'; import Constants from 'expo-constants'; import { useLocalSearchParams, useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; import { useMemo, useState } from 'react'; import { ActivityIndicator, Platform, TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -18,6 +19,7 @@ export default function ClubWebViewScreen() { const router = useRouter(); const { id, name } = useLocalSearchParams<{ id?: string; name?: string }>(); const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); const [showPermissionDialog, setShowPermissionDialog] = useState(false); const { isSubscribed, toggleSubscribe } = useSubscribedClubsContext(); const { sessionId } = useMixpanelContext(); @@ -57,6 +59,11 @@ export default function ClubWebViewScreen() { }, 200); }; + const handleError = () => { + setHasError(true); + setIsLoading(false); + }; + const handleBack = () => { trackEvent(USER_EVENT.BACK_BUTTON_CLICKED, { from: 'club_detail', @@ -89,25 +96,62 @@ export default function ClubWebViewScreen() { } }; + // WebView 메시지 핸들러 + const { handleMessage } = useWebViewMessageHandler({ + onNavigateBack: handleBack, + onSubscribe: async (targetId, clubName) => { + trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { + clubName: clubName || name, + subscribed: true, + from: 'club_detail', + url: 'app://moadong/club', + }); + + // 이미 구독 중이면 무시 + if (isSubscribed(targetId)) return; + + const result = await toggleSubscribe(targetId); + if (result.needsPermission) { + setShowPermissionDialog(true); + } + }, + onUnsubscribe: async (targetId) => { + // 구독 중이 아니면 무시 + if (!isSubscribed(targetId)) return; + + trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { + clubName: name, + subscribed: false, + from: 'club_detail', + url: 'app://moadong/club', + }); + + await toggleSubscribe(targetId); + }, + }); + return ( - -
- - - - 동아리 상세 - - - -
+ + + {hasError && ( +
+ + + + 동아리 상세 + + + +
+ )} Date: Sat, 24 Jan 2026 13:54:23 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20WebView=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=B0=94=EC=9A=B4=EC=8A=A4=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20(=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=8B=9C?= =?UTF-8?q?=20=EC=83=81=EB=8B=A8=20=EC=98=81=EC=97=AD=20=EC=B9=A8=EB=B2=94?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/club-detail/club-detail-screen.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 1a5fbe7..27a7ccb 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -165,6 +165,8 @@ export default function ClubWebViewScreen() { scalesPageToFit={true} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} + bounces={false} + overScrollMode="never" /> {isLoading && ( From 4e615120645dabe716d1b028611881390af026c2 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sat, 24 Jan 2026 14:31:37 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20WebView=20URL=EC=97=90=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=95=8C=EB=A6=BC=20=EA=B5=AC=EB=8F=85=20=EC=83=81?= =?UTF-8?q?=ED=83=9C(is=5Fsubscribed)=20=EC=A0=84=EB=8B=AC=20(=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20UI=20=EB=8F=99=EA=B8=B0=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/club-detail/club-detail-screen.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 27a7ccb..4d20e58 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -35,11 +35,17 @@ export default function ClubWebViewScreen() { const cleanUrl = webviewUrl?.replace(/\/$/, '') || ''; const baseUrl = `${cleanUrl}/club/${id}`; + const params = new URLSearchParams(); if (sessionId) { - return `${baseUrl}?session_id=${encodeURIComponent(sessionId)}`; + params.append('session_id', sessionId); } - return baseUrl; - }, [id, webviewUrl, sessionId]); + if (id && isSubscribed(id)) { + params.append('is_subscribed', 'true'); + } + + const queryString = params.toString(); + return queryString ? `${baseUrl}?${queryString}` : baseUrl; + }, [id, webviewUrl, sessionId, isSubscribed]); const subscribed = useMemo(() => { return id ? isSubscribed(id) : false; @@ -167,6 +173,7 @@ export default function ClubWebViewScreen() { showsVerticalScrollIndicator={false} bounces={false} overScrollMode="never" + /> {isLoading && ( From 832ab185613b45e76b6c9c2a14bcfde624b1b821 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 25 Jan 2026 15:50:45 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=9B=B9=EB=B7=B0=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=ED=95=98=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=EB=8F=99?= =?UTF-8?q?=EC=95=84=EB=A6=AC=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=EB=A1=9C=20=EA=B3=B5=EC=9C=A0=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=A7=80=EC=9B=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹뷰에서 전달되는 SHARE 메시지를 처리하기 위해 타입과 훅을 확장하고, ClubWebViewScreen에 네이티브 공유 로직을 연결했습니다. 기존 구조를 유지하며 기능을 확장했습니다. --- hooks/use-webview-message-handler.ts | 10 +++++++++- types/webview-message.types.ts | 4 +++- ui/club-detail/club-detail-screen.tsx | 13 ++++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts index 10be86e..dbfa87a 100644 --- a/hooks/use-webview-message-handler.ts +++ b/hooks/use-webview-message-handler.ts @@ -8,6 +8,8 @@ interface UseWebViewMessageHandlerOptions { onSubscribe?: (clubId: string, clubName?: string) => Promise | void; // 알림 구독 해제 요청 시 호출 onUnsubscribe?: (clubId: string) => Promise | void; + // 공유하기 요청 시 호출 + onShare?: (payload: { title: string; text: string; url: string }) => Promise | void; } // WebView 메시지를 처리하는 Hook @@ -15,6 +17,7 @@ export const useWebViewMessageHandler = ({ onNavigateBack, onSubscribe, onUnsubscribe, + onShare, }: UseWebViewMessageHandlerOptions) => { const handleMessage = useCallback((event: WebViewMessageEvent) => { try { @@ -37,13 +40,18 @@ export const useWebViewMessageHandler = ({ onUnsubscribe?.(message.payload.clubId); } break; + case WebViewMessageTypes.SHARE: + if (message.payload) { + onShare?.(message.payload); + } + break; default: console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); } } catch (error) { console.error('[WebViewHandler] 메시지 파싱 오류:', error); } - }, [onNavigateBack, onSubscribe, onUnsubscribe]); + }, [onNavigateBack, onSubscribe, onUnsubscribe, onShare]); return { handleMessage }; }; diff --git a/types/webview-message.types.ts b/types/webview-message.types.ts index 4343668..6ffe7d9 100644 --- a/types/webview-message.types.ts +++ b/types/webview-message.types.ts @@ -4,6 +4,7 @@ export const WebViewMessageTypes = { NAVIGATE_BACK: 'NAVIGATE_BACK', NOTIFICATION_SUBSCRIBE: 'NOTIFICATION_SUBSCRIBE', NOTIFICATION_UNSUBSCRIBE: 'NOTIFICATION_UNSUBSCRIBE', + SHARE: 'SHARE', } as const; // WebView 메시지 Discriminated Union 타입 @@ -11,7 +12,8 @@ export const WebViewMessageTypes = { export type WebViewMessage = | { type: 'NAVIGATE_BACK' } | { type: 'NOTIFICATION_SUBSCRIBE'; payload: { clubId: string; clubName?: string } } - | { type: 'NOTIFICATION_UNSUBSCRIBE'; payload: { clubId: string } }; + | { type: 'NOTIFICATION_UNSUBSCRIBE'; payload: { clubId: string } } + | { type: 'SHARE'; payload: { title: string; text: string; url: string } }; // WebView 메시지 이벤트 타입 (react-native-webview) export interface WebViewMessageEvent { diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 4d20e58..35db2d0 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -10,7 +10,7 @@ import Constants from 'expo-constants'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { useMemo, useState } from 'react'; -import { ActivityIndicator, Platform, TouchableOpacity } from 'react-native'; +import { ActivityIndicator, Platform, Share, TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from 'react-native-webview'; import styled from 'styled-components/native'; @@ -105,7 +105,7 @@ export default function ClubWebViewScreen() { // WebView 메시지 핸들러 const { handleMessage } = useWebViewMessageHandler({ onNavigateBack: handleBack, - onSubscribe: async (targetId, clubName) => { + onSubscribe: async (targetId: string, clubName?: string) => { trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { clubName: clubName || name, subscribed: true, @@ -121,7 +121,7 @@ export default function ClubWebViewScreen() { setShowPermissionDialog(true); } }, - onUnsubscribe: async (targetId) => { + onUnsubscribe: async (targetId: string) => { // 구독 중이 아니면 무시 if (!isSubscribed(targetId)) return; @@ -134,6 +134,13 @@ export default function ClubWebViewScreen() { await toggleSubscribe(targetId); }, + onShare: async ({ title, text, url }: { title: string; text: string; url: string }) => { + await Share.share({ + title, + message: text, + url, + }); + }, }); return (