diff --git a/app/(tabs)/(folder)/folder-add-url.tsx b/app/(tabs)/(folder)/folder-add-url.tsx index a81062e..caa97ec 100644 --- a/app/(tabs)/(folder)/folder-add-url.tsx +++ b/app/(tabs)/(folder)/folder-add-url.tsx @@ -45,15 +45,19 @@ export default function FolderAddUrlScreen() { const handleAdd = async () => { if (isAddingRef.current || selectedIds.size === 0) return; + const selectedLinkIds = [...selectedIds]; isAddingRef.current = true; setIsAdding(true); try { - await assignCategory([...selectedIds], Number(folderId)); - await refreshFolders(); + await assignCategory(selectedLinkIds, Number(folderId)); router.replace({ pathname: '/(tabs)/(folder)/[id]', - params: { id: folderId, urlAdded: '1' }, + params: { + id: folderId, + urlAdded: '1', + }, }); + void refreshFolders().catch(() => undefined); } catch (error) { showAlert( 'URL 추가 실패', diff --git a/app/(tabs)/(home)/_layout.tsx b/app/(tabs)/(home)/_layout.tsx index e219776..ec66833 100644 --- a/app/(tabs)/(home)/_layout.tsx +++ b/app/(tabs)/(home)/_layout.tsx @@ -1,5 +1,11 @@ import { Stack } from 'expo-router/stack'; +import { AnalysisResultCacheProvider } from '@/context/analysis-result-cache-context'; + export default function HomeLayout() { - return ; + return ( + + + + ); } diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index 600c6ce..c76d8be 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -14,8 +14,6 @@ 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'; @@ -33,7 +31,7 @@ function getSharedUrlParam(value: string | string[] | undefined): string { } export default function AddLinkScreen() { - const { getToken, isLoaded, isSignedIn } = useAuth(); + const { isLoaded, isSignedIn } = useAuth(); const { sharedUrl } = useLocalSearchParams<{ sharedUrl?: string }>(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const initialSharedUrl = getSharedUrlParam(sharedUrl); @@ -43,7 +41,6 @@ export default function AddLinkScreen() { const [isNavigating, setIsNavigating] = useState(false); const isNavigatingRef = useRef(false); const urlRef = useRef(initialSharedUrl); - const getTokenRef = useRef(getToken); const urlInputRef = useRef(null); const urlFocusTimerRef = useRef | null>(null); @@ -54,9 +51,6 @@ export default function AddLinkScreen() { } }, []); - useEffect(() => { - getTokenRef.current = getToken; - }, [getToken]); useFocusEffect( useCallback(() => { @@ -121,29 +115,7 @@ export default function AddLinkScreen() { isNavigatingRef.current = true; setIsNavigating(true); - 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); - } + router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } }); }; const handleChangeUrl = (value: string) => { @@ -248,24 +220,6 @@ 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, diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx index b41864c..cbc04ad 100644 --- a/app/(tabs)/(home)/scanning.tsx +++ b/app/(tabs)/(home)/scanning.tsx @@ -8,8 +8,10 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { fetchAnalysis, requestAnalysis, type AnalysisResponse, type AnalysisVerdict } from '@/api/analyses'; import { ApiError } from '@/api/api-client'; +import { checkSavedLinkUrl } from '@/api/saved-links'; import { Colors, Typography } from '@/constants/theme'; import { AppIcon } from '@/components/ui/app-icon'; +import { useAnalysisResultCache } from '@/context/analysis-result-cache-context'; import { useGuardedPress } from '@/utils/press-guard'; const POLLING_INTERVAL_MS = 2_000; @@ -20,18 +22,25 @@ const DEFAULT_ANIMATION_SIZE = 280; const SHORT_ANIMATION_SIZE = 216; const VERY_SHORT_ANIMATION_SIZE = 188; const PAGE_UNAVAILABLE_ERROR_CODE = 'PAGE_UNAVAILABLE'; +const SCAN_STEP_MESSAGES = { + checkingDuplicate: '\uC800\uC7A5 \uC5EC\uBD80\uB97C \uD655\uC778\uD558\uACE0 \uC788\uC5B4\uC694', + requestingAnalysis: '\uBCF4\uC548 \uAC80\uC0AC\uB97C \uC694\uCCAD\uD558\uACE0 \uC788\uC5B4\uC694', + pollingAnalysis: '\uAC80\uC0AC \uACB0\uACFC\uB97C \uD655\uC778\uD558\uACE0 \uC788\uC5B4\uC694', +}; const PAGE_UNAVAILABLE_DEFAULT_MESSAGE = '페이지에 연결할 수 없습니다.'; const PAGE_UNAVAILABLE_HELP_MESSAGE = '사이트 접속이 제한되었거나 일시적으로 응답하지 않을 수 있습니다.'; export default function ScanningScreen() { const { getToken, isLoaded, isSignedIn } = useAuth(); + const { setAnalysisResult } = useAnalysisResultCache(); const isFocused = useIsFocused(); const { url: urlParam } = useLocalSearchParams<{ url?: string | string[] }>(); const url = getUrlParam(urlParam); const { height: windowHeight } = useWindowDimensions(); const tabBarHeight = useBottomTabBarHeight(); const [errorMessage, setErrorMessage] = useState(''); + const [scanStepMessage, setScanStepMessage] = useState(SCAN_STEP_MESSAGES.checkingDuplicate); const [retryKey, setRetryKey] = useState(0); const getTokenRef = useRef(getToken); const currentAbortControllerRef = useRef(null); @@ -108,12 +117,15 @@ export default function ScanningScreen() { } setErrorMessage(''); + setScanStepMessage(SCAN_STEP_MESSAGES.checkingDuplicate); screenTimeoutId = setTimeout(handleScreenTimeout, SCAN_SCREEN_TIMEOUT_MS); runAnalysisPolling({ getToken: () => getTokenRef.current(), url, signal: abortController.signal, + onStep: setScanStepMessage, + onResolvedAnalysis: setAnalysisResult, canNavigate: () => isActive && !didTimeout && !hasNavigated, onNavigate: () => { hasNavigated = true; @@ -144,7 +156,7 @@ export default function ScanningScreen() { } abortController.abort(); }; - }, [isLoaded, isSignedIn, retryKey, url]); + }, [isLoaded, isSignedIn, retryKey, setAnalysisResult, url]); const hasError = errorMessage.length > 0; const isScanning = !hasError; @@ -240,7 +252,7 @@ export default function ScanningScreen() { {hasError ? '검사를 완료하지 못했어요' : '보안 검사 중입니다'} - {hasError ? errorMessage : '약 5-10초 정도 소요돼요'} + {hasError ? errorMessage : scanStepMessage} {/* 검사 대상 카드 */} @@ -278,20 +290,39 @@ async function runAnalysisPolling({ getToken, url, signal, + onStep, + onResolvedAnalysis, canNavigate, onNavigate, }: { getToken: () => Promise; url: string; signal: AbortSignal; + onStep: (message: string) => void; + onResolvedAnalysis: (analysis: AnalysisResponse) => void; canNavigate: () => boolean; onNavigate: () => void; }) { const deadline = Date.now() + SCAN_SCREEN_TIMEOUT_MS; + onStep(SCAN_STEP_MESSAGES.checkingDuplicate); + const savedLinkCheck = await checkSavedLinkUrl(getToken, url, { signal }); + + if (savedLinkCheck.exists) { + throw new Error('DUPLICATE_SAVED_LINK'); + } + + onStep(SCAN_STEP_MESSAGES.requestingAnalysis); let analysis = await requestAnalysis(getToken, url, { signal }); while (!signal.aborted) { - const handled = handleAnalysisResult(analysis, url, signal, canNavigate, onNavigate); + const handled = handleAnalysisResult( + analysis, + url, + signal, + canNavigate, + onNavigate, + onResolvedAnalysis, + ); if (handled) { return; @@ -304,6 +335,7 @@ async function runAnalysisPolling({ } await wait(Math.min(POLLING_INTERVAL_MS, remainingMs), signal); + onStep(SCAN_STEP_MESSAGES.pollingAnalysis); analysis = await fetchAnalysis(getToken, analysis.analysisId, { signal }); } } @@ -314,6 +346,7 @@ function handleAnalysisResult( signal: AbortSignal, canNavigate: () => boolean, onNavigate: () => void, + onResolvedAnalysis: (analysis: AnalysisResponse) => void, ) { if (analysis.status === 'queued') { return false; @@ -328,6 +361,7 @@ function handleAnalysisResult( } if (!signal.aborted && canNavigate()) { + onResolvedAnalysis(analysis); onNavigate(); router.replace({ pathname: getResultPath(analysis.verdict), @@ -406,6 +440,10 @@ function getAnalysisErrorMessage(error: unknown) { return '분석 결과를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'; } + if (error.message === 'DUPLICATE_SAVED_LINK') { + return '\uC774\uBBF8 \uC800\uC7A5\uB41C \uB9C1\uD06C\uC785\uB2C8\uB2E4.'; + } + if (error.message && error.message !== 'ANALYSIS_FAILED') { return error.message; } diff --git a/context/analysis-result-cache-context.tsx b/context/analysis-result-cache-context.tsx new file mode 100644 index 0000000..dea0a96 --- /dev/null +++ b/context/analysis-result-cache-context.tsx @@ -0,0 +1,49 @@ +import { createContext, type ReactNode, useCallback, useContext, useRef } from 'react'; + +import type { AnalysisResponse } from '@/api/analyses'; + +type AnalysisResultCacheContextValue = { + getAnalysisResult: (analysisId?: string | null) => AnalysisResponse | null; + setAnalysisResult: (analysis: AnalysisResponse) => void; + clearAnalysisResult: (analysisId: string) => void; +}; + +const AnalysisResultCacheContext = createContext(null); + +export function AnalysisResultCacheProvider({ children }: { children: ReactNode }) { + const cacheRef = useRef(new Map()); + + const getAnalysisResult = useCallback((analysisId?: string | null) => { + if (!analysisId) { + return null; + } + + return cacheRef.current.get(analysisId) ?? null; + }, []); + + const setAnalysisResult = useCallback((analysis: AnalysisResponse) => { + cacheRef.current.set(analysis.analysisId, analysis); + }, []); + + const clearAnalysisResult = useCallback((analysisId: string) => { + cacheRef.current.delete(analysisId); + }, []); + + return ( + + {children} + + ); +} + +export function useAnalysisResultCache() { + const context = useContext(AnalysisResultCacheContext); + + if (!context) { + throw new Error('useAnalysisResultCache must be used within AnalysisResultCacheProvider'); + } + + return context; +} diff --git a/hooks/use-analysis-result.ts b/hooks/use-analysis-result.ts index 8e3fd18..a0bd4fe 100644 --- a/hooks/use-analysis-result.ts +++ b/hooks/use-analysis-result.ts @@ -3,14 +3,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchAnalysis, type AnalysisResponse } from '@/api/analyses'; import { ApiError } from '@/api/api-client'; +import { useAnalysisResultCache } from '@/context/analysis-result-cache-context'; const LOGIN_REQUIRED_MESSAGE = '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; export function useAnalysisResult(analysisId?: string) { const { getToken, isLoaded, isSignedIn } = useAuth(); - const [analysis, setAnalysis] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const { getAnalysisResult, setAnalysisResult } = useAnalysisResultCache(); + const cachedAnalysis = getAnalysisResult(analysisId); + const [analysis, setAnalysis] = useState(cachedAnalysis); + const [isLoading, setIsLoading] = useState(Boolean(analysisId && !cachedAnalysis)); const [errorMessage, setErrorMessage] = useState(''); const getTokenRef = useRef(getToken); @@ -27,6 +30,15 @@ export function useAnalysisResult(analysisId?: string) { return; } + const nextCachedAnalysis = getAnalysisResult(analysisId); + + if (nextCachedAnalysis) { + setAnalysis(nextCachedAnalysis); + setIsLoading(false); + setErrorMessage(''); + return; + } + if (!isLoaded) { return; } @@ -54,6 +66,7 @@ export function useAnalysisResult(analysisId?: string) { } setAnalysis(nextAnalysis); + setAnalysisResult(nextAnalysis); } catch (error) { if (signal?.aborted) { return; @@ -70,7 +83,7 @@ export function useAnalysisResult(analysisId?: string) { } } }, - [analysisId, isLoaded, isSignedIn], + [analysisId, getAnalysisResult, isLoaded, isSignedIn, setAnalysisResult], ); useEffect(() => { @@ -85,12 +98,19 @@ export function useAnalysisResult(analysisId?: string) { return; } + if (cachedAnalysis) { + setAnalysis(cachedAnalysis); + setIsLoading(false); + setErrorMessage(''); + return; + } + const abortController = new AbortController(); void loadAnalysis(abortController.signal); return () => abortController.abort(); - }, [analysisId, isLoaded, loadAnalysis]); + }, [analysisId, cachedAnalysis, isLoaded, loadAnalysis]); return { analysis,