Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/app/(app)/index.error.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Home />);

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();
});
Expand Down
5 changes: 4 additions & 1 deletion src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -47,7 +48,9 @@ export default function Home(): React.ReactElement {
if (isError && videos.length === 0) {
return (
<View className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Text className="text-lg text-neutral-900 dark:text-neutral-100">Error loading videos</Text>
<Text className="px-6 text-center text-lg text-neutral-900 dark:text-neutral-100">
{fetchErrorMessage(error)}
</Text>
<TouchableOpacity
onPress={() => refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
Expand Down
3 changes: 3 additions & 0 deletions src/app/(app)/settings.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -348,6 +350,7 @@ export default function Settings() {
icon={<Trash color={iconColor} />}
onPress={handleClearDownloads}
/>
<Row label={t('settings.error_log')} onPress={() => router.push('/logs')} />
</ItemsContainer>

{/* About */}
Expand Down
5 changes: 4 additions & 1 deletion src/app/author/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -46,7 +47,9 @@ export default function AuthorPage(): React.ReactElement {
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
<FocusAwareStatusBar />
<Text className="text-lg text-neutral-900 dark:text-white">Error loading videos</Text>
<Text className="px-6 text-center text-lg text-neutral-900 dark:text-white">
{fetchErrorMessage(error)}
</Text>
<TouchableOpacity
onPress={() => refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
Expand Down
5 changes: 4 additions & 1 deletion src/app/category/[name].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -36,7 +37,9 @@ export default function CategoryPage(): React.ReactElement {
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
<FocusAwareStatusBar />
<Text className="text-lg text-neutral-900 dark:text-white">Error loading category</Text>
<Text className="px-6 text-center text-lg text-neutral-900 dark:text-white">
{fetchErrorMessage(error)}
</Text>
<TouchableOpacity
onPress={() => refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
Expand Down
85 changes: 85 additions & 0 deletions src/app/logs.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text> — 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<string | null>(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 (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: t('logs.title') }} />
<FocusAwareStatusBar />
<View className="flex-row items-center justify-between px-4 pb-2 pt-2">
<Text className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{t('logs.title')}
</Text>
<View className="flex-row gap-2">
<Pressable
onPress={() => 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'}`}
>
<Text className="text-xs text-neutral-600 dark:text-neutral-300">
{t('logs.share')}
</Text>
</Pressable>
<Pressable
onPress={() => void clear()}
disabled={!log}
className="rounded-full bg-neutral-100 px-3 py-1.5 dark:bg-neutral-800"
>
<Text className="text-xs text-red-500 dark:text-red-400">{t('logs.clear')}</Text>
</Pressable>
</View>
</View>

<Text
className="px-4 pb-2 text-xs leading-4 text-neutral-500 dark:text-neutral-400"
tx="logs.copy_hint"
/>

{log === null ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#3b82f6" />
</View>
) : log === '' ? (
<View className="flex-1 items-center justify-center">
<Text className="text-neutral-500 dark:text-neutral-400" tx="logs.empty" />
</View>
) : (
<ScrollView contentContainerStyle={{ paddingBottom: 32 }}>
<Text
selectable
className="px-4 text-xs leading-5 text-neutral-900 dark:text-neutral-100"
>
{log}
</Text>
</ScrollView>
)}
</SafeAreaView>
);
}
5 changes: 4 additions & 1 deletion src/app/model/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -60,7 +61,9 @@ export default function ModelPage(): React.ReactElement {
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
<FocusAwareStatusBar />
<Text className="text-lg text-neutral-900 dark:text-white">Error loading videos</Text>
<Text className="px-6 text-center text-lg text-neutral-900 dark:text-white">
{fetchErrorMessage(error)}
</Text>
<TouchableOpacity
onPress={() => refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
Expand Down
13 changes: 9 additions & 4 deletions src/app/post/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -283,6 +284,7 @@ export default function Post(): React.ReactElement | null {
isPending,
isError,
error,
refetch,
} = useVideoDetail({
variables: { id: local.id, slug },
});
Expand Down Expand Up @@ -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 (
<View className="flex-1 items-center justify-center bg-white p-6 dark:bg-black">
<Stack.Screen options={{ title: 'Error' }} />
<FocusAwareStatusBar />
<Text className="mb-2 text-lg font-bold text-neutral-900 dark:text-white">
Something went wrong
</Text>
<Text className="text-center text-neutral-500 dark:text-neutral-400">
We couldn't load this video. Please try again later.
{t('post.load_failed_title')}
</Text>
<Text className="mb-4 text-center text-neutral-500 dark:text-neutral-400">{reason}</Text>
<Button label={t('common.retry')} onPress={() => refetch()} variant="outline" />
</View>
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/app/tag/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 { fetchErrorMessage } from '@/lib/r34/fetch-error';
import { useTagStore } from '@/lib/stores/tag-store';

export default function TagPage(): React.ReactElement {
Expand Down Expand Up @@ -43,7 +44,9 @@ export default function TagPage(): React.ReactElement {
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
<FocusAwareStatusBar />
<Text className="text-lg text-neutral-900 dark:text-white">Error loading tag</Text>
<Text className="px-6 text-center text-lg text-neutral-900 dark:text-white">
{fetchErrorMessage(error)}
</Text>
<TouchableOpacity
onPress={() => refetch()}
className="mt-4 rounded-lg bg-primary-500 px-4 py-2"
Expand Down
49 changes: 49 additions & 0 deletions src/lib/logs/error-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
jest.mock('expo-file-system/legacy', () => ({
documentDirectory: 'file:///doc/',
makeDirectoryAsync: jest.fn(),
readAsStringAsync: jest.fn().mockResolvedValue(''),
writeAsStringAsync: jest.fn(),
deleteAsync: jest.fn(),
}));

import * as FileSystem from 'expo-file-system/legacy';

import { appendErrorLog, formatErrorLine, formatTimestamp } from './error-log';

describe('formatErrorLine', () => {
it('renders one line with timestamp, kind, status, url and detail', () => {
const line = formatErrorLine(
{ kind: 'blocked', status: 403, url: 'https://x/y', detail: 'Cloudflare block\npage' },
new Date('2026-09-26T04:05:06').getTime()
);
expect(line).toBe('[2026-09-26 04:05:06] blocked HTTP 403 https://x/y — Cloudflare block page');
});

it('omits absent status/url/detail', () => {
expect(formatErrorLine({ kind: 'offline' }, 0)).toMatch(/offline/);
expect(formatErrorLine({ kind: 'offline' }, 0)).not.toContain('HTTP');
});
});

describe('formatTimestamp', () => {
it('zero-pads components', () => {
expect(formatTimestamp(new Date(2026, 0, 2, 3, 4, 5).getTime())).toBe('2026-01-02 03:04:05');
});
});

describe('appendErrorLog', () => {
it('appends the formatted line to the existing content', async () => {
(FileSystem.readAsStringAsync as jest.Mock).mockResolvedValueOnce('first\n');
await appendErrorLog({ kind: 'timeout' });
const [path, content] = (FileSystem.writeAsStringAsync as jest.Mock).mock.calls[0];
expect(path).toBe('file:///doc/logs/error-log.txt');
expect(content.startsWith('first\n')).toBe(true);
expect(content).toContain('timeout');
expect(content.endsWith('\n')).toBe(true);
});

it('never throws when the filesystem fails', async () => {
(FileSystem.writeAsStringAsync as jest.Mock).mockRejectedValueOnce(new Error('disk full'));
await expect(appendErrorLog({ kind: 'server' })).resolves.toBeUndefined();
});
});
82 changes: 82 additions & 0 deletions src/lib/logs/error-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as FileSystem from 'expo-file-system/legacy';

/**
* Plain-text error log, appended by the network layer on every classified
* failure and viewable in Settings → Error log. One line per entry keeps the
* file trimmable, shareable and manually copyable — no database, no store.
*/

const LOG_DIR = `${FileSystem.documentDirectory}logs/`;
export const ERROR_LOG_FILE = `${LOG_DIR}error-log.txt`;

/** Soft cap for the file (chars ≈ bytes here — lines are ASCII-dominated). */
const MAX_CHARS = 128 * 1024;

export type ErrorLogRecord = {
kind: string;
status?: number;
url?: string;
detail?: string;
};

function pad(n: number): string {
return String(n).padStart(2, '0');
}

export function formatTimestamp(epoch: number): string {
const d = new Date(epoch);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(
d.getMinutes()
)}:${pad(d.getSeconds())}`;
}

/** Single line: `[2026-09-26 12:03:44] blocked HTTP 403 https://… — detail` */
export function formatErrorLine(record: ErrorLogRecord, time = Date.now()): string {
const parts = [`[${formatTimestamp(time)}] ${record.kind}`];
if (record.status) parts.push(`HTTP ${record.status}`);
if (record.url) parts.push(record.url);
if (record.detail) parts.push(`— ${record.detail.replace(/\s+/g, ' ').trim()}`);
return parts.join(' ');
}

async function ensureDir(): Promise<void> {
await FileSystem.makeDirectoryAsync(LOG_DIR, { intermediates: true });
}

/** Append one entry, trimming the oldest content once the cap is exceeded. */
export async function appendErrorLog(record: ErrorLogRecord): Promise<void> {
try {
await ensureDir();
let content = '';
try {
content = await FileSystem.readAsStringAsync(ERROR_LOG_FILE, { encoding: 'utf8' });
} catch {
// First entry — the file doesn't exist yet.
}
const line = formatErrorLine(record);
content = content && !content.endsWith('\n') ? `${content}\n${line}\n` : `${content}${line}\n`;
if (content.length > MAX_CHARS) {
const trimmed = content.slice(-MAX_CHARS);
content = trimmed.slice(trimmed.indexOf('\n') + 1); // resume at a line start
}
await FileSystem.writeAsStringAsync(ERROR_LOG_FILE, content, { encoding: 'utf8' });
} catch {
// Logging must never break the request path.
}
}

export async function readErrorLog(): Promise<string> {
try {
return await FileSystem.readAsStringAsync(ERROR_LOG_FILE, { encoding: 'utf8' });
} catch {
return '';
}
}

export async function clearErrorLog(): Promise<void> {
try {
await FileSystem.deleteAsync(ERROR_LOG_FILE, { idempotent: true });
} catch {
// ignore
}
}
Loading
Loading