diff --git a/src/app/(app)/index.error.test.tsx b/src/app/(app)/index.error.test.tsx new file mode 100644 index 0000000..3bcef25 --- /dev/null +++ b/src/app/(app)/index.error.test.tsx @@ -0,0 +1,123 @@ +jest.mock('react-native-mmkv', () => ({ + createMMKV: jest.fn(() => ({ + set: jest.fn(), + getString: jest.fn(), + getAllKeys: jest.fn().mockReturnValue([]), + remove: jest.fn(), + })), +})); + +jest.mock('react-native-flash-message', () => ({ + showMessage: jest.fn(), +})); + +// FlashList → FlatList passthrough so list items actually render in tests. +// (The old '@shopify/flash-list/jestSetup' mocked it to a v1-only export that +// no longer exists in v2, i.e. undefined.) +jest.mock('@shopify/flash-list', () => { + const React: typeof import('react') = jest.requireActual('react'); + const { FlatList } = jest.requireActual('react-native'); + const FlashList = (props: React.ComponentProps) => ; + return { FlashList }; +}); + +jest.mock('expo-router', () => ({ + Link: 'Link', + useLocalSearchParams: jest.fn().mockReturnValue({}), + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock('@react-native-menu/menu', () => { + const React: typeof import('react') = jest.requireActual('react'); + const MenuView = ({ children }: { children?: React.ReactNode }) => children ?? null; + return { MenuView }; +}); + +jest.mock('@/lib/hooks/use-download-settings', () => ({ + useDownloadSettings: () => ({ downloadPath: 'videos' }), +})); + +// Per-test state of the videos query. +const mockQueryState: { + data: { pages: { data: unknown[] }[] } | undefined; + isError: boolean; + error: unknown; +} = { + data: undefined, + isError: false, + error: null, +}; + +jest.mock('@/api/video-queries', () => ({ + useVideos: () => ({ + data: mockQueryState.data, + isPending: false, + isError: mockQueryState.isError, + error: mockQueryState.error, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + refetch: jest.fn(), + isRefetching: false, + }), +})); + +import { cleanup, render, screen } from '@/lib/test-utils'; +import { showMessage } from 'react-native-flash-message'; + +import Home from './index'; + +const sampleVideo = { + id: 'v1', + slug: 'v-1', + title: 'Loaded Video', + thumbnail: '', + duration: '1:00', + views: '1', + rating: '100%', +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockQueryState.data = undefined; + mockQueryState.isError = false; + mockQueryState.error = null; +}); + +afterEach(cleanup); + +describe('Home screen — fetch error resilience', () => { + it('keeps the loaded feed and toasts when a page fetch fails', async () => { + // Pages 1-6 loaded; page 7 came back 502 (data is retained by the query). + mockQueryState.data = { pages: [{ data: [sampleVideo] }] }; + mockQueryState.isError = true; + const error = new Error( + 'Failed to fetch https://rule34video.com/latest-updates/7/: 502' + ) as Error & { status?: number }; + error.status = 502; + mockQueryState.error = error; + + render(); + + // The feed item is still on screen; the full error page is not. + expect(await screen.findByText('Loaded Video')).toBeTruthy(); + expect(screen.queryByText('Error loading videos')).toBeNull(); + // The classified toast fired (en locale in tests). + expect(showMessage).toHaveBeenCalledTimes(1); + const call = (showMessage as jest.Mock).mock.calls[0][0]; + expect(call.message).toContain('502'); + expect(call.position).toBe('center'); + }); + + it('shows the full error screen (no toast) when the feed never loaded', () => { + mockQueryState.data = undefined; + mockQueryState.isError = true; + mockQueryState.error = new Error('Failed to fetch: 502'); + + render(); + + expect(screen.getByText('Error loading videos')).toBeTruthy(); + expect(screen.queryByText('Loaded Video')).toBeNull(); + expect(showMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index a967f8b..891259b 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -8,6 +8,7 @@ import { SafeAreaView, Text } from '@/components/ui'; import { VideoTile } from '@/components/video-tile'; import { flattenUniquePages } from '@/lib/flatten-pages'; import { useColumns } from '@/lib/hooks/use-columns'; +import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast'; import type { VideoListItem } from '@/lib/r34/types'; export default function Home(): React.ReactElement { @@ -18,6 +19,7 @@ export default function Home(): React.ReactElement { data, isPending, isError, + error, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -33,12 +35,16 @@ export default function Home(): React.ReactElement { return flattenUniquePages(data?.pages, (item) => item.id); }, [data]); + // Keep the loaded feed on fetch errors (e.g. a 502 on page 7) and say what + // went wrong via toast; the full error screen is only for an empty feed. + useFetchErrorToast(isError, error, videos.length > 0); + const renderItem = React.useCallback( ({ item }: { item: VideoListItem }) => , [] ); - if (isError) { + if (isError && videos.length === 0) { return ( Error loading videos diff --git a/src/app/(app)/search.tsx b/src/app/(app)/search.tsx index 331e242..00a1472 100644 --- a/src/app/(app)/search.tsx +++ b/src/app/(app)/search.tsx @@ -9,6 +9,7 @@ import { VideoTile } from '@/components/video-tile'; import { useTranslate } from '@/lib'; import { flattenUniquePages } from '@/lib/flatten-pages'; import { useColumns } from '@/lib/hooks/use-columns'; +import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast'; import { useSearchHistory } from '@/lib/hooks/use-search-history'; import type { Post } from '@/lib/r34/extractor'; import { useTagStore } from '@/lib/stores/tag-store'; @@ -49,7 +50,7 @@ export default function SearchScreen() { const { getHistory, addHistory, removeHistory, clearHistory } = useSearchHistory(); const { favoriteTags } = useTagStore(); - const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = + const { data, isPending, isError, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useSearch(submittedQuery); const numColumns = useColumns(); @@ -57,6 +58,10 @@ export default function SearchScreen() { return flattenUniquePages(data?.pages, (item) => item.id); }, [data]); + // Search errors used to fail silently — surface them via toast while + // keeping whatever results are already on screen. + useFetchErrorToast(isError, error, posts.length > 0); + const [history, setHistory] = React.useState(getHistory()); const handleSubmit = () => { diff --git a/src/app/author/[id].tsx b/src/app/author/[id].tsx index 338df10..199146c 100644 --- a/src/app/author/[id].tsx +++ b/src/app/author/[id].tsx @@ -8,6 +8,7 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui'; import { VideoTile } from '@/components/video-tile'; import { flattenUniquePages } from '@/lib/flatten-pages'; import { useColumns } from '@/lib/hooks/use-columns'; +import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast'; import type { VideoListItem } from '@/lib/r34/types'; import { useFollowingStore } from '@/lib/stores/following-store'; @@ -17,19 +18,30 @@ export default function AuthorPage(): React.ReactElement { const followed = isFollowing(id); const numColumns = useColumns(); - const { data, isPending, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = - useMemberVideos({ variables: { memberId: id } }); + const { + data, + isPending, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useMemberVideos({ variables: { memberId: id } }); const videos = React.useMemo(() => { return flattenUniquePages(data?.pages, (item) => item.id); }, [data]); + // Keep loaded videos on fetch errors; toast says what went wrong. + useFetchErrorToast(isError, error, videos.length > 0); + const renderItem = React.useCallback( ({ item }: { item: VideoListItem }) => , [] ); - if (isError) { + if (isError && videos.length === 0) { return ( diff --git a/src/app/category/[name].tsx b/src/app/category/[name].tsx index f25c814..c3ef246 100644 --- a/src/app/category/[name].tsx +++ b/src/app/category/[name].tsx @@ -8,13 +8,14 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui'; import { VideoTile } from '@/components/video-tile'; import { flattenUniquePages } from '@/lib/flatten-pages'; import { useColumns } from '@/lib/hooks/use-columns'; +import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast'; import type { VideoListItem } from '@/lib/r34/types'; export default function CategoryPage(): React.ReactElement { const { name } = useLocalSearchParams<{ name: string }>(); const numColumns = useColumns(); - const { data, isPending, isError, refetch, isRefetching } = useVideos({ + const { data, isPending, isError, error, refetch, isRefetching } = useVideos({ variables: { category: name }, }); @@ -22,12 +23,15 @@ export default function CategoryPage(): React.ReactElement { return flattenUniquePages(data?.pages, (item) => item.id); }, [data]); + // Keep loaded videos on fetch errors; toast says what went wrong. + useFetchErrorToast(isError, error, videos.length > 0); + const renderItem = React.useCallback( ({ item }: { item: VideoListItem }) => , [] ); - if (isError) { + if (isError && videos.length === 0) { return ( diff --git a/src/app/model/[slug].tsx b/src/app/model/[slug].tsx index edf415d..d1c04a7 100644 --- a/src/app/model/[slug].tsx +++ b/src/app/model/[slug].tsx @@ -8,6 +8,7 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui'; import { VideoTile } from '@/components/video-tile'; import { flattenUniquePages } from '@/lib/flatten-pages'; import { useColumns } from '@/lib/hooks/use-columns'; +import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast'; import type { VideoListItem } from '@/lib/r34/types'; export default function ModelPage(): React.ReactElement { @@ -18,6 +19,7 @@ export default function ModelPage(): React.ReactElement { data, isPending, isError, + error, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -31,6 +33,9 @@ export default function ModelPage(): React.ReactElement { return flattenUniquePages(data?.pages, (item) => item.id); }, [data]); + // Keep loaded videos on fetch errors; toast says what went wrong. + useFetchErrorToast(isError, error, videos.length > 0); + const renderItem = React.useCallback( ({ item }: { item: VideoListItem }) => , [] @@ -50,7 +55,7 @@ export default function ModelPage(): React.ReactElement { [slug, videos.length] ); - if (isError) { + if (isError && videos.length === 0) { return ( diff --git a/src/app/tag/[id].tsx b/src/app/tag/[id].tsx index e055c04..f680f7b 100644 --- a/src/app/tag/[id].tsx +++ b/src/app/tag/[id].tsx @@ -8,6 +8,7 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui'; import { VideoTile } from '@/components/video-tile'; import { flattenUniquePages } from '@/lib/flatten-pages'; import { useColumns } from '@/lib/hooks/use-columns'; +import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast'; import type { Post } from '@/lib/r34/extractor'; import { useTagStore } from '@/lib/stores/tag-store'; @@ -17,16 +18,27 @@ export default function TagPage(): React.ReactElement { const favorited = isFavorite(name ?? id); const numColumns = useColumns(); - const { data, isPending, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = - useTagVideos(id); + const { + data, + isPending, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTagVideos(id); const posts = React.useMemo(() => { return flattenUniquePages(data?.pages, (item) => item.id); }, [data]); + // Keep loaded posts on fetch errors; toast says what went wrong. + useFetchErrorToast(isError, error, posts.length > 0); + const renderItem = React.useCallback(({ item }: { item: Post }) => , []); - if (isError) { + if (isError && posts.length === 0) { return ( diff --git a/src/lib/hooks/use-fetch-error-toast.ts b/src/lib/hooks/use-fetch-error-toast.ts new file mode 100644 index 0000000..322999d --- /dev/null +++ b/src/lib/hooks/use-fetch-error-toast.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; + +import { showFetchErrorToast } from '@/lib/r34/fetch-error'; + +/** + * Toasts a classified fetch error while the screen keeps its already-loaded + * content. `enabled` should reflect "content is on screen" — when there is + * nothing to keep, the screen renders its full error state instead and the + * toast would be noise. Call before any early returns (it's a hook). + */ +export function useFetchErrorToast(isError: boolean, error: unknown, enabled: boolean): void { + React.useEffect(() => { + if (isError && enabled) { + showFetchErrorToast(error); + } + }, [isError, error, enabled]); +} diff --git a/src/lib/r34/fetch-error.test.ts b/src/lib/r34/fetch-error.test.ts new file mode 100644 index 0000000..5bd6202 --- /dev/null +++ b/src/lib/r34/fetch-error.test.ts @@ -0,0 +1,80 @@ +jest.mock('react-native-flash-message', () => ({ + showMessage: jest.fn(), +})); + +import { showMessage } from 'react-native-flash-message'; + +import { resources } from '@/lib/i18n/resources'; + +import { classifyFetchError, showFetchErrorToast } from './fetch-error'; + +const httpError = (status: number) => { + const error = new Error(`Failed to fetch https://rule34video.com/x/: ${status}`) as Error & { + status?: number; + }; + error.status = status; + return error; +}; + +describe('classifyFetchError', () => { + it('classifies HTTP statuses', () => { + expect(classifyFetchError(httpError(502))).toBe('server'); + expect(classifyFetchError(httpError(503))).toBe('server'); + expect(classifyFetchError(httpError(429))).toBe('rate-limited'); + expect(classifyFetchError(httpError(404))).toBe('not-found'); + expect(classifyFetchError(httpError(410))).toBe('not-found'); + expect(classifyFetchError(httpError(403))).toBe('http'); + }); + + it('classifies transport failures by message', () => { + expect(classifyFetchError(new Error('Request timed out: https://x/'))).toBe('timeout'); + expect(classifyFetchError(new Error('Network request failed'))).toBe('offline'); + expect(classifyFetchError(new TypeError('Network request failed'))).toBe('offline'); + }); + + it('falls back to offline for unknown shapes', () => { + expect(classifyFetchError(null)).toBe('offline'); + expect(classifyFetchError('boom')).toBe('offline'); + }); +}); + +describe('showFetchErrorToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const en = resources.en.translation.net; + const lastCall = () => (showMessage as jest.Mock).mock.calls[0]?.[0]; + + it('toasts a localized server message carrying the status', () => { + showFetchErrorToast(httpError(502)); + + expect(lastCall().message).toBe(en.server.replace('{{status}}', '502')); + expect(lastCall().type).toBe('warning'); + expect(lastCall().position).toBe('center'); + }); + + it('toasts an offline message without a status for transport failures', () => { + showFetchErrorToast(new Error('Network request failed')); + + expect(lastCall().message).toBe(en.offline); + }); + + it('toasts a timeout message for aborted requests', () => { + showFetchErrorToast(new Error('Request timed out: https://x/')); + + expect(lastCall().message).toBe(en.timeout); + }); + + it('toasts a rate-limit message', () => { + showFetchErrorToast(httpError(429)); + + expect(lastCall().message).toBe(en.rate_limited); + }); + + it('toasts a not-found message without a status suffix', () => { + showFetchErrorToast(httpError(404)); + + expect(lastCall().message).toBe(en.not_found); + }); +}); diff --git a/src/lib/r34/fetch-error.ts b/src/lib/r34/fetch-error.ts new file mode 100644 index 0000000..91b5e76 --- /dev/null +++ b/src/lib/r34/fetch-error.ts @@ -0,0 +1,53 @@ +import i18n from '@/lib/i18n'; +import { showMessage } from 'react-native-flash-message'; + +/** + * Classification of a failed site fetch, so the UI can say what actually went + * wrong instead of a generic "error". `fetchPage` attaches the HTTP `status` + * to its errors and normalises aborts to "Request timed out: …"; anything + * without a status is a transport-level failure (offline, DNS, reset). + */ +export type FetchErrorKind = + | 'offline' + | 'timeout' + | 'rate-limited' + | 'not-found' + | 'server' + | 'http'; + +export function classifyFetchError(error: unknown): FetchErrorKind { + const status = (error as (Error & { status?: number }) | null)?.status; + if (typeof status === 'number') { + if (status === 429 || status === 418) return 'rate-limited'; + if (status === 404 || status === 410) return 'not-found'; + if (status >= 500) return 'server'; + return 'http'; + } + const message = error instanceof Error ? error.message : ''; + if (message.startsWith('Request timed out')) return 'timeout'; + return 'offline'; +} + +const MESSAGE_KEYS: Record = { + offline: 'net.offline', + timeout: 'net.timeout', + 'rate-limited': 'net.rate_limited', + 'not-found': 'net.not_found', + server: 'net.server', + http: 'net.http_error', +}; + +/** + * Small centered toast describing a failed fetch. Messages are classified + * (see `classifyFetchError`) and localized; server/other-HTTP messages carry + * the status code, e.g. "The site is having trouble (502) — try again later". + */ +export function showFetchErrorToast(error: unknown): void { + const kind = classifyFetchError(error); + const status = (error as (Error & { status?: number }) | null)?.status; + const translate = i18n.t.bind(i18n) as (key: string, options?: Record) => string; + const message = translate(MESSAGE_KEYS[kind], { + ...(typeof status === 'number' ? { status: String(status) } : {}), + }); + showMessage({ message, type: 'warning', position: 'center', duration: 2500 }); +} diff --git a/src/lib/test-utils.tsx b/src/lib/test-utils.tsx index eca9263..76c4fe8 100644 --- a/src/lib/test-utils.tsx +++ b/src/lib/test-utils.tsx @@ -1,5 +1,3 @@ -import '@shopify/flash-list/jestSetup'; - import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import type { RenderOptions } from '@testing-library/react-native'; import { render, userEvent } from '@testing-library/react-native'; diff --git a/src/translations/en.json b/src/translations/en.json index e511622..f423c78 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -99,5 +99,13 @@ "video_removed": "This video has been removed from the site", "site_unreachable": "Couldn't reach the site — check your connection" }, + "net": { + "offline": "You appear to be offline — check your connection", + "timeout": "Network timed out — try again shortly", + "rate_limited": "Too many requests — try again soon", + "server": "The site is having trouble ({{status}}) — try again later", + "not_found": "This content no longer exists", + "http_error": "Request failed ({{status}}) — try again later" + }, "welcome": "Welcome to obytes app site" } diff --git a/src/translations/es.json b/src/translations/es.json index de4ac71..ba08085 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -99,5 +99,13 @@ "video_removed": "Este vídeo ha sido eliminado del sitio", "site_unreachable": "No se puede acceder al sitio; comprueba tu conexión" }, + "net": { + "offline": "Parece que no tienes conexión; comprueba tu red", + "timeout": "Se agotó el tiempo de espera; inténtalo de nuevo en un momento", + "rate_limited": "Demasiadas solicitudes; inténtalo de nuevo en unos momentos", + "server": "El sitio tiene problemas ({{status}}); inténtalo más tarde", + "not_found": "Este contenido ya no existe", + "http_error": "Error en la solicitud ({{status}}); inténtalo más tarde" + }, "welcome": "Bienvenido a la aplicación obytes" } diff --git a/src/translations/ja.json b/src/translations/ja.json index 3f801f9..d92d6e1 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -99,5 +99,13 @@ "video_removed": "この動画はサイトから削除されました", "site_unreachable": "サイトに接続できません。ネットワークを確認してください" }, + "net": { + "offline": "ネットワークに接続できません。接続を確認してください", + "timeout": "ネットワークがタイムアウトしました。しばらくしてから再試行してください", + "rate_limited": "リクエストが多すぎます。しばらく待ってから再試行してください", + "server": "サーバーが一時的に利用できません({{status}})。しばらくしてから再試行してください", + "not_found": "コンテンツが存在しないか、削除されました", + "http_error": "リクエストに失敗しました({{status}})。しばらくしてから再試行してください" + }, "welcome": "obytes アプリへようこそ" } diff --git a/src/translations/ko.json b/src/translations/ko.json index 936f509..f6fe085 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -99,5 +99,13 @@ "video_removed": "이 영상은 사이트에서 삭제되었습니다", "site_unreachable": "사이트에 연결할 수 없습니다. 네트워크를 확인하세요" }, + "net": { + "offline": "네트워크에 연결할 수 없습니다. 연결을 확인하세요", + "timeout": "네트워크 시간이 초과되었습니다. 잠시 후 다시 시도하세요", + "rate_limited": "요청이 너무 많습니다. 잠시 후 다시 시도하세요", + "server": "사이트에 일시적인 문제가 있습니다({{status}}). 잠시 후 다시 시도하세요", + "not_found": "콘텐츠가 없거나 삭제되었습니다", + "http_error": "요청이 실패했습니다({{status}}). 잠시 후 다시 시도하세요" + }, "welcome": "obytes 앱에 오신 것을 환영합니다" } diff --git a/src/translations/pt.json b/src/translations/pt.json index 11e1b96..88754c0 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -99,5 +99,13 @@ "video_removed": "Este vídeo foi removido do site", "site_unreachable": "Não foi possível acessar o site; verifique sua conexão" }, + "net": { + "offline": "Você parece estar offline; verifique sua conexão", + "timeout": "A rede expirou; tente novamente em instantes", + "rate_limited": "Muitas solicitações; tente novamente em breve", + "server": "O site está com problemas ({{status}}); tente mais tarde", + "not_found": "Este conteúdo não existe mais", + "http_error": "Falha na solicitação ({{status}}); tente mais tarde" + }, "welcome": "Bem-vindo ao aplicativo obytes" } diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json index c34b73d..4b00965 100644 --- a/src/translations/zh-TW.json +++ b/src/translations/zh-TW.json @@ -99,5 +99,13 @@ "video_removed": "目前影片已被網站刪除", "site_unreachable": "無法存取網站,請檢查網路" }, + "net": { + "offline": "網路連線不可用,請檢查網路", + "timeout": "網路逾時,請稍後重試", + "rate_limited": "請求過於頻繁,請稍後再試", + "server": "網站服務暫時無法使用({{status}}),請稍後重試", + "not_found": "內容不存在或已被移除", + "http_error": "請求失敗({{status}}),請稍後重試" + }, "welcome": "歡迎使用 obytes 應用" } diff --git a/src/translations/zh.json b/src/translations/zh.json index dd0d7dc..860034d 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -99,5 +99,13 @@ "video_removed": "当前视频已被网站删除", "site_unreachable": "无法访问网站,请检查网络" }, + "net": { + "offline": "网络连接不可用,请检查网络", + "timeout": "网络超时,请稍后重试", + "rate_limited": "请求过于频繁,请稍后再试", + "server": "网站服务暂时不可用({{status}}),请稍后重试", + "not_found": "内容不存在或已被移除", + "http_error": "请求失败({{status}}),请稍后重试" + }, "welcome": "欢迎使用 obytes 应用" }