-
Notifications
You must be signed in to change notification settings - Fork 0
[feature] 상세 페이지 UI 웹 이관을 위한 WebView 통신 핸들러 구현 및 동적 Safety Area 적용 #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f716909
dacef97
19dc4b9
4e61512
832ab18
d7714af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { WebViewMessage, WebViewMessageEvent, WebViewMessageTypes } from '@/types/webview-message.types'; | ||
| import { useCallback } from 'react'; | ||
|
|
||
| interface UseWebViewMessageHandlerOptions { | ||
| // 뒤로가기 요청 시 호출 | ||
| onNavigateBack?: () => void; | ||
| // 알림 구독 요청 시 호출 | ||
| onSubscribe?: (clubId: string, clubName?: string) => Promise<void> | void; | ||
| // 알림 구독 해제 요청 시 호출 | ||
| onUnsubscribe?: (clubId: string) => Promise<void> | void; | ||
| // 공유하기 요청 시 호출 | ||
| onShare?: (payload: { title: string; text: string; url: string }) => Promise<void> | void; | ||
| } | ||
|
|
||
| // WebView 메시지를 처리하는 Hook | ||
| export const useWebViewMessageHandler = ({ | ||
| onNavigateBack, | ||
| onSubscribe, | ||
| onUnsubscribe, | ||
| onShare, | ||
| }: 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; | ||
| case WebViewMessageTypes.SHARE: | ||
| if (message.payload) { | ||
| onShare?.(message.payload); | ||
| } | ||
| break; | ||
| default: | ||
| console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); | ||
| } | ||
| } catch (error) { | ||
| console.error('[WebViewHandler] 메시지 파싱 오류:', error); | ||
| } | ||
| }, [onNavigateBack, onSubscribe, onUnsubscribe, onShare]); | ||
|
|
||
| return { handleMessage }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
|
|
||
| //WebView 메시지 타입 상수 | ||
| export const WebViewMessageTypes = { | ||
| NAVIGATE_BACK: 'NAVIGATE_BACK', | ||
| NOTIFICATION_SUBSCRIBE: 'NOTIFICATION_SUBSCRIBE', | ||
| NOTIFICATION_UNSUBSCRIBE: 'NOTIFICATION_UNSUBSCRIBE', | ||
| SHARE: 'SHARE', | ||
| } 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 } } | ||
| | { type: 'SHARE'; payload: { title: string; text: string; url: string } }; | ||
|
|
||
| // WebView 메시지 이벤트 타입 (react-native-webview) | ||
| export interface WebViewMessageEvent { | ||
| nativeEvent: { | ||
| data: string; | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,25 @@ | ||
| import { MoaImage } from "@/components/moa-image"; | ||
| import { MoaText } from "@/components/moa-text"; | ||
| 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 { Ionicons } from "@expo/vector-icons"; | ||
| import Constants from "expo-constants"; | ||
| import { useLocalSearchParams, useRouter } from "expo-router"; | ||
| import { useMemo, useState } from "react"; | ||
| import { | ||
| ActivityIndicator, | ||
| Platform, | ||
| Share, | ||
| TouchableOpacity, | ||
| } from "react-native"; | ||
| import { SafeAreaView } from "react-native-safe-area-context"; | ||
| import { WebView, WebViewMessageEvent } from "react-native-webview"; | ||
| import styled from "styled-components/native"; | ||
| import { MoaImage } from '@/components/moa-image'; | ||
| import { MoaText } from '@/components/moa-text'; | ||
| 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, 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, 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'; | ||
|
|
||
| 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); | ||
oesnuj marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const [showPermissionDialog, setShowPermissionDialog] = useState(false); | ||
| const { isSubscribed, toggleSubscribe } = useSubscribedClubsContext(); | ||
| const { sessionId } = useMixpanelContext(); | ||
|
|
@@ -37,12 +34,18 @@ 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); | ||
| } | ||
| if (id && isSubscribed(id)) { | ||
| params.append('is_subscribed', 'true'); | ||
| } | ||
| return baseUrl; | ||
| }, [id, webviewUrl, sessionId]); | ||
|
|
||
| const queryString = params.toString(); | ||
| return queryString ? `${baseUrl}?${queryString}` : baseUrl; | ||
| }, [id, webviewUrl, sessionId, isSubscribed]); | ||
|
|
||
| const subscribed = useMemo(() => { | ||
| return id ? isSubscribed(id) : false; | ||
|
|
@@ -62,6 +65,11 @@ export default function ClubWebViewScreen() { | |
| }, 200); | ||
| }; | ||
|
|
||
| const handleError = () => { | ||
| setHasError(true); | ||
| setIsLoading(false); | ||
| }; | ||
|
|
||
| const handleBack = () => { | ||
| trackEvent(USER_EVENT.BACK_BUTTON_CLICKED, { | ||
| from: "club_detail", | ||
|
|
@@ -94,53 +102,85 @@ export default function ClubWebViewScreen() { | |
| } | ||
| }; | ||
|
|
||
| const handleMessage = async (event: WebViewMessageEvent) => { | ||
| try { | ||
| const data = JSON.parse(event.nativeEvent.data); | ||
| if (data.type === "SHARE") { | ||
| const { title, text, url } = data.payload; | ||
| await Share.share({ | ||
| title, | ||
| message: text, | ||
| url, | ||
| }); | ||
| // WebView 메시지 핸들러 | ||
| const { handleMessage } = useWebViewMessageHandler({ | ||
| onNavigateBack: handleBack, | ||
| onSubscribe: async (targetId: string, clubName?: string) => { | ||
| 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); | ||
| } | ||
| } catch (e) { | ||
| // Ignore errors | ||
| } | ||
| }; | ||
| }, | ||
| onUnsubscribe: async (targetId: string) => { | ||
| // 구독 중이 아니면 무시 | ||
| if (!isSubscribed(targetId)) return; | ||
|
|
||
| trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { | ||
| clubName: name, | ||
| subscribed: false, | ||
| from: 'club_detail', | ||
| url: 'app://moadong/club', | ||
| }); | ||
|
|
||
| await toggleSubscribe(targetId); | ||
| }, | ||
| onShare: async ({ title, text, url }: { title: string; text: string; url: string }) => { | ||
| await Share.share({ | ||
| title, | ||
| message: text, | ||
| url, | ||
| }); | ||
|
Comment on lines
+137
to
+142
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In React Native’s built-in Docs: React Native Sources
Android에서 공유 URL이 누락될 수 있습니다. React Native 🐛 제안 수정안- onShare: async ({ title, text, url }: { title: string; text: string; url: string }) => {
- await Share.share({
- title,
- message: text,
- url,
- });
- },
+ onShare: async ({ title, text, url }: { title: string; text: string; url: string }) => {
+ try {
+ const message =
+ Platform.OS === 'android'
+ ? [text, url].filter(Boolean).join('\n')
+ : text;
+ await Share.share({
+ title,
+ message,
+ url,
+ });
+ } catch (e) {
+ console.warn('[ClubWebViewScreen] Share failed', e);
+ }
+ },🤖 Prompt for AI Agents |
||
| }, | ||
| }); | ||
|
|
||
| return ( | ||
| <Container edges={["top", "bottom"]}> | ||
| <Header> | ||
| <BackButton onPress={handleBack} activeOpacity={0.7}> | ||
| <Ionicons name="arrow-back" size={24} color="#111111" /> | ||
| </BackButton> | ||
| <HeaderTitle type="title2">동아리 상세</HeaderTitle> | ||
| <SubscribeButton onPress={handleSubscribeToggle} activeOpacity={0.6}> | ||
| <MoaImage | ||
| source={ | ||
| subscribed | ||
| ? require("@/assets/icons/ic-subscribe-selected.png") | ||
| : require("@/assets/icons/ic-subscribe-unselected.png") | ||
| } | ||
| style={{ width: 24, height: 24 }} | ||
| contentFit="contain" | ||
| /> | ||
| </SubscribeButton> | ||
| </Header> | ||
| <Container edges={['bottom']}> | ||
| <StatusBar translucent style="dark" /> | ||
| {hasError && ( | ||
| <Header> | ||
| <BackButton onPress={handleBack} activeOpacity={0.7}> | ||
| <Ionicons name="arrow-back" size={24} color="#111111" /> | ||
| </BackButton> | ||
| <HeaderTitle type="title2">동아리 상세</HeaderTitle> | ||
| <SubscribeButton onPress={handleSubscribeToggle} activeOpacity={0.6}> | ||
| <MoaImage | ||
| source={ | ||
| subscribed | ||
| ? require('@/assets/icons/ic-subscribe-selected.png') | ||
| : require('@/assets/icons/ic-subscribe-unselected.png') | ||
| } | ||
| style={{ width: 24, height: 24 }} | ||
| contentFit="contain" | ||
| /> | ||
| </SubscribeButton> | ||
| </Header> | ||
| )} | ||
oesnuj marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| <WebViewContainer> | ||
| <WebView | ||
| source={{ uri }} | ||
| style={{ flex: 1, backgroundColor: "#fff" }} | ||
| userAgent={userAgent} | ||
| onLoadEnd={handleLoadEnd} | ||
| onError={handleError} | ||
| onMessage={handleMessage} | ||
| startInLoadingState={false} | ||
| scalesPageToFit={true} | ||
| showsHorizontalScrollIndicator={false} | ||
| showsVerticalScrollIndicator={false} | ||
| bounces={false} | ||
| overScrollMode="never" | ||
|
|
||
| /> | ||
| {isLoading && ( | ||
| <LoadingContainer pointerEvents="none"> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.