Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@
},
"plugins": [
"expo-router",
[
"expo-share-intent",
{
"iosActivationRules": {
"NSExtensionActivationSupportsText": true,
"NSExtensionActivationSupportsWebURLWithMaxCount": 1,
"NSExtensionActivationSupportsWebPageWithMaxCount": 1
},
"iosShareExtensionName": "LinCleanShareExtension",
"androidIntentFilters": [
"text/*"
]
}
],
"./plugins/with-android-single-task",
[
"expo-splash-screen",
Expand Down
19 changes: 18 additions & 1 deletion app/(auth)/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAuth, useSSO } from '@clerk/expo';
import * as AuthSession from 'expo-auth-session';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import { useShareIntentContext } from 'expo-share-intent';
import * as WebBrowser from 'expo-web-browser';
import { useState } from 'react';
import { Alert, StyleSheet, Text, View } from 'react-native';
Expand All @@ -10,6 +11,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SocialLoginButton } from '@/components/ui/social-login-button';
import { Colors, Typography } from '@/constants/theme';
import { syncAuthenticatedMember } from '@/services/auth-api';
import { getSharedUrlFromIntent } from '@/utils/shared-url';

const IMG_WORDMARK = require('@/assets/images/login_wordmark.png');
const CLERK_REDIRECT_URL = AuthSession.makeRedirectUri({
Expand All @@ -23,6 +25,7 @@ export default function LoginScreen() {
const insets = useSafeAreaInsets();
const { getToken, signOut } = useAuth();
const { startSSOFlow } = useSSO();
const { hasShareIntent, resetShareIntent, shareIntent } = useShareIntentContext();
const [isSigningIn, setIsSigningIn] = useState(false);

const handleGoogleLogin = async () => {
Expand All @@ -46,7 +49,21 @@ export default function LoginScreen() {
await setActive({ session: createdSessionId });
sessionActivated = true;
await syncAuthenticatedMember(getToken);
router.replace('/(tabs)/(home)');

const sharedUrl = hasShareIntent ? getSharedUrlFromIntent(shareIntent) : null;

if (hasShareIntent) {
resetShareIntent(true);
}

if (sharedUrl) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

현재코드:

  if (sharedUrl) {
    router.replace({ pathname: '/(tabs)/(home)/add-link', params: { sharedUrl }
  });
    resetShareIntent(true);
  } else {
    if (hasShareIntent) {
      resetShareIntent(true);
    }
    router.replace('/(tabs)/(home)');
  }

제안:

  if (hasShareIntent) resetShareIntent(true);
  if (sharedUrl) {
    router.replace({ pathname: '/(tabs)/(home)/add-link', params: { sharedUrl }
  });
  } else {
    router.replace('/(tabs)/(home)');
  }

동작은 동일하고, resetShareIntent를 한 곳에서만 처리해 의도가 더 명확지지 않을까 합니다.
큰 문제는 아니고 가독성 향상을 위한 제안이라 반영할지 넘길지 판단만 해주시면 될 거 같습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

좋은 제안 감사합니다. 말씀해주신 대로 share intent가 있는 경우에는 URL 추출 성공 여부와 관계없이 소비한 intent를 정리하는 흐름이라, resetShareIntent(true)를 분기 밖에서 한 번만 처리하도록 반영했습니다.

이렇게 하니 중복도 줄고, 공유 인텐트를 처리한 뒤 정리한다는 의도가 더 명확해지는 것 같습니다.

router.replace({
pathname: '/(tabs)/(home)/add-link',
params: { sharedUrl },
});
} else {
router.replace('/(tabs)/(home)');
}
} catch (error) {
console.error(error);

Expand Down
61 changes: 26 additions & 35 deletions app/(tabs)/(home)/add-link.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Expand All @@ -8,48 +8,36 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { Stack, router } from 'expo-router';
import { Stack, router, useLocalSearchParams } from 'expo-router';

import { ScanButton } from '@/components/ui/scan-button';
import { Colors, Typography } from '@/constants/theme';
import { normalizeHttpUrlInput } from '@/utils/shared-url';

function getRawHostname(value: string): string | null {
const authority = value.match(/^https?:\/\/([^/?#]+)/i)?.[1];
if (!authority) return null;

return authority.split('@').pop()?.split(':')[0] ?? null;
}

function isValidHostname(hostname: string): boolean {
const labels = hostname.split('.');
if (labels.length < 2) return false;
if (labels.some((label) => !label)) return false;

const tld = labels[labels.length - 1];
if (!/^[a-z]{2,}$/i.test(tld)) return false;
function getSharedUrlParam(value: string | string[] | undefined): string {
if (typeof value !== 'string') {
return '';
}

return labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label));
return normalizeHttpUrlInput(value) ?? '';
}

function isValidUrlFormat(value: string): boolean {
const trimmed = value.trim();
if (!trimmed || /\s/.test(trimmed)) return false;
export default function AddLinkScreen() {
const { sharedUrl } = useLocalSearchParams<{ sharedUrl?: string }>();
const initialSharedUrl = getSharedUrlParam(sharedUrl);
const [url, setUrl] = useState(initialSharedUrl);
const [error, setError] = useState('');

const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const rawHostname = getRawHostname(candidate);
if (!rawHostname || !isValidHostname(rawHostname)) return false;
useEffect(() => {
const nextSharedUrl = getSharedUrlParam(sharedUrl);

try {
const parsed = new URL(candidate);
return isValidHostname(parsed.hostname);
} catch {
return false;
}
}
if (!nextSharedUrl) {
return;
}

export default function AddLinkScreen() {
const [url, setUrl] = useState('');
const [error, setError] = useState('');
setUrl(nextSharedUrl);
setError('');
}, [sharedUrl]);

const handleScan = () => {
const trimmed = url.trim();
Expand All @@ -59,13 +47,16 @@ export default function AddLinkScreen() {
return;
}

if (!isValidUrlFormat(trimmed)) {
// 1단계: new URL()로 형식 검증
const normalizedUrl = normalizeHttpUrlInput(trimmed);

if (!normalizedUrl) {
setError('올바르지 않은 URL 입력입니다.');
return;
}

setError('');
router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: trimmed } });
router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } });
};

const handleChangeUrl = (value: string) => {
Expand Down
2 changes: 2 additions & 0 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { Redirect, router, Tabs } from 'expo-router';
import { useEffect, useRef, useState } from 'react';

import { ShareIntentRouter } from '@/components/share-intent-router';
import { BottomTabBar, type TabVariant } from '@/components/ui/bottom-tab-bar';
import { SavedLinksProvider } from '@/context/saved-links-context';
import { syncAuthenticatedMember } from '@/services/auth-api';
Expand Down Expand Up @@ -120,6 +121,7 @@ export default function TabLayout() {

return (
<SavedLinksProvider>
<ShareIntentRouter />
<Tabs
tabBar={(props) => <CustomTabBar {...props} />}
screenOptions={{ headerShown: false }}
Expand Down
31 changes: 17 additions & 14 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ClerkProvider } from '@clerk/expo';
import { tokenCache } from '@clerk/expo/token-cache';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { ShareIntentProvider } from 'expo-share-intent';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
Expand All @@ -24,19 +25,21 @@ export default function RootLayout() {
const colorScheme = useColorScheme();

return (
<ClerkProvider publishableKey={clerkPublishableKey} tokenCache={tokenCache}>
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="sso-callback" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</GestureHandlerRootView>
</ClerkProvider>
<ShareIntentProvider options={{ scheme: 'linclean', resetOnBackground: false }}>
<ClerkProvider publishableKey={clerkPublishableKey} tokenCache={tokenCache}>
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="sso-callback" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</GestureHandlerRootView>
</ClerkProvider>
</ShareIntentProvider>
);
}
59 changes: 59 additions & 0 deletions components/share-intent-router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useAuth } from '@clerk/expo';
import { router, useSegments } from 'expo-router';
import { useShareIntentContext } from 'expo-share-intent';
import { useEffect, useRef } from 'react';

import { getSharedUrlFromIntent } from '@/utils/shared-url';

export function ShareIntentRouter() {
const { isLoaded, isSignedIn } = useAuth();
const segments = useSegments();
const { error, hasShareIntent, isReady, resetShareIntent, shareIntent } =
useShareIntentContext();
const handledUrlRef = useRef<string | null>(null);

useEffect(() => {
if (error) {
console.warn('Failed to read share intent.', error);
}
}, [error]);

useEffect(() => {
if (!hasShareIntent) {
handledUrlRef.current = null;
return;
}

if (!isLoaded || !isReady) {
return;
}

if (!isSignedIn) {
return;
}

if (segments[0] !== '(tabs)') {
return;
}

const sharedUrl = getSharedUrlFromIntent(shareIntent);

if (!sharedUrl) {
resetShareIntent(true);
return;
}

if (handledUrlRef.current === sharedUrl) {
return;
}

handledUrlRef.current = sharedUrl;
router.replace({
pathname: '/(tabs)/(home)/add-link',
params: { sharedUrl },
});
resetShareIntent(true);
}, [hasShareIntent, isLoaded, isReady, isSignedIn, resetShareIntent, segments, shareIntent]);

return null;
}
Loading
Loading