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
16 changes: 16 additions & 0 deletions api/saved-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export type SavedLinkTitleUpdateResponse = {
title: string;
};

export type SavedLinkUrlCheckResponse = {
exists: boolean;
};

type SavedLinkRequestOptions = Pick<ApiRequestOptions, 'signal'>;

export function createSavedLink(
Expand All @@ -80,6 +84,18 @@ export function fetchSavedLinks(
);
}

export function checkSavedLinkUrl(
getToken: ClerkTokenGetter,
url: string,
options: SavedLinkRequestOptions = {},
) {
return authenticatedApiRequest<SavedLinkUrlCheckResponse>(
getToken,
`/api/v1/saved-links/check?url=${encodeURIComponent(url)}`,
options,
);
}

export function deleteSavedLink(getToken: ClerkTokenGetter, id: number) {
return authenticatedApiRequest<void>(
getToken,
Expand Down
84 changes: 78 additions & 6 deletions app/(tabs)/(home)/add-link.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAuth } from '@clerk/expo';
import { useFocusEffect } from '@react-navigation/native';
import {
Keyboard,
Expand All @@ -13,6 +14,8 @@ import {
} from 'react-native';
import { Stack, router, useLocalSearchParams } from 'expo-router';

import { ApiError } from '@/api/api-client';
import { checkSavedLinkUrl } from '@/api/saved-links';
import { ScanButton } from '@/components/ui/scan-button';
import { Colors, Typography } from '@/constants/theme';
import { useGuardedPress } from '@/utils/press-guard';
Expand All @@ -30,6 +33,7 @@ function getSharedUrlParam(value: string | string[] | undefined): string {
}

export default function AddLinkScreen() {
const { getToken, isLoaded, isSignedIn } = useAuth();
const { sharedUrl } = useLocalSearchParams<{ sharedUrl?: string }>();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const initialSharedUrl = getSharedUrlParam(sharedUrl);
Expand All @@ -38,6 +42,8 @@ export default function AddLinkScreen() {
const isCompact = windowWidth < COMPACT_WIDTH || windowHeight <= SHORT_SCREEN_HEIGHT;
const [isNavigating, setIsNavigating] = useState(false);
const isNavigatingRef = useRef(false);
const urlRef = useRef(initialSharedUrl);
const getTokenRef = useRef(getToken);
const urlInputRef = useRef<TextInput>(null);
const urlFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

Expand All @@ -48,6 +54,10 @@ export default function AddLinkScreen() {
}
}, []);

useEffect(() => {
getTokenRef.current = getToken;
}, [getToken]);

useFocusEffect(
useCallback(() => {
isNavigatingRef.current = false;
Expand All @@ -72,11 +82,12 @@ export default function AddLinkScreen() {
return;
}

urlRef.current = nextSharedUrl;
setUrl(nextSharedUrl);
setError('');
}, [sharedUrl]);

const handleScan = () => {
const handleScan = async () => {
if (isNavigatingRef.current) {
return;
}
Expand All @@ -96,20 +107,55 @@ export default function AddLinkScreen() {
return;
}

if (!isLoaded) {
setError('로그인 상태를 확인하고 있습니다. 잠시 후 다시 시도해주세요.');
return;
}

if (!isSignedIn) {
setError('로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.');
return;
}

setError('');
isNavigatingRef.current = true;
setIsNavigating(true);
router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } });

Comment thread
minsoo0506 marked this conversation as resolved.
try {
const response = await checkSavedLinkUrl(() => getTokenRef.current(), normalizedUrl);
const currentNormalizedUrl = normalizeHttpUrlInput(urlRef.current.trim());

if (currentNormalizedUrl !== normalizedUrl) {
isNavigatingRef.current = false;
setIsNavigating(false);
return;
}

if (response.exists) {
setError('이미 저장된 링크입니다.');
isNavigatingRef.current = false;
setIsNavigating(false);
return;
}

router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } });
} catch (error) {
setError(getSavedLinkUrlCheckErrorMessage(error));
isNavigatingRef.current = false;
setIsNavigating(false);
}
};

const handleChangeUrl = (value: string) => {
urlRef.current = value;
setUrl(value);
if (error) setError('');
};

const hasError = error.length > 0;
const scanDisabled = !url.trim() || isNavigating;
const scanDisabled = !url.trim() || isNavigating || !isLoaded;
const guardedClearUrl = useGuardedPress(() => {
urlRef.current = '';
setUrl('');
setError('');
}, { lockMs: 250 });
Expand Down Expand Up @@ -161,8 +207,11 @@ export default function AddLinkScreen() {
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
onSubmitEditing={Keyboard.dismiss}
returnKeyType="search"
onSubmitEditing={() => {
Keyboard.dismiss();
void handleScan();
}}
/>
{url.length > 0 && (
<TouchableOpacity
Expand All @@ -174,7 +223,12 @@ export default function AddLinkScreen() {
</TouchableOpacity>
)}
</View>
<ScanButton onPress={handleScan} disabled={scanDisabled} />
<ScanButton
onPress={() => {
void handleScan();
}}
disabled={scanDisabled}
/>
</View>

{/* 에러 메시지 */}
Expand All @@ -194,6 +248,24 @@ export default function AddLinkScreen() {
);
}

function getSavedLinkUrlCheckErrorMessage(error: unknown) {
if (error instanceof ApiError) {
if (error.status === 401 || error.status === 403) {
return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.';
}

return error.message || '저장된 링크 확인에 실패했습니다. 잠시 후 다시 시도해주세요.';
}

if (error instanceof Error) {
if (error.message === 'Missing Clerk session token') {
return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.';
}
}

return '저장된 링크 확인에 실패했습니다. 잠시 후 다시 시도해주세요.';
}

const styles = StyleSheet.create({
safeArea: {
flex: 1,
Expand Down
Loading