diff --git a/src/app/(app)/index.error.test.tsx b/src/app/(app)/index.error.test.tsx index 3bcef25..03a62d1 100644 --- a/src/app/(app)/index.error.test.tsx +++ b/src/app/(app)/index.error.test.tsx @@ -112,11 +112,18 @@ describe('Home screen — fetch error resilience', () => { 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'); + const error = new Error( + 'Failed to fetch https://rule34video.com/latest-updates/1/: 502' + ) as Error & { status?: number }; + error.status = 502; + mockQueryState.error = error; render(); - expect(screen.getByText('Error loading videos')).toBeTruthy(); + // The full error state carries the classified, localized message instead + // of a generic one (en locale in tests). + expect(screen.getByText(/The site is having trouble/)).toBeTruthy(); + expect(screen.getByText(/502/)).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 a2f9889..a1f7f50 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -9,6 +9,7 @@ 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 { fetchErrorMessage } from '@/lib/r34/fetch-error'; import type { VideoListItem } from '@/lib/r34/types'; export default function Home(): React.ReactElement { @@ -47,7 +48,9 @@ export default function Home(): React.ReactElement { if (isError && videos.length === 0) { return ( - Error loading videos + + {fetchErrorMessage(error)} + refetch()} className="mt-4 rounded-lg bg-primary-500 px-4 py-2" diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index f066943..873a251 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -1,6 +1,7 @@ import { Env } from '@env'; import { Image } from 'expo-image'; import * as LocalAuthentication from 'expo-local-authentication'; +import { useRouter } from 'expo-router'; import type * as React from 'react'; import { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, Platform, Pressable, TouchableOpacity } from 'react-native'; @@ -73,6 +74,7 @@ function Row({ export default function Settings() { const t = useTranslate(); + const router = useRouter(); const { colorScheme } = useColorScheme(); const iconColor = colorScheme === 'dark' ? colors.neutral[400] : colors.neutral[500]; const { tabs, setTab } = useTabConfigStore(); @@ -348,6 +350,7 @@ export default function Settings() { icon={} onPress={handleClearDownloads} /> + router.push('/logs')} /> {/* About */} diff --git a/src/app/author/[id].tsx b/src/app/author/[id].tsx index 199146c..0b89d82 100644 --- a/src/app/author/[id].tsx +++ b/src/app/author/[id].tsx @@ -9,6 +9,7 @@ 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 { fetchErrorMessage } from '@/lib/r34/fetch-error'; import type { VideoListItem } from '@/lib/r34/types'; import { useFollowingStore } from '@/lib/stores/following-store'; @@ -46,7 +47,9 @@ export default function AuthorPage(): React.ReactElement { - Error loading videos + + {fetchErrorMessage(error)} + refetch()} className="mt-4 rounded-lg bg-primary-500 px-4 py-2" diff --git a/src/app/category/[name].tsx b/src/app/category/[name].tsx index c3ef246..a041217 100644 --- a/src/app/category/[name].tsx +++ b/src/app/category/[name].tsx @@ -9,6 +9,7 @@ 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 { fetchErrorMessage } from '@/lib/r34/fetch-error'; import type { VideoListItem } from '@/lib/r34/types'; export default function CategoryPage(): React.ReactElement { @@ -36,7 +37,9 @@ export default function CategoryPage(): React.ReactElement { - Error loading category + + {fetchErrorMessage(error)} + refetch()} className="mt-4 rounded-lg bg-primary-500 px-4 py-2" diff --git a/src/app/logs.tsx b/src/app/logs.tsx new file mode 100644 index 0000000..12d09db --- /dev/null +++ b/src/app/logs.tsx @@ -0,0 +1,85 @@ +import { Stack, useFocusEffect } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, Share, View } from 'react-native'; +import { showMessage } from 'react-native-flash-message'; + +import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui'; +import { useTranslate } from '@/lib/i18n'; +import { clearErrorLog, readErrorLog } from '@/lib/logs/error-log'; + +/** + * Viewer for the plain-text error log (one line per classified network + * failure). The content is a single selectable — long-press and use + * the system copy menu to copy any range; Share sends the whole file. + */ +export default function Logs(): React.ReactElement { + const t = useTranslate(); + // null = not loaded yet, '' = empty log. + const [log, setLog] = useState(null); + + useFocusEffect( + useCallback(() => { + void readErrorLog().then(setLog); + }, []) + ); + + const clear = async () => { + await clearErrorLog(); + setLog(''); + showMessage({ message: t('logs.cleared'), type: 'success', position: 'top', duration: 1500 }); + }; + + return ( + + + + + + {t('logs.title')} + + + void Share.share({ title: t('logs.title'), message: log ?? '' })} + disabled={!log} + className={`rounded-full px-3 py-1.5 ${log ? 'bg-neutral-100 dark:bg-neutral-800' : 'bg-neutral-100/50 dark:bg-neutral-800/50'}`} + > + + {t('logs.share')} + + + void clear()} + disabled={!log} + className="rounded-full bg-neutral-100 px-3 py-1.5 dark:bg-neutral-800" + > + {t('logs.clear')} + + + + + + + {log === null ? ( + + + + ) : log === '' ? ( + + + + ) : ( + + + {log} + + + )} + + ); +} diff --git a/src/app/model/[slug].tsx b/src/app/model/[slug].tsx index d1c04a7..d5decee 100644 --- a/src/app/model/[slug].tsx +++ b/src/app/model/[slug].tsx @@ -9,6 +9,7 @@ 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 { fetchErrorMessage } from '@/lib/r34/fetch-error'; import type { VideoListItem } from '@/lib/r34/types'; export default function ModelPage(): React.ReactElement { @@ -60,7 +61,9 @@ export default function ModelPage(): React.ReactElement { - Error loading videos + + {fetchErrorMessage(error)} + refetch()} className="mt-4 rounded-lg bg-primary-500 px-4 py-2" diff --git a/src/app/post/[id].tsx b/src/app/post/[id].tsx index 496fa02..6b5b23c 100644 --- a/src/app/post/[id].tsx +++ b/src/app/post/[id].tsx @@ -12,6 +12,7 @@ import { useVideoAutoplay } from '@/lib/hooks/use-video-autoplay'; import { useTranslate } from '@/lib/i18n'; import { endVideoOpen } from '@/lib/navigation/open-guard'; import { artistChipLabel } from '@/lib/r34/artists'; +import { fetchErrorMessage } from '@/lib/r34/fetch-error'; import { toOfflineDetail } from '@/lib/r34/offline-detail'; import type { VideoDetail, VideoFormat } from '@/lib/r34/types'; import { baseIdOf, useDownloadedStore } from '@/lib/stores/downloaded-store'; @@ -283,6 +284,7 @@ export default function Post(): React.ReactElement | null { isPending, isError, error, + refetch, } = useVideoDetail({ variables: { id: local.id, slug }, }); @@ -371,16 +373,19 @@ export default function Post(): React.ReactElement | null { } if (!data) { + // What went wrong, specifically (timeout / Cloudflare block / offline / + // site unreachable / …) — falls back to the generic line only when the + // failure isn't a classified fetch error. + const reason = isError ? fetchErrorMessage(error) : t('post.load_error'); return ( - Something went wrong - - - We couldn't load this video. Please try again later. + {t('post.load_failed_title')} + {reason} +