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
10 changes: 7 additions & 3 deletions app/(tabs)/(folder)/folder-add-url.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 추가 실패',
Expand Down
8 changes: 7 additions & 1 deletion app/(tabs)/(home)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Stack } from 'expo-router/stack';

import { AnalysisResultCacheProvider } from '@/context/analysis-result-cache-context';

export default function HomeLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
return (
<AnalysisResultCacheProvider>
<Stack screenOptions={{ headerShown: false }} />
</AnalysisResultCacheProvider>
);
}
50 changes: 2 additions & 48 deletions app/(tabs)/(home)/add-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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<TextInput>(null);
const urlFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

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

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

useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 41 additions & 3 deletions app/(tabs)/(home)/scanning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<AbortController | null>(null);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -240,7 +252,7 @@ export default function ScanningScreen() {
{hasError ? '검사를 완료하지 못했어요' : '보안 검사 중입니다'}
</Text>
<Text style={[styles.subtitle, isShortScreen && styles.subtitleCompact, hasError && styles.errorText]}>
{hasError ? errorMessage : '약 5-10초 정도 소요돼요'}
{hasError ? errorMessage : scanStepMessage}
</Text>

{/* 검사 대상 카드 */}
Expand Down Expand Up @@ -278,20 +290,39 @@ async function runAnalysisPolling({
getToken,
url,
signal,
onStep,
onResolvedAnalysis,
canNavigate,
onNavigate,
}: {
getToken: () => Promise<string | null>;
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;
Expand All @@ -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 });
}
}
Expand All @@ -314,6 +346,7 @@ function handleAnalysisResult(
signal: AbortSignal,
canNavigate: () => boolean,
onNavigate: () => void,
onResolvedAnalysis: (analysis: AnalysisResponse) => void,
) {
if (analysis.status === 'queued') {
return false;
Expand All @@ -328,6 +361,7 @@ function handleAnalysisResult(
}

if (!signal.aborted && canNavigate()) {
onResolvedAnalysis(analysis);
onNavigate();
router.replace({
pathname: getResultPath(analysis.verdict),
Expand Down Expand Up @@ -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;
}
Expand Down
49 changes: 49 additions & 0 deletions context/analysis-result-cache-context.tsx
Original file line number Diff line number Diff line change
@@ -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<AnalysisResultCacheContextValue | null>(null);

export function AnalysisResultCacheProvider({ children }: { children: ReactNode }) {
const cacheRef = useRef(new Map<string, AnalysisResponse>());

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 (
<AnalysisResultCacheContext.Provider
value={{ getAnalysisResult, setAnalysisResult, clearAnalysisResult }}
>
{children}
</AnalysisResultCacheContext.Provider>
);
}

export function useAnalysisResultCache() {
const context = useContext(AnalysisResultCacheContext);

if (!context) {
throw new Error('useAnalysisResultCache must be used within AnalysisResultCacheProvider');
}

return context;
}
28 changes: 24 additions & 4 deletions hooks/use-analysis-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnalysisResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { getAnalysisResult, setAnalysisResult } = useAnalysisResultCache();
const cachedAnalysis = getAnalysisResult(analysisId);
const [analysis, setAnalysis] = useState<AnalysisResponse | null>(cachedAnalysis);
const [isLoading, setIsLoading] = useState(Boolean(analysisId && !cachedAnalysis));
const [errorMessage, setErrorMessage] = useState('');
const getTokenRef = useRef(getToken);

Expand All @@ -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;
}
Expand Down Expand Up @@ -54,6 +66,7 @@ export function useAnalysisResult(analysisId?: string) {
}

setAnalysis(nextAnalysis);
setAnalysisResult(nextAnalysis);
} catch (error) {
if (signal?.aborted) {
return;
Expand All @@ -70,7 +83,7 @@ export function useAnalysisResult(analysisId?: string) {
}
}
},
[analysisId, isLoaded, isSignedIn],
[analysisId, getAnalysisResult, isLoaded, isSignedIn, setAnalysisResult],
);

useEffect(() => {
Expand All @@ -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,
Expand Down
Loading