From 2f4ee802102eb4da68d7e99d002b725b63dbb54e Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Thu, 3 Sep 2026 14:35:40 +0800 Subject: [PATCH 1/3] chore(test): drop flash-list jestSetup broken since the v2 upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup module mocks FlashList to RecyclerView — an export that only exists in flash-list v1. Under the repo's flash-list 2.x it registers the mock as undefined, so any test that actually renders FlashList crashes in nativewind's JSX runtime. Nothing rendered it so far, which is why it went unnoticed. --- src/lib/test-utils.tsx | 2 -- 1 file changed, 2 deletions(-) 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'; From 8678bde96960bc547fc99eddff3249d168d24a0b Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Thu, 3 Sep 2026 14:35:54 +0800 Subject: [PATCH 2/3] feat(scraper): classify fetch errors into localized toast messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New fetch-error module maps a failed site fetch to a kind and shows a small centered toast with a localized, cause-specific message. fetchPage attaches the HTTP status to its errors, so HTTP responses classify precisely (429 rate-limited, 404/410 removed content, >=500 server trouble, other 4xx request failure — server/http messages carry the status code, e.g. 'The site is having trouble (502)…'), while errors without a status split into timeout ('Request timed out') and offline. Messages added to all seven app languages. --- src/lib/r34/fetch-error.test.ts | 80 +++++++++++++++++++++++++++++++++ src/lib/r34/fetch-error.ts | 53 ++++++++++++++++++++++ src/translations/en.json | 8 ++++ src/translations/es.json | 8 ++++ src/translations/ja.json | 8 ++++ src/translations/ko.json | 8 ++++ src/translations/pt.json | 8 ++++ src/translations/zh-TW.json | 8 ++++ src/translations/zh.json | 8 ++++ 9 files changed, 189 insertions(+) create mode 100644 src/lib/r34/fetch-error.test.ts create mode 100644 src/lib/r34/fetch-error.ts 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/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 应用" } From 8203ebd4fcf473ee0b6954aaafb249b1704ca5bd Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Thu, 3 Sep 2026 14:36:07 +0800 Subject: [PATCH 3/3] fix(lists): keep loaded content on fetch errors and toast the cause A failed page fetch (e.g. a transient 502 while paging the home feed) sent the whole screen to 'Error loading videos', discarding pages that were already loaded and rendered. List screens now keep their content when a fetch fails with something on screen and surface the classified toast instead; the full error screen remains only when there is nothing to keep. Wired into home, search (which previously failed silently), tag, category, model and author screens via a small useFetchErrorToast hook. --- src/app/(app)/index.error.test.tsx | 123 +++++++++++++++++++++++++ src/app/(app)/index.tsx | 8 +- src/app/(app)/search.tsx | 7 +- src/app/author/[id].tsx | 18 +++- src/app/category/[name].tsx | 8 +- src/app/model/[slug].tsx | 7 +- src/app/tag/[id].tsx | 18 +++- src/lib/hooks/use-fetch-error-toast.ts | 17 ++++ 8 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 src/app/(app)/index.error.test.tsx create mode 100644 src/lib/hooks/use-fetch-error-toast.ts 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]); +}