diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index e2d67dc..0e3a133 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -5,13 +5,14 @@ import { router, useLocalSearchParams } from 'expo-router'; import { useShareIntentContext } from 'expo-share-intent'; import * as WebBrowser from 'expo-web-browser'; import { useEffect, useRef, useState } from 'react'; -import { Alert, StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ApiError } from '@/api/api-client'; import { SocialLoginButton } from '@/components/ui/social-login-button'; import { Colors, Typography } from '@/constants/theme'; import { syncAuthenticatedMember } from '@/services/auth-api'; +import { showAlert } from '@/utils/guarded-alert'; import { getSharedUrlFromIntent } from '@/utils/shared-url'; const IMG_WORDMARK = require('@/assets/images/login_wordmark.png'); @@ -62,7 +63,7 @@ export default function LoginScreen() { } shownNoticeRef.current = notice; - Alert.alert(alert.title, alert.message); + showAlert(alert.title, alert.message); }, [notice]); const handleGoogleLogin = async () => { @@ -98,7 +99,7 @@ export default function LoginScreen() { } } - Alert.alert( + showAlert( '로그인 실패', '계정 연결 중 서버와 통신하지 못했습니다. 잠시 후 다시 시도해주세요.' ); diff --git a/app/(tabs)/(folder)/[id].tsx b/app/(tabs)/(folder)/[id].tsx index b920ebf..ad617d3 100644 --- a/app/(tabs)/(folder)/[id].tsx +++ b/app/(tabs)/(folder)/[id].tsx @@ -1,4 +1,4 @@ -import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -12,7 +12,8 @@ import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import { useFolders } from '@/context/folders-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { showAlert } from '@/utils/guarded-alert'; type MoreMenuState = { visible: boolean; @@ -35,6 +36,7 @@ export default function FolderDetailScreen() { const [toastVisible, setToastVisible] = useState(false); const [addToastVisible, setAddToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); + const deletingFromFolderIdsRef = useRef>(new Set()); useEffect(() => { if (urlAdded === '1') setAddToastVisible(true); @@ -45,20 +47,32 @@ export default function FolderDetailScreen() { }; const handleDelete = async (linkId: number) => { + if (deletingFromFolderIdsRef.current.has(linkId)) { + return; + } + + deletingFromFolderIdsRef.current.add(linkId); + try { await assignCategory([linkId], null); await refreshFolders(); setToastVisible(true); } catch (error) { - Alert.alert( + showAlert( '폴더에서 삭제 실패', getSavedLinkErrorMessage(error, '링크를 폴더에서 제외하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); + } finally { + deletingFromFolderIdsRef.current.delete(linkId); } }; const confirmDeleteFromFolder = (linkId: number) => { - Alert.alert( + if (deletingFromFolderIdsRef.current.has(linkId)) { + return; + } + + showAlert( '폴더에서 삭제할까요?', '링크는 삭제되지 않아요.\n현재 폴더에서만 제외돼요.', [ diff --git a/app/(tabs)/(folder)/folder-add-url.tsx b/app/(tabs)/(folder)/folder-add-url.tsx index c83d4d2..5e8538e 100644 --- a/app/(tabs)/(folder)/folder-add-url.tsx +++ b/app/(tabs)/(folder)/folder-add-url.tsx @@ -1,6 +1,5 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { - Alert, FlatList, StyleSheet, Text, @@ -14,6 +13,7 @@ import { SelectionCircle } from '@/components/ui/selection-circle'; import { Colors, Typography } from '@/constants/theme'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; import { useSavedLinks, type SavedLink } from '@/context/saved-links-context'; +import { showAlert } from '@/utils/guarded-alert'; // ─── Selectable card row ────────────────────────────────────────────────────── @@ -60,6 +60,7 @@ export default function FolderAddUrlScreen() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [isAdding, setIsAdding] = useState(false); + const isAddingRef = useRef(false); const toggleSelect = useCallback((id: number) => { setSelectedIds((prev) => { @@ -74,7 +75,8 @@ export default function FolderAddUrlScreen() { }, []); const handleAdd = async () => { - if (isAdding || selectedIds.size === 0) return; + if (isAddingRef.current || selectedIds.size === 0) return; + isAddingRef.current = true; setIsAdding(true); try { await assignCategory([...selectedIds], Number(folderId)); @@ -84,11 +86,12 @@ export default function FolderAddUrlScreen() { params: { id: folderId, urlAdded: '1' }, }); } catch (error) { - Alert.alert( + showAlert( 'URL 추가 실패', getFolderErrorMessage(error, '선택한 URL을 폴더에 추가하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); } finally { + isAddingRef.current = false; setIsAdding(false); } }; diff --git a/app/(tabs)/(folder)/folder-name.tsx b/app/(tabs)/(folder)/folder-name.tsx index 56775c3..5819a5c 100644 --- a/app/(tabs)/(folder)/folder-name.tsx +++ b/app/(tabs)/(folder)/folder-name.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; import { KeyboardAvoidingView, Platform, @@ -13,16 +14,30 @@ import { router, Stack } from 'expo-router'; import { Button } from '@/components/ui/button'; import { Colors, Typography } from '@/constants/theme'; import { useFolders } from '@/context/folders-context'; +import { useGuardedPress } from '@/utils/press-guard'; export default function FolderNameScreen() { const [folderName, setFolderName] = useState(''); const [isFocused, setIsFocused] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [isNavigating, setIsNavigating] = useState(false); + const isNavigatingRef = useRef(false); const { folders } = useFolders(); - const canProceed = folderName.trim().length > 0; + useFocusEffect( + useCallback(() => { + isNavigatingRef.current = false; + setIsNavigating(false); + }, []), + ); + + const canProceed = folderName.trim().length > 0 && !isNavigating; const handleNext = () => { + if (isNavigatingRef.current) { + return; + } + const trimmedName = folderName.trim(); const hasDuplicateName = folders.some( (folder) => normalizeFolderName(folder.name) === normalizeFolderName(trimmedName), @@ -34,11 +49,14 @@ export default function FolderNameScreen() { } setErrorMessage(''); + isNavigatingRef.current = true; + setIsNavigating(true); router.push({ pathname: '/(tabs)/(folder)/folder-url-select', params: { folderName: trimmedName }, }); }; + const guardedClearFolderName = useGuardedPress(() => setFolderName(''), { lockMs: 250 }); return ( <> @@ -87,7 +105,7 @@ export default function FolderNameScreen() { /> {folderName.length > 0 && ( setFolderName('')} + onPress={guardedClearFolderName} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={styles.clearButton} > diff --git a/app/(tabs)/(folder)/folder-url-select.tsx b/app/(tabs)/(folder)/folder-url-select.tsx index 5dd64b7..4293d52 100644 --- a/app/(tabs)/(folder)/folder-url-select.tsx +++ b/app/(tabs)/(folder)/folder-url-select.tsx @@ -1,6 +1,5 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { - Alert, FlatList, StyleSheet, Text, @@ -14,6 +13,7 @@ import { SelectionCircle } from '@/components/ui/selection-circle'; import { Colors, Typography } from '@/constants/theme'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; import { useSavedLinks, type SavedLink } from '@/context/saved-links-context'; +import { showAlert } from '@/utils/guarded-alert'; // ─── Selectable card row ────────────────────────────────────────────────────── @@ -56,6 +56,7 @@ export default function FolderUrlSelectScreen() { const { links: allLinks, refreshLinks } = useSavedLinks(); const links = allLinks.filter((l) => l.categoryId === null); const name = (folderName ?? '새 폴더').trim(); + const isCreatingRef = useRef(false); const toggleSelect = useCallback((id: number) => { setSelectedIds((prev) => { @@ -70,15 +71,17 @@ export default function FolderUrlSelectScreen() { }, []); const handleCreate = async () => { - if (isCreating) return; + if (isCreatingRef.current) return; + isCreatingRef.current = true; setIsCreating(true); try { await addFolder(name, [...selectedIds]); } catch (error) { - Alert.alert( + showAlert( '폴더 생성 실패', getFolderErrorMessage(error, '폴더를 생성하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); + isCreatingRef.current = false; setIsCreating(false); return; } @@ -87,8 +90,6 @@ export default function FolderUrlSelectScreen() { await refreshLinks(); } catch { // Link refresh is best-effort after the folder has already been created. - } finally { - setIsCreating(false); } router.dismissAll(); @@ -96,6 +97,8 @@ export default function FolderUrlSelectScreen() { pathname: '/(tabs)/(folder)', params: { folderCreated: String(Date.now()) }, }); + isCreatingRef.current = false; + setIsCreating(false); }; const selectedCount = selectedIds.size; diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index ede91f0..e511c93 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, - Alert, Keyboard, LayoutAnimation, Modal, @@ -26,6 +25,8 @@ import { Colors, Typography } from '@/constants/theme'; import type { AnchorPosition } from '@/components/ui/folder-card'; import { useSavedLinks } from '@/context/saved-links-context'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; +import { showAlert } from '@/utils/guarded-alert'; +import { useGuardedPress } from '@/utils/press-guard'; type MenuState = { visible: boolean; @@ -86,6 +87,7 @@ export default function FolderScreen() { const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [renameToastVisible, setRenameToastVisible] = useState(false); const lastCreatedToastRef = useRef(undefined); + const isMutatingRef = useRef(false); const renameInputRef = useRef(null); const renameFocusTimerRef = useRef | null>(null); const folderCreated = typeof folderCreatedParam === 'string' ? folderCreatedParam : undefined; @@ -146,7 +148,8 @@ export default function FolderScreen() { const handleRenameConfirm = async () => { const trimmed = renameState.value.trim(); const currentName = renameState.currentName.trim(); - if (renameState.folderId == null || !trimmed || trimmed === currentName || isMutating) return; + if (renameState.folderId == null || !trimmed || trimmed === currentName || isMutatingRef.current) return; + isMutatingRef.current = true; setIsMutating(true); try { await renameFolder(renameState.folderId, trimmed); @@ -157,20 +160,26 @@ export default function FolderScreen() { setRenameState({ visible: false, value: '', currentName: '' }); setRenameToastVisible(true); } catch (error) { - Alert.alert( + showAlert( '폴더명 수정 실패', getFolderErrorMessage(error, '폴더 이름을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); } finally { + isMutatingRef.current = false; setIsMutating(false); } }; const handleRenameCancel = () => { + if (isMutatingRef.current) { + return; + } + if (renameFocusTimerRef.current) { clearTimeout(renameFocusTimerRef.current); renameFocusTimerRef.current = null; } + setRenameState({ visible: false, value: '', currentName: '' }); }; @@ -189,24 +198,26 @@ export default function FolderScreen() { if (menuState.folderId == null) return; const folderId = menuState.folderId; - Alert.alert('폴더 삭제', '폴더를 삭제할까요? 폴더 안의 링크는 미분류 상태로 이동합니다.', [ + showAlert('폴더 삭제', '폴더를 삭제할까요? 폴더 안의 링크는 미분류 상태로 이동합니다.', [ { text: '취소', style: 'cancel' }, { text: '삭제', style: 'destructive', onPress: async () => { - if (isMutating) return; + if (isMutatingRef.current) return; + isMutatingRef.current = true; setIsMutating(true); try { await deleteFolder(folderId); await refreshLinks(); setDeleteToastVisible(true); } catch (error) { - Alert.alert( + showAlert( '폴더 삭제 실패', getFolderErrorMessage(error, '폴더를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); } finally { + isMutatingRef.current = false; setIsMutating(false); } }, @@ -223,6 +234,12 @@ export default function FolderScreen() { trimmedRenameValue.length === 0 || trimmedRenameValue === renameState.currentName.trim() || isMutating; + const guardedRenameCancel = useGuardedPress(handleRenameCancel, { disabled: isMutating, lockMs: 250 }); + const guardedRenameConfirm = useGuardedPress(handleRenameConfirm, { disabled: renameSubmitDisabled }); + const guardedClearRename = useGuardedPress( + () => setRenameState((s) => ({ ...s, value: '' })), + { disabled: isMutating, lockMs: 250 }, + ); const renameRestingBottomInset = Math.max(insets.bottom, RENAME_MODAL_BOTTOM_GAP); const renameModalBottomInset = renameKeyboardInset > 0 ? renameKeyboardInset + RENAME_KEYBOARD_TOP_GAP @@ -316,7 +333,7 @@ export default function FolderScreen() { visible={renameState.visible} transparent animationType="fade" - onRequestClose={handleRenameCancel} + onRequestClose={guardedRenameCancel} onShow={handleRenameModalShow} > - + {renameState.value.length > 0 && ( setRenameState((s) => ({ ...s, value: '' }))} + onPress={guardedClearRename} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={renameStyles.clearButton} > @@ -356,15 +373,12 @@ export default function FolderScreen() { )} - + 취소 diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index 64d42d4..fd97936 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; import { KeyboardAvoidingView, Platform, @@ -12,6 +13,7 @@ import { Stack, router, useLocalSearchParams } from 'expo-router'; import { ScanButton } from '@/components/ui/scan-button'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; import { normalizeHttpUrlInput } from '@/utils/shared-url'; function getSharedUrlParam(value: string | string[] | undefined): string { @@ -27,6 +29,15 @@ export default function AddLinkScreen() { const initialSharedUrl = getSharedUrlParam(sharedUrl); const [url, setUrl] = useState(initialSharedUrl); const [error, setError] = useState(''); + const [isNavigating, setIsNavigating] = useState(false); + const isNavigatingRef = useRef(false); + + useFocusEffect( + useCallback(() => { + isNavigatingRef.current = false; + setIsNavigating(false); + }, []), + ); useEffect(() => { const nextSharedUrl = getSharedUrlParam(sharedUrl); @@ -40,6 +51,10 @@ export default function AddLinkScreen() { }, [sharedUrl]); const handleScan = () => { + if (isNavigatingRef.current) { + return; + } + const trimmed = url.trim(); if (!trimmed) { @@ -56,6 +71,8 @@ export default function AddLinkScreen() { } setError(''); + isNavigatingRef.current = true; + setIsNavigating(true); router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } }); }; @@ -65,7 +82,11 @@ export default function AddLinkScreen() { }; const hasError = error.length > 0; - const scanDisabled = !url.trim(); + const scanDisabled = !url.trim() || isNavigating; + const guardedClearUrl = useGuardedPress(() => { + setUrl(''); + setError(''); + }, { lockMs: 250 }); return ( <> @@ -116,7 +137,7 @@ export default function AddLinkScreen() { /> {url.length > 0 && ( { setUrl(''); setError(''); }} + onPress={guardedClearUrl} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={styles.clearButton} > diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index cec21b0..3a7901c 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { - Alert, ScrollView, StyleSheet, Text, @@ -19,6 +18,7 @@ import { Colors, Typography } from '@/constants/theme'; import { useFolders } from '@/context/folders-context'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; +import { showAlert } from '@/utils/guarded-alert'; export default function HomeScreen() { const { savedLinkToast: savedLinkToastParam } = useLocalSearchParams<{ @@ -32,6 +32,7 @@ export default function HomeScreen() { const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); const lastToastParamRef = useRef(undefined); + const deletingLinkIdsRef = useRef>(new Set()); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; // 최근 저장한 링크 — createdAt 내림차순 상위 3개 @@ -57,7 +58,7 @@ export default function HomeScreen() { try { await toggleBookmark(id); } catch (error) { - Alert.alert( + showAlert( '북마크 변경 실패', getSavedLinkErrorMessage(error, '북마크 상태를 변경하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); @@ -68,21 +69,33 @@ export default function HomeScreen() { const handleDelete = useCallback( (id: number) => { - Alert.alert('링크 삭제', '저장한 링크를 삭제할까요?', [ + if (deletingLinkIdsRef.current.has(id)) { + return; + } + + showAlert('링크 삭제', '저장한 링크를 삭제할까요?', [ { text: '취소', style: 'cancel' }, { text: '삭제', style: 'destructive', onPress: async () => { + if (deletingLinkIdsRef.current.has(id)) { + return; + } + + deletingLinkIdsRef.current.add(id); + try { await deleteLink(id); await refreshFolders(); setDeleteToastVisible(true); } catch (error) { - Alert.alert( + showAlert( '삭제 실패', getSavedLinkErrorMessage(error, '링크를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); + } finally { + deletingLinkIdsRef.current.delete(id); } }, }, diff --git a/app/(tabs)/(home)/notices.tsx b/app/(tabs)/(home)/notices.tsx index ed3eaac..ad01273 100644 --- a/app/(tabs)/(home)/notices.tsx +++ b/app/(tabs)/(home)/notices.tsx @@ -16,6 +16,7 @@ import { fetchNotices, type NoticeListItemResponse } from '@/api/notices'; import { AppIcon } from '@/components/ui/app-icon'; import { Button } from '@/components/ui/button'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; const NOTICE_PAGE_SIZE = 20; @@ -145,17 +146,21 @@ export default function NoticesScreen() { startLoadNotices(); }, [startLoadNotices]); + const openNotice = useCallback((id: number) => { + router.push({ + pathname: '/(tabs)/(home)/notice-detail' as any, + params: { id: String(id) }, + }); + }, []); + + const guardedOpenNotice = useGuardedPress(openNotice); + const renderNotice = useCallback( ({ item }: { item: NoticeListItemResponse }) => ( - router.push({ - pathname: '/(tabs)/(home)/notice-detail' as any, - params: { id: String(item.id) }, - }) - } + onPress={() => guardedOpenNotice?.(item.id)} > {item.isPinned && ( @@ -168,7 +173,7 @@ export default function NoticesScreen() { {formatDate(item.createdAt)} ), - [], + [guardedOpenNotice], ); return ( diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index 46670ac..26cca02 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -1,7 +1,6 @@ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { ActivityIndicator, - Alert, FlatList, RefreshControl, StyleSheet, @@ -21,6 +20,7 @@ import type { AnchorPosition } from '@/components/ui/folder-card'; import { Colors, Typography } from '@/constants/theme'; import { useFolders } from '@/context/folders-context'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; +import { showAlert } from '@/utils/guarded-alert'; // ─── Screen ─────────────────────────────────────────────────────────────────── @@ -52,6 +52,7 @@ export default function SavedLinksScreen() { const [editingLink, setEditingLink] = useState(null); const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); + const deletingLinkIdsRef = useRef>(new Set()); const folderItems = useMemo( () => [ @@ -93,7 +94,7 @@ export default function SavedLinksScreen() { try { await toggleBookmark(id); } catch (error) { - Alert.alert( + showAlert( '북마크 변경 실패', getSavedLinkErrorMessage(error, '북마크 상태를 변경하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); @@ -104,21 +105,33 @@ export default function SavedLinksScreen() { const handleDelete = useCallback( (id: number) => { - Alert.alert('링크 삭제', '저장한 링크를 삭제할까요?', [ + if (deletingLinkIdsRef.current.has(id)) { + return; + } + + showAlert('링크 삭제', '저장한 링크를 삭제할까요?', [ { text: '취소', style: 'cancel' }, { text: '삭제', style: 'destructive', onPress: async () => { + if (deletingLinkIdsRef.current.has(id)) { + return; + } + + deletingLinkIdsRef.current.add(id); + try { await deleteLink(id); await refreshFolders(); setDeleteToastVisible(true); } catch (error) { - Alert.alert( + showAlert( '삭제 실패', getSavedLinkErrorMessage(error, '링크를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); + } finally { + deletingLinkIdsRef.current.delete(id); } }, }, diff --git a/app/(tabs)/(home)/scan-result-block.tsx b/app/(tabs)/(home)/scan-result-block.tsx index 3fd1be5..7e3a8f2 100644 --- a/app/(tabs)/(home)/scan-result-block.tsx +++ b/app/(tabs)/(home)/scan-result-block.tsx @@ -12,6 +12,7 @@ import { getAnalysisResultPath, getRouteParam, } from '@/utils/analysis-result-display'; +import { useGuardedPress } from '@/utils/press-guard'; export default function ScanResultBlockScreen() { const { @@ -25,6 +26,7 @@ export default function ScanResultBlockScreen() { const reason = getAnalysisReasonText(analysis, getMockScanResultReason('danger')); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'danger'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); + const guardedDismissAll = useGuardedPress(() => router.dismissAll()); useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'danger') { @@ -105,7 +107,7 @@ export default function ScanResultBlockScreen() { router.dismissAll()} + onPress={guardedDismissAll} activeOpacity={0.8} > 확인 diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index 6f96c8b..08e5d0b 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Alert, Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; @@ -15,6 +15,8 @@ import { getAnalysisResultPath, getRouteParam, } from '@/utils/analysis-result-display'; +import { showAlert } from '@/utils/guarded-alert'; +import { useGuardedPress } from '@/utils/press-guard'; export default function ScanResultCautionScreen() { const { @@ -30,6 +32,7 @@ export default function ScanResultCautionScreen() { const reason = getAnalysisReasonText(analysis, getMockScanResultReason('caution')); const [saveModalVisible, setSaveModalVisible] = useState(false); const [isSaving, setIsSaving] = useState(false); + const isSavingRef = useRef(false); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'caution'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); const saveAnalysisId = analysis?.analysisId ?? analysisId; @@ -56,10 +59,11 @@ export default function ScanResultCautionScreen() { }, [analysis, url]); const handleSave = async (title: string) => { - if (!canSave || !saveAnalysisId) { + if (!canSave || !saveAnalysisId || isSavingRef.current) { return; } + isSavingRef.current = true; setIsSaving(true); try { @@ -75,11 +79,12 @@ export default function ScanResultCautionScreen() { params: { savedLinkToast: String(Date.now()) }, }); } catch (error) { - Alert.alert( + showAlert( '저장 실패', getSavedLinkErrorMessage(error, '링크를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); } finally { + isSavingRef.current = false; setIsSaving(false); } }; @@ -93,6 +98,10 @@ export default function ScanResultCautionScreen() { } } }; + const guardedOpenSaveModal = useGuardedPress(() => setSaveModalVisible(true), { + disabled: !canSave || saveModalVisible, + }); + const guardedOpenUrl = useGuardedPress(handleOpenUrl, { disabled: !finalUrl }); if (isVerifyingAnalysis || shouldRedirectToVerdict) { return ( @@ -157,15 +166,15 @@ export default function ScanResultCautionScreen() { {/* 버튼 영역 */} setSaveModalVisible(true)} + style={[styles.cautionButton, (!canSave || saveModalVisible) && styles.disabledButton]} + onPress={guardedOpenSaveModal} activeOpacity={0.8} - disabled={!canSave} + disabled={!canSave || saveModalVisible} > 주의 후 저장 - + 즉시 URL 접속 diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx index 889a50b..313e315 100644 --- a/app/(tabs)/(home)/scan-result.tsx +++ b/app/(tabs)/(home)/scan-result.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Alert, Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; @@ -15,6 +15,8 @@ import { getAnalysisResultPath, getRouteParam, } from '@/utils/analysis-result-display'; +import { showAlert } from '@/utils/guarded-alert'; +import { useGuardedPress } from '@/utils/press-guard'; export default function ScanResultScreen() { const { @@ -30,6 +32,7 @@ export default function ScanResultScreen() { const reason = getAnalysisReasonText(analysis, getMockScanResultReason('safe')); const [saveModalVisible, setSaveModalVisible] = useState(false); const [isSaving, setIsSaving] = useState(false); + const isSavingRef = useRef(false); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'safe'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); const saveAnalysisId = analysis?.analysisId ?? analysisId; @@ -56,10 +59,11 @@ export default function ScanResultScreen() { }, [analysis, url]); const handleSave = async (title: string) => { - if (!canSave || !saveAnalysisId) { + if (!canSave || !saveAnalysisId || isSavingRef.current) { return; } + isSavingRef.current = true; setIsSaving(true); try { @@ -75,11 +79,12 @@ export default function ScanResultScreen() { params: { savedLinkToast: String(Date.now()) }, }); } catch (error) { - Alert.alert( + showAlert( '저장 실패', getSavedLinkErrorMessage(error, '링크를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); } finally { + isSavingRef.current = false; setIsSaving(false); } }; @@ -93,6 +98,10 @@ export default function ScanResultScreen() { } } }; + const guardedOpenSaveModal = useGuardedPress(() => setSaveModalVisible(true), { + disabled: !canSave || saveModalVisible, + }); + const guardedOpenUrl = useGuardedPress(handleOpenUrl, { disabled: !finalUrl }); if (isVerifyingAnalysis || shouldRedirectToVerdict) { return ( @@ -157,15 +166,15 @@ export default function ScanResultScreen() { {/* 버튼 영역 */} setSaveModalVisible(true)} + style={[styles.primaryButton, (!canSave || saveModalVisible) && styles.disabledButton]} + onPress={guardedOpenSaveModal} activeOpacity={0.8} - disabled={!canSave} + disabled={!canSave || saveModalVisible} > 저장 - + 즉시 URL 접속 diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx index a258e41..497598b 100644 --- a/app/(tabs)/(home)/scanning.tsx +++ b/app/(tabs)/(home)/scanning.tsx @@ -7,6 +7,7 @@ import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { fetchAnalysis, requestAnalysis, type AnalysisResponse, type AnalysisVerdict } from '@/api/analyses'; import { ApiError } from '@/api/api-client'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; const POLLING_INTERVAL_MS = 2_000; const MAX_POLLING_MS = 30_000; @@ -88,6 +89,8 @@ export default function ScanningScreen() { }, [isLoaded, isSignedIn, retryKey, url]); const hasError = errorMessage.length > 0; + const guardedRetry = useGuardedPress(() => setRetryKey((key) => key + 1)); + const guardedBack = useGuardedPress(() => router.back()); return ( <> @@ -138,14 +141,14 @@ export default function ScanningScreen() { setRetryKey((key) => key + 1)} + onPress={guardedRetry} activeOpacity={0.8} > 다시 검사 router.back()} + onPress={guardedBack} activeOpacity={0.8} > 돌아가기 diff --git a/app/(tabs)/(home)/settings.tsx b/app/(tabs)/(home)/settings.tsx index 27642ef..7eee27f 100644 --- a/app/(tabs)/(home)/settings.tsx +++ b/app/(tabs)/(home)/settings.tsx @@ -2,7 +2,7 @@ import { useAuth } from '@clerk/expo'; import Constants from 'expo-constants'; import { router } from 'expo-router'; import { useRef, useState } from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ApiError } from '@/api/api-client'; @@ -10,6 +10,8 @@ import { withdrawMember } from '@/api/members'; import { AppIcon } from '@/components/ui/app-icon'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors, Typography } from '@/constants/theme'; +import { showAlert } from '@/utils/guarded-alert'; +import { useGuardedPress } from '@/utils/press-guard'; const version = Constants.expoConfig?.version ?? '—'; type LoginNotice = 'withdrawal-complete' | 'session-expired'; @@ -27,10 +29,12 @@ interface SettingRowProps { } function SettingRow({ label, onPress, rightText, destructive = false, showChevron = false }: SettingRowProps) { + const guardedOnPress = useGuardedPress(onPress, { disabled: !onPress }); + return ( @@ -48,17 +52,37 @@ function SettingRow({ label, onPress, rightText, destructive = false, showChevro export default function SettingsScreen() { const { getToken, signOut } = useAuth(); const [isWithdrawing, setIsWithdrawing] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); const isWithdrawingRef = useRef(false); + const isLoggingOutRef = useRef(false); function handleLogout() { - Alert.alert('로그아웃', '로그아웃하시겠습니까?', [ + if (isLoggingOutRef.current) { + return; + } + + showAlert('로그아웃', '로그아웃하시겠습니까?', [ { text: '취소', style: 'cancel' }, { text: '로그아웃', style: 'destructive', onPress: async () => { - await signOut(); - router.replace('/(auth)/login'); + if (isLoggingOutRef.current) { + return; + } + + isLoggingOutRef.current = true; + setIsLoggingOut(true); + + try { + await signOut(); + router.replace('/(auth)/login'); + } catch (error) { + console.error(error); + isLoggingOutRef.current = false; + setIsLoggingOut(false); + showAlert('로그아웃 실패', '로그아웃 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.'); + } }, }, ]); @@ -69,7 +93,7 @@ export default function SettingsScreen() { return; } - Alert.alert( + showAlert( '회원탈퇴', '회원탈퇴하시겠습니까? 탈퇴 후에는 현재 계정으로 서비스를 이용할 수 없습니다.', [ @@ -106,7 +130,7 @@ export default function SettingsScreen() { isWithdrawingRef.current = false; setIsWithdrawing(false); - Alert.alert( + showAlert( '회원탈퇴 실패', '회원탈퇴 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.', ); @@ -181,7 +205,11 @@ export default function SettingsScreen() { {/* 계정 */} - + - + @@ -38,8 +40,15 @@ export default function RootLayout() { + ); } + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, +}); diff --git a/components/external-link.tsx b/components/external-link.tsx index 883e515..9ef1014 100644 --- a/components/external-link.tsx +++ b/components/external-link.tsx @@ -2,22 +2,28 @@ import { Href, Link } from 'expo-router'; import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; import { type ComponentProps } from 'react'; +import { useGuardedPress } from '@/utils/press-guard'; + type Props = Omit, 'href'> & { href: Href & string }; export function ExternalLink({ href, ...rest }: Props) { + const openExternalLink = useGuardedPress(async () => { + await openBrowserAsync(href, { + presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, + }); + }); + return ( { + onPress={(event) => { if (process.env.EXPO_OS !== 'web') { // Prevent the default behavior of linking to the default browser on native. event.preventDefault(); // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); + openExternalLink?.(); } }} /> diff --git a/components/ui/action-icon-button.tsx b/components/ui/action-icon-button.tsx index 0905257..44f2297 100644 --- a/components/ui/action-icon-button.tsx +++ b/components/ui/action-icon-button.tsx @@ -1,5 +1,6 @@ import { Pressable, StyleSheet, Text } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; import { IconSymbol } from './icon-symbol'; type Variant = 'delete' | 'erase'; @@ -28,10 +29,11 @@ export function ActionIconButton({ const labelColor = disabled ? Colors.brand.textHint : Colors.brand.textSecondary; const showIcon = icon; const showLabel = Boolean(label); + const guardedOnPress = useGuardedPress(onPress, { disabled }); return ( [ styles.container, pressed && !disabled && styles.pressed, diff --git a/components/ui/add-folder-button.tsx b/components/ui/add-folder-button.tsx index c66d23f..08aa5e3 100644 --- a/components/ui/add-folder-button.tsx +++ b/components/ui/add-folder-button.tsx @@ -1,6 +1,7 @@ import { StyleSheet, Text, TouchableOpacity, type TouchableOpacityProps } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; export interface AddFolderButtonProps extends Omit { label?: string; @@ -15,10 +16,12 @@ export function AddFolderButton({ onPress, ...rest }: AddFolderButtonProps) { + const guardedOnPress = useGuardedPress(onPress, { disabled }); + return ( [ styles.chip, containerStyle, diff --git a/components/ui/button.tsx b/components/ui/button.tsx index ac58d9a..edf41ae 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -8,6 +8,7 @@ import { } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; export type ButtonVariant = 'primary' | 'secondary' | 'ghost'; export type ButtonSize = 'large' | 'medium' | 'small'; @@ -34,6 +35,7 @@ export function Button({ ...rest }: ButtonProps) { const isDisabled = disabled || loading; + const guardedOnPress = useGuardedPress(onPress, { disabled: isDisabled }); return ( { event.stopPropagation(); - onBookmark?.(); + guardedBookmark?.(); }; const handleMorePress = (event: GestureResponderEvent) => { event.stopPropagation(); moreRef.current?.measure((_fx, _fy, width, height, px, py) => { - onMore?.({ x: px, y: py, width, height }); + guardedMore?.({ x: px, y: py, width, height }); }); }; @@ -76,20 +80,21 @@ export function CardLink({ } if (!openUrl) { - Alert.alert('URL을 열 수 없어요', '저장된 URL 정보가 없습니다.'); + showAlert('URL을 열 수 없어요', '저장된 URL 정보가 없습니다.'); return; } try { await Linking.openURL(openUrl); } catch { - Alert.alert('URL을 열 수 없어요', '잠시 후 다시 시도해주세요.'); + showAlert('URL을 열 수 없어요', '잠시 후 다시 시도해주세요.'); } }; + const guardedCardPress = useGuardedPress(handleCardPress, { disabled }); return ( [ styles.card, disabled && styles.cardDisabled, diff --git a/components/ui/check-icon.tsx b/components/ui/check-icon.tsx index f87884e..28ae5ee 100644 --- a/components/ui/check-icon.tsx +++ b/components/ui/check-icon.tsx @@ -1,5 +1,6 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; type Variant = 'checked' | 'unchecked'; @@ -42,10 +43,11 @@ export function CheckIcon({ }: CheckIconProps) { const isChecked = checked ?? variant === 'checked'; const labelColor = disabled ? Colors.brand.textHint : Colors.brand.textSecondary; + const guardedOnPress = useGuardedPress(onPress, { disabled }); return ( [ styles.container, pressed && !disabled && styles.pressed, diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx index 6345fde..cc1f957 100644 --- a/components/ui/collapsible.tsx +++ b/components/ui/collapsible.tsx @@ -6,16 +6,18 @@ import { ThemedView } from '@/components/themed-view'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; +import { useGuardedPress } from '@/utils/press-guard'; export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { const [isOpen, setIsOpen] = useState(false); const theme = useColorScheme() ?? 'light'; + const handleToggle = useGuardedPress(() => setIsOpen((value) => !value), { lockMs: 250 }); return ( setIsOpen((value) => !value)} + onPress={handleToggle} activeOpacity={0.8}> {items.map((item, index) => { @@ -41,7 +44,7 @@ function Dropdown({ items, selectedValue, onSelect }: DropdownProps) { return ( onSelect(item.value)} + onPress={() => guardedOnSelect?.(item.value)} style={({ pressed }) => [ dropdownStyles.item, isSelected && dropdownStyles.itemSelected, @@ -139,10 +142,12 @@ export function FilterChip({ onSelect?.(value); } + const guardedHandlePress = useGuardedPress(handlePress, { disabled, lockMs: 250 }); + return ( [ chipStyles.chip, disabled && chipStyles.chipDisabled, diff --git a/components/ui/folder-card.tsx b/components/ui/folder-card.tsx index 21c2bb2..347cfa9 100644 --- a/components/ui/folder-card.tsx +++ b/components/ui/folder-card.tsx @@ -2,6 +2,7 @@ import { useRef } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; import { AppIcon } from './app-icon'; export interface AnchorPosition { @@ -27,17 +28,19 @@ export function FolderCard({ disabled = false, }: FolderCardProps) { const moreRef = useRef(null); + const guardedOnPress = useGuardedPress(onPress, { disabled }); + const guardedOnMorePress = useGuardedPress(onMorePress, { disabled }); const handleMorePress = () => { moreRef.current?.measure((_fx, _fy, width, height, px, py) => { - onMorePress?.({ x: px, y: py, width, height }); + guardedOnMorePress?.({ x: px, y: py, width, height }); }); }; return ( [pressed && !disabled && styles.pressed]} - onPress={disabled ? undefined : onPress} + onPress={guardedOnPress} accessibilityRole="button" accessibilityLabel={`${folderName} 폴더, ${urlCount}개`} accessibilityState={{ disabled }} diff --git a/components/ui/folder-context-menu.tsx b/components/ui/folder-context-menu.tsx index 54dafb2..017cfab 100644 --- a/components/ui/folder-context-menu.tsx +++ b/components/ui/folder-context-menu.tsx @@ -1,5 +1,6 @@ import { Dimensions, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; import type { AnchorPosition } from './folder-card'; const MENU_WIDTH = 140; @@ -36,6 +37,11 @@ export function FolderContextMenu({ { label: '폴더명 수정', onPress: () => onEditName?.() }, { label: '폴더 삭제', onPress: () => onDelete?.(), destructive: true }, ]; + const guardedDismiss = useGuardedPress(onDismiss, { lockMs: 250 }); + const guardedItemPress = useGuardedPress((item: ContextMenuItem) => { + onDismiss?.(); + item.onPress(); + }); const menuHeight = MENU_ITEM_HEIGHT * resolvedItems.length + (resolvedItems.length - 1); @@ -56,17 +62,14 @@ export function FolderContextMenu({ animationType="fade" onRequestClose={onDismiss} > - + {resolvedItems.map((item, index) => ( {index > 0 && } [styles.menuItem, pressed && styles.menuItemPressed]} - onPress={() => { - onDismiss?.(); - item.onPress(); - }} + onPress={() => guardedItemPress?.(item)} accessibilityRole="button" accessibilityLabel={item.label} > diff --git a/components/ui/folder-icon.tsx b/components/ui/folder-icon.tsx index 7c4c391..039b2db 100644 --- a/components/ui/folder-icon.tsx +++ b/components/ui/folder-icon.tsx @@ -1,6 +1,7 @@ import { StyleSheet, Text, TouchableOpacity, type StyleProp, type ViewStyle } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; import { IconSymbol } from './icon-symbol'; export interface FolderIconProps { @@ -33,10 +34,11 @@ export function FolderIcon({ : active ? Colors.brand.text : Colors.brand.textSecondary; + const guardedOnPress = useGuardedPress(onPress, { disabled }); return ( - + @@ -95,7 +98,7 @@ export function LinkSaveModal({ @@ -104,7 +107,7 @@ export function LinkSaveModal({ diff --git a/components/ui/press-blocker.tsx b/components/ui/press-blocker.tsx new file mode 100644 index 0000000..c2c5f11 --- /dev/null +++ b/components/ui/press-blocker.tsx @@ -0,0 +1,22 @@ +import { StyleSheet, View } from 'react-native'; + +import { useBlockingInteractionActive } from '@/utils/press-guard'; + +export function PressBlocker() { + const isBlocking = useBlockingInteractionActive(); + + if (!isBlocking) { + return null; + } + + return ; +} + +const styles = StyleSheet.create({ + blocker: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'transparent', + elevation: 9999, + zIndex: 9999, + }, +}); diff --git a/components/ui/scan-button.tsx b/components/ui/scan-button.tsx index 28bbb48..07e2466 100644 --- a/components/ui/scan-button.tsx +++ b/components/ui/scan-button.tsx @@ -1,5 +1,6 @@ import { StyleSheet, Text, TouchableOpacity, type StyleProp, type ViewStyle } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; import { IconSymbol } from './icon-symbol'; interface ScanButtonProps { @@ -10,10 +11,11 @@ interface ScanButtonProps { export function ScanButton({ onPress, disabled = false, style }: ScanButtonProps) { const color = disabled ? Colors.brand.textHint : Colors.brand.primary; + const guardedOnPress = useGuardedPress(onPress, { disabled }); return ( {label} {rightSlot ?? (onViewAll && ( - + {viewAllLabel} ))} diff --git a/components/ui/social-login-button.tsx b/components/ui/social-login-button.tsx index f05790b..5069891 100644 --- a/components/ui/social-login-button.tsx +++ b/components/ui/social-login-button.tsx @@ -3,6 +3,7 @@ import { Image } from 'expo-image'; import { Pressable, StyleSheet, Text, View, type PressableProps } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; +import { useGuardedPress } from '@/utils/press-guard'; export type SocialLoginProvider = 'google' | 'apple'; @@ -13,16 +14,20 @@ interface SocialLoginButtonProps extends Omit [ styles.base, styles[provider], pressed && styles.pressed, - disabled && styles.disabled, + isDisabled && styles.disabled, ]} {...rest} > diff --git a/components/ui/title-edit-modal.tsx b/components/ui/title-edit-modal.tsx index 99a888d..66e39be 100644 --- a/components/ui/title-edit-modal.tsx +++ b/components/ui/title-edit-modal.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { - Alert, Keyboard, LayoutAnimation, Modal, @@ -14,10 +13,12 @@ import { View, type KeyboardEvent, } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, type SavedLink } from '@/context/saved-links-context'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { showAlert } from '@/utils/guarded-alert'; +import { useGuardedPress } from '@/utils/press-guard'; const MODAL_BOTTOM_GAP = 16; const KEYBOARD_TOP_GAP = 8; @@ -127,7 +128,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod clearTitleFocusTimer(); onClose(); } catch (error) { - Alert.alert( + showAlert( '제목 수정 실패', getSavedLinkErrorMessage(error, '제목을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); @@ -142,6 +143,12 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod trimmedTitleValue.length > 500 || trimmedTitleValue === editingLink?.title.trim() || isUpdatingTitle; + const guardedClose = useGuardedPress(handleClose, { disabled: isUpdatingTitle, lockMs: 250 }); + const guardedConfirm = useGuardedPress(handleConfirm, { disabled: titleSubmitDisabled }); + const guardedClearTitle = useGuardedPress(() => setTitleValue(''), { + disabled: isUpdatingTitle, + lockMs: 250, + }); const restingBottomInset = Math.max(insets.bottom, MODAL_BOTTOM_GAP); const modalBottomInset = keyboardInset > 0 ? keyboardInset + KEYBOARD_TOP_GAP @@ -152,7 +159,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod visible={editingLink !== null} transparent animationType="fade" - onRequestClose={handleClose} + onRequestClose={guardedClose} onShow={handleModalShow} > - + {titleValue.length > 0 && !isUpdatingTitle && ( setTitleValue('')} + onPress={guardedClearTitle} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={styles.clearButton} > @@ -195,14 +202,14 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod 취소 diff --git a/utils/guarded-alert.ts b/utils/guarded-alert.ts new file mode 100644 index 0000000..8537d83 --- /dev/null +++ b/utils/guarded-alert.ts @@ -0,0 +1,43 @@ +import { Alert, type AlertButton, type AlertOptions } from 'react-native'; + +import { beginBlockingInteraction, isBlockingInteractionActive } from '@/utils/press-guard'; + +export function showAlert( + title: string, + message?: string, + buttons?: AlertButton[], + options?: AlertOptions, +) { + if (isBlockingInteractionActive()) { + return; + } + + const releaseBlock = beginBlockingInteraction(); + let didHandleButton = false; + + const release = () => { + releaseBlock(); + }; + + const resolvedButtons = buttons && buttons.length > 0 ? buttons : [{ text: '확인' }]; + const guardedButtons = resolvedButtons.map((button) => ({ + ...button, + onPress: () => { + if (didHandleButton) { + return; + } + + didHandleButton = true; + release(); + button.onPress?.(); + }, + })); + + Alert.alert(title, message, guardedButtons, { + ...options, + onDismiss: () => { + release(); + options?.onDismiss?.(); + }, + }); +} diff --git a/utils/press-guard.ts b/utils/press-guard.ts new file mode 100644 index 0000000..ee7802f --- /dev/null +++ b/utils/press-guard.ts @@ -0,0 +1,121 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react'; + +export const DEFAULT_PRESS_GUARD_MS = 700; + +type GuardOptions = { + disabled?: boolean; + lockMs?: number; + allowWhileBlocked?: boolean; +}; + +let blockingInteractionCount = 0; +const listeners = new Set<() => void>(); + +function emitBlockingInteractionChange() { + listeners.forEach((listener) => listener()); +} + +function subscribeBlockingInteraction(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function isBlockingInteractionActive() { + return blockingInteractionCount > 0; +} + +export function beginBlockingInteraction() { + let released = false; + + blockingInteractionCount += 1; + emitBlockingInteractionChange(); + + return () => { + if (released) { + return; + } + + released = true; + blockingInteractionCount = Math.max(0, blockingInteractionCount - 1); + emitBlockingInteractionChange(); + }; +} + +export function useBlockingInteractionActive() { + return useSyncExternalStore( + subscribeBlockingInteraction, + isBlockingInteractionActive, + () => false, + ); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof value.then === 'function' + ); +} + +export function useGuardedPress( + handler: ((...args: TArgs) => unknown) | undefined, + { + disabled = false, + lockMs = DEFAULT_PRESS_GUARD_MS, + allowWhileBlocked = false, + }: GuardOptions = {}, +) { + const inFlightRef = useRef(false); + const lastPressAtRef = useRef(0); + const releaseTimerRef = useRef | null>(null); + + const guardedHandler = useCallback( + (...args: TArgs) => { + if (!handler || disabled || inFlightRef.current) { + return; + } + + if (!allowWhileBlocked && isBlockingInteractionActive()) { + return; + } + + const now = Date.now(); + + if (now - lastPressAtRef.current < lockMs) { + return; + } + + lastPressAtRef.current = now; + inFlightRef.current = true; + + if (releaseTimerRef.current) { + clearTimeout(releaseTimerRef.current); + releaseTimerRef.current = null; + } + + const release = () => { + inFlightRef.current = false; + }; + + try { + const result = handler(...args); + + if (isPromiseLike(result)) { + result.then(release, release); + return; + } + + releaseTimerRef.current = setTimeout(release, lockMs); + } catch (error) { + release(); + throw error; + } + }, + [allowWhileBlocked, disabled, handler, lockMs], + ); + + return handler ? guardedHandler : undefined; +}