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,