Skip to content
46 changes: 43 additions & 3 deletions src/app/(app)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { Image } from 'expo-image';
import * as LocalAuthentication from 'expo-local-authentication';
import type * as React from 'react';
import { useEffect, useState } from 'react';
import { Alert, Platform, Pressable, TouchableOpacity } from 'react-native';
import { ActivityIndicator, Alert, Platform, Pressable, TouchableOpacity } from 'react-native';

import { AppIconItem } from '@/components/settings/app-icon-item';
import { ItemsContainer } from '@/components/settings/items-container';
import { LanguageItem } from '@/components/settings/language-item';
import { ThemeItem } from '@/components/settings/theme-item';
import { FocusAwareStatusBar, SafeAreaView, ScrollView, Text, View, colors } from '@/components/ui';
import { Trash } from '@/components/ui/icons';
import { UpdateDialog } from '@/components/update-dialog';
import { LOCK_TIMEOUT_OPTIONS, useSecuritySettings } from '@/lib/hooks/use-security-settings';
import { useUpdateCheck } from '@/lib/hooks/use-update-check';
import { useTranslate } from '@/lib/i18n';
import { useDownloadedStore } from '@/lib/stores/downloaded-store';
import { ORIENTATIONS, useOrientationStore } from '@/lib/stores/orientation-store';
Expand All @@ -25,17 +27,23 @@ const SITE_DOMAIN = 'rule34video.com';
/**
* A plain-text settings row. `label` is a pre-translated string passed in by
* the caller (build it via useTranslate() with a settings.* translation key).
* `loading` shows a spinner left of the value (used by the update check).
*/
function Row({
label,
value,
valueClassName,
icon,
onPress,
loading,
}: {
label: string;
value?: string;
/** Extra classes for the value text (e.g. highlighting a new version). */
valueClassName?: string;
icon?: React.ReactNode;
onPress?: () => void;
loading?: boolean;
}) {
const actionable = onPress !== undefined;
return (
Expand All @@ -48,7 +56,16 @@ function Row({
{icon ? <View className="pr-2">{icon}</View> : null}
<Text className="text-neutral-900 dark:text-neutral-100">{label}</Text>
</View>
{value ? <Text className="text-neutral-500 dark:text-neutral-400">{value}</Text> : null}
{value ? (
<View className="flex-row items-center">
{loading ? <ActivityIndicator size="small" className="pr-2" /> : null}
<Text className={`text-neutral-500 dark:text-neutral-400 ${valueClassName ?? ''}`}>
{value}
</Text>
</View>
) : loading ? (
<ActivityIndicator size="small" />
) : null}
</Pressable>
);
}
Expand All @@ -65,6 +82,14 @@ export default function Settings() {
const { appLock, setAppLock, lockTimeoutMs, setLockTimeoutMs, hidePreview, setHidePreview } =
useSecuritySettings();
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
const {
checking,
newerRelease,
pendingRelease,
runCheck,
dismissUpdateDialog,
openReleaseDownload,
} = useUpdateCheck(Env.VERSION);

useEffect(() => {
LocalAuthentication.hasHardwareAsync().then((has) => {
Expand Down Expand Up @@ -295,13 +320,28 @@ export default function Settings() {
{/* About */}
<ItemsContainer title="settings.about">
<Row label={t('settings.app_name')} value={Env.NAME} />
<Row label={t('settings.version')} value={Env.VERSION} />
<Row
label={newerRelease ? t('settings.update_available') : t('settings.version')}
value={`v${newerRelease ? newerRelease.version : Env.VERSION}`}
valueClassName={
newerRelease ? 'font-semibold text-danger-600 dark:text-danger-400' : undefined
}
loading={checking}
onPress={() => runCheck(true)}
/>
</ItemsContainer>

<View className="h-8" />
</View>
</ScrollView>
</SafeAreaView>

<UpdateDialog
release={pendingRelease}
currentVersion={Env.VERSION}
onClose={dismissUpdateDialog}
onDownload={openReleaseDownload}
/>
</>
);
}
86 changes: 86 additions & 0 deletions src/components/update-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
jest.mock('react-native-flash-message', () => ({
showMessage: jest.fn(),
}));

import { cleanup, fireEvent, render, screen } from '@/lib/test-utils';
import type { ReleaseInfo } from '@/lib/updater';

import { UpdateDialog } from './update-dialog';

const release: ReleaseInfo = {
version: '0.4.0',
title: 'v0.4.0',
notes: "What's Changed\n• fix one\n• feat two",
releaseUrl: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0',
apkUrl: 'https://github.com/ghostcoder42/r34/releases/download/v0.4.0/r34-0.4.0.apk',
apkSize: 44040192, // 42 MB
publishedAt: '2026-09-05T10:00:00Z',
};

const noop = () => {};

afterEach(cleanup);

describe('UpdateDialog', () => {
it('renders nothing without a release', () => {
render(<UpdateDialog release={null} currentVersion="0.3.0" onClose={noop} onDownload={noop} />);

expect(screen.queryByText(/0\.4\.0/)).toBeNull();
});

it('shows version, message, meta line and notes', () => {
render(
<UpdateDialog release={release} currentVersion="0.3.0" onClose={noop} onDownload={noop} />
);

// Title carries the new version; the message mentions the current one.
expect(screen.getAllByText(/0\.4\.0/).length).toBeGreaterThan(0);
expect(screen.getByText(/0\.3\.0/)).toBeTruthy();
// Meta: release date (ISO slice, no Intl) and APK size.
expect(screen.getByText(/2026-09-05/)).toBeTruthy();
expect(screen.getByText(/42\.0 MB/)).toBeTruthy();
// Notes render (getByText matches across newlines in one Text node).
expect(screen.getByText(/What's Changed/)).toBeTruthy();
expect(screen.getByText(/• fix one/)).toBeTruthy();
});

it('hides the meta line and notes when absent', () => {
const minimal: ReleaseInfo = {
version: '0.4.0',
title: 'v0.4.0',
notes: '',
releaseUrl: release.releaseUrl,
apkUrl: null,
};

render(
<UpdateDialog release={minimal} currentVersion="0.3.0" onClose={noop} onDownload={noop} />
);

expect(screen.queryByText(/MB/)).toBeNull();
expect(screen.queryByText(/What's new:/)).toBeNull();
});

it('invokes onClose and onDownload from the buttons and backdrop', () => {
const onClose = jest.fn();
const onDownload = jest.fn();

render(
<UpdateDialog
release={release}
currentVersion="0.3.0"
onClose={onClose}
onDownload={onDownload}
/>
);

fireEvent.press(screen.getByTestId('update-dialog-download'));
expect(onDownload).toHaveBeenCalledWith(release);

fireEvent.press(screen.getByTestId('update-dialog-cancel'));
expect(onClose).toHaveBeenCalledTimes(1);

fireEvent.press(screen.getByTestId('update-dialog-backdrop'));
expect(onClose).toHaveBeenCalledTimes(2);
});
});
106 changes: 106 additions & 0 deletions src/components/update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type * as React from 'react';
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';

import { useTranslate } from '@/lib/i18n';
import type { ReleaseInfo } from '@/lib/updater';

type UpdateDialogProps = {
/** Release to present; null keeps the dialog closed. */
release: ReleaseInfo | null;
currentVersion: string;
onClose: () => void;
onDownload: (release: ReleaseInfo) => void;
};

/**
* In-app "new version" card. The system Alert can't cap its height and
* GitHub release notes grow without bound, so this dialog clamps to 80% of
* the screen with the body scrolling inside. The card itself is a Pressable
* without onPress (tap sink) — only the backdrop dismisses, while scrolls
* and buttons inside keep working.
*/
export function UpdateDialog({
release,
currentVersion,
onClose,
onDownload,
}: UpdateDialogProps): React.ReactElement | null {
const t = useTranslate();
if (!release) return null;

// np names releases after the tag; only show the title when it says
// something the version number doesn't.
const showName =
!!release.title && release.title !== `v${release.version}` && release.title !== release.version;

const meta: string[] = [];
if (release.publishedAt) {
// ISO string slice — no Intl/Date formatting needed (Hermes-safe).
meta.push(`${t('settings.release_date')} ${release.publishedAt.slice(0, 10)}`);
}
if (release.apkSize) {
meta.push(`APK ≈ ${(release.apkSize / 1024 / 1024).toFixed(1)} MB`);
}

return (
<Modal transparent visible animationType="fade" onRequestClose={onClose}>
<Pressable
className="flex-1 items-center justify-center bg-black/60 px-6"
onPress={onClose}
testID="update-dialog-backdrop"
>
<Pressable
className="w-full max-w-md rounded-2xl bg-white p-5 dark:bg-neutral-900"
style={{ maxHeight: '80%' }}
>
<Text className="text-lg font-semibold text-neutral-900 dark:text-white">
{t('settings.update_available_title', { version: release.version })}
</Text>
<ScrollView className="mt-2" showsVerticalScrollIndicator={false}>
<Text className="text-sm leading-5 text-neutral-800 dark:text-neutral-200">
{t('settings.update_available_msg', {
version: release.version,
current: currentVersion,
})}
</Text>
{meta.length > 0 ? (
<Text className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{meta.join(' · ')}
</Text>
) : null}
{showName ? (
<Text className="mt-3 text-sm font-medium text-neutral-900 dark:text-white">
{release.title}
</Text>
) : null}
{release.notes ? (
<Text className="mt-3 text-xs leading-5 text-neutral-600 dark:text-neutral-300">
{t('settings.release_notes')}
{'\n'}
{release.notes}
</Text>
) : null}
</ScrollView>
<View className="mt-4 flex-row justify-end gap-2">
<Pressable
onPress={onClose}
className="rounded-full bg-neutral-200 px-4 py-2 dark:bg-neutral-700"
accessibilityRole="button"
testID="update-dialog-cancel"
>
<Text className="text-neutral-900 dark:text-white">{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={() => onDownload(release)}
className="rounded-full bg-primary-500 px-4 py-2"
accessibilityRole="button"
testID="update-dialog-download"
>
<Text className="font-semibold text-white">{t('settings.update_now')}</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
Loading