From 1a1094d90dc6a13bdc6fd565f09ec87c270f486c Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 03:06:56 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EB=B2=84=ED=8A=BC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=9E=85=EB=A0=A5=20=EB=B0=8F=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=B0=BD=20=ED=84=B0=EC=B9=98=20=EA=B4=80=ED=86=B5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/login.tsx | 7 +- app/(tabs)/(folder)/[id].tsx | 22 +++- app/(tabs)/(folder)/folder-add-url.tsx | 11 +- app/(tabs)/(folder)/folder-name.tsx | 24 ++++- app/(tabs)/(folder)/folder-url-select.tsx | 15 +-- app/(tabs)/(folder)/index.tsx | 40 ++++--- app/(tabs)/(home)/add-link.tsx | 27 ++++- app/(tabs)/(home)/index.tsx | 21 +++- app/(tabs)/(home)/notices.tsx | 16 +-- app/(tabs)/(home)/saved-links.tsx | 23 +++- app/(tabs)/(home)/scan-result-block.tsx | 4 +- app/(tabs)/(home)/scan-result-caution.tsx | 25 +++-- app/(tabs)/(home)/scan-result.tsx | 25 +++-- app/(tabs)/(home)/scanning.tsx | 7 +- app/(tabs)/(home)/settings.tsx | 44 ++++++-- app/_layout.tsx | 11 +- components/external-link.tsx | 14 ++- components/ui/action-icon-button.tsx | 4 +- components/ui/add-folder-button.tsx | 5 +- components/ui/app-icon.tsx | 4 +- components/ui/bookmark-chip.tsx | 4 +- components/ui/button.tsx | 4 +- components/ui/card-link.tsx | 17 +-- components/ui/check-icon.tsx | 4 +- components/ui/collapsible.tsx | 4 +- components/ui/filter-chip.tsx | 9 +- components/ui/folder-card.tsx | 7 +- components/ui/folder-context-menu.tsx | 13 ++- components/ui/folder-icon.tsx | 4 +- components/ui/link-save-modal.tsx | 13 ++- components/ui/press-blocker.tsx | 22 ++++ components/ui/scan-button.tsx | 4 +- components/ui/section-header.tsx | 5 +- components/ui/social-login-button.tsx | 11 +- components/ui/title-edit-modal.tsx | 23 ++-- utils/guarded-alert.ts | 43 ++++++++ utils/press-guard.ts | 121 ++++++++++++++++++++++ 37 files changed, 533 insertions(+), 124 deletions(-) create mode 100644 components/ui/press-blocker.tsx create mode 100644 utils/guarded-alert.ts create mode 100644 utils/press-guard.ts 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 c2093bc..94a0c29 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, KeyboardAvoidingView, Modal, Platform, @@ -24,6 +23,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; @@ -60,6 +61,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 folderCreated = typeof folderCreatedParam === 'string' ? folderCreatedParam : undefined; @@ -91,23 +93,29 @@ 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); 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; + } + setRenameState({ visible: false, value: '', currentName: '' }); }; @@ -115,24 +123,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); } }, @@ -149,6 +159,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 }, + ); return ( @@ -238,13 +254,13 @@ export default function FolderScreen() { visible={renameState.visible} transparent animationType="fade" - onRequestClose={handleRenameCancel} + onRequestClose={guardedRenameCancel} > - + 폴더명 수정 @@ -256,13 +272,13 @@ export default function FolderScreen() { placeholder="폴더 이름 입력" placeholderTextColor={Colors.brand.textHint} returnKeyType="done" - onSubmitEditing={handleRenameConfirm} + onSubmitEditing={guardedRenameConfirm} maxLength={50} autoFocus /> {renameState.value.length > 0 && ( setRenameState((s) => ({ ...s, value: '' }))} + onPress={guardedClearRename} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={renameStyles.clearButton} > @@ -271,12 +287,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..efa9584 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; @@ -144,18 +145,19 @@ export default function NoticesScreen() { const handleRetry = useCallback(() => { startLoadNotices(); }, [startLoadNotices]); + const guardedOpenNotice = useGuardedPress((id: number) => + router.push({ + pathname: '/(tabs)/(home)/notice-detail' as any, + params: { id: String(id) }, + }), + ); 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 +170,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 ( - + @@ -75,7 +78,7 @@ export function LinkSaveModal({ placeholder="예: 네이버 블로그" placeholderTextColor={Colors.brand.textHint} returnKeyType="done" - onSubmitEditing={handleSave} + onSubmitEditing={guardedSave} maxLength={500} editable={!loading} autoFocus @@ -94,7 +97,7 @@ export function LinkSaveModal({ @@ -103,7 +106,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 4593c06..646bdd7 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, KeyboardAvoidingView, Modal, Platform, @@ -14,6 +13,8 @@ import { import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, type SavedLink } from '@/context/saved-links-context'; +import { showAlert } from '@/utils/guarded-alert'; +import { useGuardedPress } from '@/utils/press-guard'; interface TitleEditModalProps { editingLink: SavedLink | null; @@ -59,7 +60,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod await onConfirm(editingLink.id, trimmedTitle); onClose(); } catch (error) { - Alert.alert( + showAlert( '제목 수정 실패', getSavedLinkErrorMessage(error, '제목을 수정하지 못했습니다. 잠시 후 다시 시도해주세요.'), ); @@ -74,19 +75,25 @@ 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, + }); return ( - + 제목 수정 @@ -98,14 +105,14 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod placeholder="URL 제목 입력" placeholderTextColor={Colors.brand.textHint} returnKeyType="done" - onSubmitEditing={handleConfirm} + onSubmitEditing={guardedConfirm} maxLength={500} editable={!isUpdatingTitle} autoFocus /> {titleValue.length > 0 && !isUpdatingTitle && ( setTitleValue('')} + onPress={guardedClearTitle} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={styles.clearButton} > @@ -116,14 +123,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; +} From 3a6335db13762e2796ece6218098736b3a41965f Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 16:40:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=A7=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9D=B4=EB=8F=99=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/notices.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/(home)/notices.tsx b/app/(tabs)/(home)/notices.tsx index efa9584..ad01273 100644 --- a/app/(tabs)/(home)/notices.tsx +++ b/app/(tabs)/(home)/notices.tsx @@ -145,12 +145,15 @@ export default function NoticesScreen() { const handleRetry = useCallback(() => { startLoadNotices(); }, [startLoadNotices]); - const guardedOpenNotice = useGuardedPress((id: number) => + + 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 }) => ( From 6011b2029a1bb8fccc12e0e7846b8594532898c4 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 17:12:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 176 ++++++++++++++++++++------- components/ui/link-save-modal.tsx | 3 +- components/ui/title-edit-modal.tsx | 183 +++++++++++++++++++++-------- 3 files changed, 270 insertions(+), 92 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 94a0c29..e511c93 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, - KeyboardAvoidingView, + Keyboard, + LayoutAnimation, Modal, Platform, Pressable, @@ -11,8 +12,9 @@ import { TextInput, TouchableOpacity, View, + type KeyboardEvent, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { AddFolderButton } from '@/components/ui/add-folder-button'; import { FolderCard } from '@/components/ui/folder-card'; @@ -32,7 +34,30 @@ type MenuState = { folderId?: number; }; +const RENAME_MODAL_BOTTOM_GAP = 16; +const RENAME_KEYBOARD_TOP_GAP = 8; + +const showKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; +const hideKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + +const syncKeyboardLayoutAnimation = (event: KeyboardEvent) => { + if (Platform.OS !== 'ios') { + return; + } + + const duration = event.duration > 10 ? event.duration : 10; + + LayoutAnimation.configureNext({ + duration, + update: { + duration, + type: LayoutAnimation.Types[event.easing] || LayoutAnimation.Types.keyboard, + }, + }); +}; + export default function FolderScreen() { + const insets = useSafeAreaInsets(); const router = useRouter(); const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ folderCreated?: string | string[]; @@ -57,12 +82,14 @@ export default function FolderScreen() { currentName: '', }); const [isMutating, setIsMutating] = useState(false); + const [renameKeyboardInset, setRenameKeyboardInset] = useState(0); const [createToastVisible, setCreateToastVisible] = useState(false); 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; const folders = useMemo( @@ -83,11 +110,39 @@ export default function FolderScreen() { setCreateToastVisible(true); }, [folderCreated]); + useEffect(() => { + return () => { + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!renameState.visible) { + setRenameKeyboardInset(0); + return; + } + + const showSubscription = Keyboard.addListener(showKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setRenameKeyboardInset(event.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener(hideKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setRenameKeyboardInset(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [renameState.visible]); + const handleEditName = () => { if (menuState.folderId == null) return; const current = folders.find((f) => f.id === menuState.folderId)?.name ?? ''; setRenameState({ visible: true, folderId: menuState.folderId, value: current, currentName: current }); - setTimeout(() => renameInputRef.current?.focus(), 100); }; const handleRenameConfirm = async () => { @@ -98,6 +153,10 @@ export default function FolderScreen() { setIsMutating(true); try { await renameFolder(renameState.folderId, trimmed); + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + renameFocusTimerRef.current = null; + } setRenameState({ visible: false, value: '', currentName: '' }); setRenameToastVisible(true); } catch (error) { @@ -116,9 +175,25 @@ export default function FolderScreen() { return; } + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + renameFocusTimerRef.current = null; + } + setRenameState({ visible: false, value: '', currentName: '' }); }; + const handleRenameModalShow = () => { + if (renameFocusTimerRef.current) { + clearTimeout(renameFocusTimerRef.current); + } + + renameFocusTimerRef.current = setTimeout(() => { + renameInputRef.current?.focus(); + renameFocusTimerRef.current = null; + }, 100); + }; + const handleDelete = () => { if (menuState.folderId == null) return; const folderId = menuState.folderId; @@ -165,6 +240,10 @@ export default function FolderScreen() { () => 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 + : renameRestingBottomInset; return ( @@ -255,53 +334,61 @@ export default function FolderScreen() { transparent animationType="fade" onRequestClose={guardedRenameCancel} + onShow={handleRenameModalShow} > - - 폴더명 수정 - - setRenameState((s) => ({ ...s, value: v }))} - placeholder="폴더 이름 입력" - placeholderTextColor={Colors.brand.textHint} - returnKeyType="done" - onSubmitEditing={guardedRenameConfirm} - maxLength={50} - autoFocus - /> - {renameState.value.length > 0 && ( + + 폴더명 수정 + + setRenameState((s) => ({ ...s, value: v }))} + placeholder="폴더 이름 입력" + placeholderTextColor={Colors.brand.textHint} + returnKeyType="done" + onSubmitEditing={Keyboard.dismiss} + maxLength={50} + /> + {renameState.value.length > 0 && ( + + + + )} + + + + 취소 + - + + 저장 + - )} - - - - 취소 - - - - 저장 - - - + + - + ); @@ -389,9 +476,14 @@ const renameStyles = StyleSheet.create({ backgroundColor: Colors.brand.overlayBackdrop, }, sheet: { + width: '100%', + maxHeight: '80%', backgroundColor: Colors.brand.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, + overflow: 'hidden', + }, + sheetContent: { padding: 24, paddingBottom: 40, gap: 16, diff --git a/components/ui/link-save-modal.tsx b/components/ui/link-save-modal.tsx index 884cf58..bf4d680 100644 --- a/components/ui/link-save-modal.tsx +++ b/components/ui/link-save-modal.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + Keyboard, KeyboardAvoidingView, Modal, Platform, @@ -78,7 +79,7 @@ export function LinkSaveModal({ placeholder="예: 네이버 블로그" placeholderTextColor={Colors.brand.textHint} returnKeyType="done" - onSubmitEditing={guardedSave} + onSubmitEditing={Keyboard.dismiss} maxLength={500} editable={!loading} autoFocus diff --git a/components/ui/title-edit-modal.tsx b/components/ui/title-edit-modal.tsx index 646bdd7..66e39be 100644 --- a/components/ui/title-edit-modal.tsx +++ b/components/ui/title-edit-modal.tsx @@ -1,21 +1,47 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { - KeyboardAvoidingView, + Keyboard, + LayoutAnimation, Modal, Platform, Pressable, + ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, 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 { showAlert } from '@/utils/guarded-alert'; import { useGuardedPress } from '@/utils/press-guard'; +const MODAL_BOTTOM_GAP = 16; +const KEYBOARD_TOP_GAP = 8; + +const showKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; +const hideKeyboardEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + +const syncKeyboardLayoutAnimation = (event: KeyboardEvent) => { + if (Platform.OS !== 'ios') { + return; + } + + const duration = event.duration > 10 ? event.duration : 10; + + LayoutAnimation.configureNext({ + duration, + update: { + duration, + type: LayoutAnimation.Types[event.easing] || LayoutAnimation.Types.keyboard, + }, + }); +}; + interface TitleEditModalProps { editingLink: SavedLink | null; onConfirm: (id: number, title: string) => Promise; @@ -23,20 +49,51 @@ interface TitleEditModalProps { } export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditModalProps) { + const insets = useSafeAreaInsets(); const [titleValue, setTitleValue] = useState(''); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + const [keyboardInset, setKeyboardInset] = useState(0); const titleInputRef = useRef(null); + const titleFocusTimerRef = useRef | null>(null); + + const clearTitleFocusTimer = useCallback(() => { + if (titleFocusTimerRef.current) { + clearTimeout(titleFocusTimerRef.current); + titleFocusTimerRef.current = null; + } + }, []); useEffect(() => { if (!editingLink) { setTitleValue(''); + setKeyboardInset(0); + clearTitleFocusTimer(); return; } setTitleValue(editingLink.title); - const focusTimer = setTimeout(() => titleInputRef.current?.focus(), 100); + }, [clearTitleFocusTimer, editingLink]); + + useEffect(() => clearTitleFocusTimer, [clearTitleFocusTimer]); - return () => clearTimeout(focusTimer); + useEffect(() => { + if (!editingLink) { + return; + } + + const showSubscription = Keyboard.addListener(showKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setKeyboardInset(event.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener(hideKeyboardEvent, (event) => { + syncKeyboardLayoutAnimation(event); + setKeyboardInset(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; }, [editingLink]); const handleClose = useCallback(() => { @@ -44,8 +101,18 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod return; } + clearTitleFocusTimer(); onClose(); - }, [isUpdatingTitle, onClose]); + }, [clearTitleFocusTimer, isUpdatingTitle, onClose]); + + const handleModalShow = useCallback(() => { + clearTitleFocusTimer(); + + titleFocusTimerRef.current = setTimeout(() => { + titleInputRef.current?.focus(); + titleFocusTimerRef.current = null; + }, 100); + }, [clearTitleFocusTimer]); const handleConfirm = useCallback(async () => { const trimmedTitle = titleValue.trim(); @@ -58,6 +125,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod try { await onConfirm(editingLink.id, trimmedTitle); + clearTitleFocusTimer(); onClose(); } catch (error) { showAlert( @@ -67,7 +135,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod } finally { setIsUpdatingTitle(false); } - }, [editingLink, isUpdatingTitle, onClose, onConfirm, titleValue]); + }, [clearTitleFocusTimer, editingLink, isUpdatingTitle, onClose, onConfirm, titleValue]); const trimmedTitleValue = titleValue.trim(); const titleSubmitDisabled = @@ -81,6 +149,10 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod disabled: isUpdatingTitle, lockMs: 250, }); + const restingBottomInset = Math.max(insets.bottom, MODAL_BOTTOM_GAP); + const modalBottomInset = keyboardInset > 0 + ? keyboardInset + KEYBOARD_TOP_GAP + : restingBottomInset; return ( - - 제목 수정 - - - {titleValue.length > 0 && !isUpdatingTitle && ( + + 제목 수정 + + + {titleValue.length > 0 && !isUpdatingTitle && ( + + + + )} + + - + 취소 - )} - - - - 취소 - - - - {isUpdatingTitle ? '저장 중...' : '저장'} - - - + + + {isUpdatingTitle ? '저장 중...' : '저장'} + + + + - + ); } @@ -154,9 +234,14 @@ const styles = StyleSheet.create({ backgroundColor: Colors.brand.overlayBackdrop, }, sheet: { + width: '100%', + maxHeight: '80%', backgroundColor: Colors.brand.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, + overflow: 'hidden', + }, + sheetContent: { padding: 24, paddingBottom: 40, gap: 16, From 7079d6e9d3aa4ec82b4ae7b01459565d2c47ad21 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 29 May 2026 21:34:46 +0900 Subject: [PATCH 4/4] fix: remove leftover conflict markers --- app/(tabs)/(folder)/index.tsx | 39 ------------------------------ components/ui/title-edit-modal.tsx | 27 --------------------- 2 files changed, 66 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index ade2987..e511c93 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -1,10 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through -======= - Alert, ->>>>>>> dev Keyboard, LayoutAnimation, Modal, @@ -175,21 +171,15 @@ export default function FolderScreen() { }; const handleRenameCancel = () => { -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through if (isMutatingRef.current) { return; } -======= ->>>>>>> dev if (renameFocusTimerRef.current) { clearTimeout(renameFocusTimerRef.current); renameFocusTimerRef.current = null; } -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through -======= ->>>>>>> dev setRenameState({ visible: false, value: '', currentName: '' }); }; @@ -244,15 +234,12 @@ export default function FolderScreen() { trimmedRenameValue.length === 0 || trimmedRenameValue === renameState.currentName.trim() || isMutating; -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through 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 }, ); -======= ->>>>>>> dev const renameRestingBottomInset = Math.max(insets.bottom, RENAME_MODAL_BOTTOM_GAP); const renameModalBottomInset = renameKeyboardInset > 0 ? renameKeyboardInset + RENAME_KEYBOARD_TOP_GAP @@ -346,11 +333,7 @@ export default function FolderScreen() { visible={renameState.visible} transparent animationType="fade" -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through onRequestClose={guardedRenameCancel} -======= - onRequestClose={handleRenameCancel} ->>>>>>> dev onShow={handleRenameModalShow} > {renameState.value.length > 0 && ( setRenameState((s) => ({ ...s, value: '' }))} ->>>>>>> dev hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={renameStyles.clearButton} > @@ -394,7 +373,6 @@ export default function FolderScreen() { )} -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through 취소 @@ -407,23 +385,6 @@ export default function FolderScreen() { 저장 -======= - - 취소 - - - - 저장 - - ->>>>>>> dev diff --git a/components/ui/title-edit-modal.tsx b/components/ui/title-edit-modal.tsx index 1a82043..66e39be 100644 --- a/components/ui/title-edit-modal.tsx +++ b/components/ui/title-edit-modal.tsx @@ -1,9 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through -======= - Alert, ->>>>>>> dev Keyboard, LayoutAnimation, Modal, @@ -21,12 +17,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, type SavedLink } from '@/context/saved-links-context'; -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through import { showAlert } from '@/utils/guarded-alert'; import { useGuardedPress } from '@/utils/press-guard'; -======= -import { useSafeAreaInsets } from 'react-native-safe-area-context'; ->>>>>>> dev const MODAL_BOTTOM_GAP = 16; const KEYBOARD_TOP_GAP = 8; @@ -151,15 +143,12 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod trimmedTitleValue.length > 500 || trimmedTitleValue === editingLink?.title.trim() || isUpdatingTitle; -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through const guardedClose = useGuardedPress(handleClose, { disabled: isUpdatingTitle, lockMs: 250 }); const guardedConfirm = useGuardedPress(handleConfirm, { disabled: titleSubmitDisabled }); const guardedClearTitle = useGuardedPress(() => setTitleValue(''), { disabled: isUpdatingTitle, lockMs: 250, }); -======= ->>>>>>> dev const restingBottomInset = Math.max(insets.bottom, MODAL_BOTTOM_GAP); const modalBottomInset = keyboardInset > 0 ? keyboardInset + KEYBOARD_TOP_GAP @@ -170,11 +159,7 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod visible={editingLink !== null} transparent animationType="fade" -<<<<<<< fix/#56-prevent-duplicate-taps-alert-touch-through onRequestClose={guardedClose} -======= - onRequestClose={handleClose} ->>>>>>> dev onShow={handleModalShow} > {titleValue.length > 0 && !isUpdatingTitle && ( setTitleValue('')} ->>>>>>> dev hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={styles.clearButton} > @@ -221,22 +202,14 @@ export function TitleEditModal({ editingLink, onConfirm, onClose }: TitleEditMod >>>>>> dev disabled={isUpdatingTitle} > 취소 >>>>>> dev disabled={titleSubmitDisabled} >