diff --git a/app/(tabs)/(folder)/[id].tsx b/app/(tabs)/(folder)/[id].tsx index ad617d3..494c6b1 100644 --- a/app/(tabs)/(folder)/[id].tsx +++ b/app/(tabs)/(folder)/[id].tsx @@ -12,7 +12,7 @@ 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, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { showAlert } from '@/utils/guarded-alert'; type MoreMenuState = { @@ -26,7 +26,7 @@ export default function FolderDetailScreen() { const router = useRouter(); const folderId = Number(id); - const { links, toggleBookmark, assignCategory, updateTitle } = useSavedLinks(); + const { links, bookmarkingLinkIds, toggleBookmark, assignCategory, updateTitle } = useSavedLinks(); const { folders, refreshFolders } = useFolders(); const folderLinks = links.filter((l) => l.categoryId === folderId); const folderName = folders.find((f) => f.id === folderId)?.name ?? '폴더'; @@ -46,6 +46,20 @@ export default function FolderDetailScreen() { setMenuState({ visible: true, anchor, linkId }); }; + const handleBookmark = useCallback( + async (linkId: number) => { + try { + await toggleBookmark(linkId); + } catch (error) { + showAlert( + '북마크 변경 실패', + getSavedLinkErrorMessage(error, '북마크 상태를 변경하지 못했습니다. 잠시 후 다시 시도해주세요.'), + ); + } + }, + [toggleBookmark], + ); + const handleDelete = async (linkId: number) => { if (deletingFromFolderIdsRef.current.has(linkId)) { return; @@ -150,7 +164,8 @@ export default function FolderDetailScreen() { originalUrl={link.originalUrl} finalUrl={link.finalUrl} bookmarked={link.isBookmarked} - onBookmark={() => toggleBookmark(link.id)} + bookmarkDisabled={bookmarkingLinkIds.has(link.id)} + onBookmark={() => handleBookmark(link.id)} onMore={(anchor: AnchorPosition) => handleMore(link.id, anchor)} /> ))} diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 781898d..9e2454b 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -60,7 +60,7 @@ export default function HomeScreen() { }>(); const { width: windowWidth } = useWindowDimensions(); const tabBarHeight = useBottomTabBarHeight(); - const { links, toggleBookmark, deleteLink, updateTitle } = useSavedLinks(); + const { links, bookmarkingLinkIds, toggleBookmark, deleteLink, updateTitle } = useSavedLinks(); const { refreshFolders } = useFolders(); const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false }); const [editingLink, setEditingLink] = useState(null); @@ -284,9 +284,8 @@ export default function HomeScreen() { originalUrl={link.originalUrl} finalUrl={link.finalUrl} bookmarked={link.isBookmarked} - onBookmark={() => { - void handleBookmark(link.id); - }} + bookmarkDisabled={bookmarkingLinkIds.has(link.id)} + onBookmark={() => handleBookmark(link.id)} onMore={(anchor) => handleMore(link.id, anchor)} /> ))} diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx index 26cca02..37375ff 100644 --- a/app/(tabs)/(home)/saved-links.tsx +++ b/app/(tabs)/(home)/saved-links.tsx @@ -33,6 +33,7 @@ export default function SavedLinksScreen() { hasNext, refreshLinks, loadMoreLinks, + bookmarkingLinkIds, toggleBookmark, deleteLink, updateTitle, @@ -227,9 +228,8 @@ export default function SavedLinksScreen() { originalUrl={item.originalUrl} finalUrl={item.finalUrl} bookmarked={item.isBookmarked} - onBookmark={() => { - void handleBookmark(item.id); - }} + bookmarkDisabled={bookmarkingLinkIds.has(item.id)} + onBookmark={() => handleBookmark(item.id)} onMore={(anchor) => openMoreMenu(item, anchor)} /> )} diff --git a/components/ui/card-link.tsx b/components/ui/card-link.tsx index 1d902e8..846075c 100644 --- a/components/ui/card-link.tsx +++ b/components/ui/card-link.tsx @@ -19,7 +19,8 @@ export interface CardLinkProps { bookmarked?: boolean; icon?: boolean; disabled?: boolean; - onBookmark?: () => void; + bookmarkDisabled?: boolean; + onBookmark?: () => void | Promise; onMore?: (anchor: AnchorPosition) => void; onPress?: () => void; } @@ -46,6 +47,7 @@ export function CardLink({ bookmarked = false, icon = true, disabled = false, + bookmarkDisabled = false, onBookmark, onMore, onPress, @@ -57,7 +59,8 @@ export function CardLink({ const normalizedVerdict = verdict && verdict in VERDICT_LABELS ? verdict : undefined; const statusLabel = normalizedVerdict ? VERDICT_LABELS[normalizedVerdict] : (getFirstText(label) ?? '결과 없음'); const statusColors = normalizedVerdict ? VERDICT_COLORS[normalizedVerdict] : undefined; - const guardedBookmark = useGuardedPress(onBookmark, { disabled }); + const isBookmarkDisabled = disabled || bookmarkDisabled; + const guardedBookmark = useGuardedPress(onBookmark, { disabled: isBookmarkDisabled }); const guardedMore = useGuardedPress(onMore, { disabled }); const handleBookmarkPress = (event: GestureResponderEvent) => { @@ -124,14 +127,19 @@ export function CardLink({ {icon && ( pressed && !disabled && styles.pressed} + style={({ pressed }) => [ + pressed && !isBookmarkDisabled && styles.pressed, + bookmarkDisabled && styles.bookmarkDisabled, + ]} + accessibilityRole="button" + accessibilityState={{ disabled: isBookmarkDisabled }} > ; hasNext: boolean; nextCursor: string | null; refreshLinks: (query?: SavedLinkListQuery) => Promise; @@ -47,10 +50,14 @@ export function SavedLinksProvider({ children }: { children: React.ReactNode }) const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [bookmarkingLinkIds, setBookmarkingLinkIds] = useState>( + () => new Set(), + ); const [hasNext, setHasNext] = useState(false); const [nextCursor, setNextCursor] = useState(null); const lastQueryRef = useRef({ size: DEFAULT_PAGE_SIZE }); const getTokenRef = useRef(getToken); + const bookmarkingLinkIdsRef = useRef>(new Set()); useEffect(() => { getTokenRef.current = getToken; @@ -138,7 +145,14 @@ export function SavedLinksProvider({ children }: { children: React.ReactNode }) }, []); const toggleBookmark = useCallback(async (id: number) => { - const previousLinks = links; + if (bookmarkingLinkIdsRef.current.has(id)) { + return; + } + + bookmarkingLinkIdsRef.current.add(id); + setBookmarkingLinkIds(new Set(bookmarkingLinkIdsRef.current)); + + const previousIsBookmarked = links.find((link) => link.id === id)?.isBookmarked; setLinks((prev) => prev.map((link) => link.id === id ? { ...link, isBookmarked: !link.isBookmarked } : link, @@ -153,8 +167,18 @@ export function SavedLinksProvider({ children }: { children: React.ReactNode }) ), ); } catch (error) { - setLinks(previousLinks); + if (previousIsBookmarked != null) { + setLinks((prev) => + prev.map((link) => + link.id === id ? { ...link, isBookmarked: previousIsBookmarked } : link, + ), + ); + } + throw error; + } finally { + bookmarkingLinkIdsRef.current.delete(id); + setBookmarkingLinkIds(new Set(bookmarkingLinkIdsRef.current)); } }, [links]); @@ -223,6 +247,7 @@ export function SavedLinksProvider({ children }: { children: React.ReactNode }) isLoading, isLoadingMore, errorMessage, + bookmarkingLinkIds, hasNext, nextCursor, refreshLinks, @@ -251,6 +276,10 @@ export function getSavedLinkErrorMessage(error: unknown, fallbackMessage: string return LOGIN_REQUIRED_MESSAGE; } + if (error.message === 'Network request failed') { + return NETWORK_REQUEST_FAILED_MESSAGE; + } + if (error.message) { return error.message; }