Skip to content
Merged
1 change: 1 addition & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

57 changes: 57 additions & 0 deletions hooks/use-webview-message-handler.ts
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 };
};
23 changes: 23 additions & 0 deletions types/webview-message.types.ts
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;
};
}
152 changes: 96 additions & 56 deletions ui/club-detail/club-detail-screen.tsx
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);
const [showPermissionDialog, setShowPermissionDialog] = useState(false);
const { isSubscribed, toggleSubscribe } = useSubscribedClubsContext();
const { sessionId } = useMixpanelContext();
Expand All @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

React Native Share.share API Android url field behavior documentation

💡 Result:

In React Native’s built-in Share.share(content, options) API, the content.url field is documented as iOS-only. On Android, the supported content fields are message (text to share) and title (used as the chooser/title), so url is not part of the Android behavior and may be ignored by the native share sheet. To share a link on Android, put it in message (e.g., message: 'Check this out: https://…'). [1][2]

Docs: React Native Share API reference (shows url - a URL to share iOS, title - … Android). [1][2]

Sources

  1. React Native docs (v0.82) – Share API: url is iOS-only, Android uses message/title. [1]
  2. React Native docs (“Next”) – same platform notes for url/title. [2]

Android에서 공유 URL이 누락될 수 있습니다.

React Native Share.share에서 url 필드는 iOS 전용이며 Android에서는 무시됩니다. Android에서 링크 공유를 보장하려면 message에 URL을 포함해야 합니다. 또한 공유 실패 시 예외가 발생할 수 있으므로 최소한의 try/catch 처리도 권장됩니다.

🐛 제안 수정안
-    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
In `@ui/club-detail/club-detail-screen.tsx` around lines 137 - 142, The onShare
handler currently passes url only in the url field (iOS-only) and lacks error
handling; update the onShare function to build a single message that
concatenates text and url (e.g., `${text}\n${url}`) so Android receives the
link, call Share.share with that combined message and maintain title, and wrap
the await Share.share call in a try/catch to handle/rethrow or log errors as
appropriate.

},
});

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>
)}

<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">
Expand Down