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
53 changes: 41 additions & 12 deletions src/app/(app)/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { queryClient } from '@/api/common/query-client';
import { ScreenErrorBoundary } from '@/components/error-boundary';
import { Icon } from '@/components/icon';
import { SafeAreaView } from '@/components/safe-area-view';
import { UpdateDialog } from '@/components/update-dialog';
import { SITE_DOMAINS, SITE_DOMAIN_KEY, type SiteDomain } from '@/lib/hanime1/endpoints';
import { type ThemeMode, useThemeConfig } from '@/lib/hooks';
import { useSecuritySettings } from '@/lib/hooks/use-security-settings';
Expand Down Expand Up @@ -51,15 +53,20 @@ function Row({
children,
}: {
label: string;
description?: string;
/** Plain string gets the standard muted style; a node renders as-is (rich text). */
description?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<View className="flex-row items-center justify-between px-4 py-3">
<View className="max-w-[60%]">
<Text className="text-foreground">{label}</Text>
{description ? (
<Text className="mt-0.5 text-xs text-muted-foreground">{description}</Text>
typeof description === 'string' ? (
<Text className="mt-0.5 text-xs text-muted-foreground">{description}</Text>
) : (
description
)
) : null}
</View>
<View className="flex-row">{children}</View>
Expand Down Expand Up @@ -207,24 +214,46 @@ export default function SettingsScreen() {
<Row
label={t('settings.checkUpdates')}
description={
update.hasUpdate
? t('settings.updateAvailableHint', { version: update.latestVersion ?? '' })
: undefined
update.hasUpdate ? (
<Text className="mt-0.5 text-xs text-muted-foreground">
{t('settings.updateAvailablePrefix')}{' '}
<Text className="font-semibold text-rose-500">{update.latestVersion}</Text>{' '}
{t('settings.updateAvailableSuffix')}
</Text>
) : undefined
}
>
{update.checking ? (
<ActivityIndicator size="small" color="#fb7185" />
) : update.hasUpdate ? (
<Text className="text-rose-500">{update.latestVersion}</Text>
) : (
<Text className="text-muted-foreground">{t('settings.checkNow')}</Text>
)}
{/* Pill-shaped button so the row reads as actionable (the old muted
text looked disabled); swaps to a spinner + "checking" label
while the request is in flight. View, not Pressable — the whole
row is the tap target. */}
<View
className={`flex-row items-center gap-1.5 rounded-full px-3 py-1.5 ${
update.checking ? 'bg-muted' : 'bg-primary'
}`}
>
{update.checking ? (
<ActivityIndicator size="small" color="#fb7185" />
) : (
<Icon name="refresh" size={16} color="white" />
)}
<Text className={update.checking ? 'text-foreground' : 'text-primary-foreground'}>
{t(update.checking ? 'settings.checking' : 'settings.checkNow')}
</Text>
</View>
</Row>
</Pressable>
<Row label={t('settings.version')}>
<Text className="text-muted-foreground">{VERSION}</Text>
</Row>
</ScrollView>

<UpdateDialog
release={update.pendingRelease}
currentVersion={VERSION}
onClose={update.dismissUpdateDialog}
onDownload={update.openReleaseDownload}
/>
</SafeAreaView>
);
}
55 changes: 55 additions & 0 deletions src/components/update-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { LatestRelease } from '@/lib/updates';
import { fireEvent, render } from '@testing-library/react-native';
import { UpdateDialog } from './update-dialog';

const release: LatestRelease = {
version: '0.3.0',
releaseUrl: 'https://github.com/ghostcoder42/hanime1/releases/tag/v0.3.0',
apkUrl: 'https://example.com/app.apk',
name: 'v0.3.0 — big release',
publishedAt: '2026-08-27T02:23:26Z',
notes: "## What's Changed\r\n* Fix A\r\n* Fix B",
apkSize: 52967745,
};

async function setup(overrides: Partial<Parameters<typeof UpdateDialog>[0]> = {}) {
const onClose = jest.fn();
const onDownload = jest.fn();
const utils = await render(
<UpdateDialog
release={release}
currentVersion="0.2.0"
onClose={onClose}
onDownload={onDownload}
{...overrides}
/>
);
return { utils, onClose, onDownload };
}

describe('UpdateDialog', () => {
it('renders nothing when there is no release', async () => {
const { utils } = await setup({ release: null });
expect(utils.toJSON()).toBeNull();
});

it('shows the release date, apk size, title and notes with proper line breaks', async () => {
const { utils } = await setup();
expect(utils.getByText('2026-08-27', { exact: false })).toBeTruthy();
expect(utils.getByText(/APK ≈ 50\.5 MB/)).toBeTruthy();
expect(utils.getByText('v0.3.0 — big release')).toBeTruthy();
// CRLF normalized (per-line matching — the matcher folds newlines; the
// exact \n layout is covered by the stripMarkdown unit tests)
expect(utils.getByText(/What's Changed/)).toBeTruthy();
expect(utils.getByText(/• Fix A/)).toBeTruthy();
expect(utils.getByText(/• Fix B/)).toBeTruthy();
});

it('closes via cancel and hands the release to onDownload', async () => {
const { utils, onClose, onDownload } = await setup();
fireEvent.press(utils.getByText('Cancel'));
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.press(utils.getByText('Update now'));
expect(onDownload).toHaveBeenCalledWith(release);
});
});
88 changes: 88 additions & 0 deletions src/components/update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useTranslate } from '@/lib/i18n/utils';
import { type LatestRelease, stripMarkdown, truncateText } from '@/lib/updates';
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';

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

/**
* In-app replacement for the system Alert on a positive manual update check.
* The system dialog cannot cap its height, and GitHub release notes can get
* arbitrarily long — this card is clamped to 80% of the screen with the body
* scrolling inside, so it stays usable no matter how much text a release has.
*/
export function UpdateDialog({ release, currentVersion, onClose, onDownload }: UpdateDialogProps) {
const t = useTranslate();
if (!release) return null;

const showName =
!!release.name && release.name !== `v${release.version}` && release.name !== release.version;
const notes = release.notes ? truncateText(stripMarkdown(release.notes), 2000) : '';

const meta: string[] = [];
if (release.publishedAt) {
meta.push(`${t('settings.releaseDate')} ${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}>
{/* Pressable card with no onPress = tap sink, so only the backdrop
dismisses while scrolls/buttons inside still work. */}
<Pressable
className="w-full max-w-md rounded-2xl bg-background p-5"
style={{ maxHeight: '80%' }}
>
<Text className="text-lg font-semibold text-foreground">
{t('settings.updateAvailableTitle')}
</Text>

<ScrollView className="mt-2" showsVerticalScrollIndicator={false}>
<Text className="text-sm leading-5 text-foreground">
{t('settings.updateAvailableMsg', {
version: release.version,
current: currentVersion,
})}
</Text>
{meta.length > 0 ? (
<Text className="mt-2 text-xs text-muted-foreground">{meta.join(' · ')}</Text>
) : null}
{showName ? (
<Text className="mt-3 text-sm font-medium text-foreground">{release.name}</Text>
) : null}
{notes ? (
<Text className="mt-3 text-xs leading-5 text-muted-foreground">
{t('settings.releaseNotes')}
{'\n'}
{notes}
</Text>
) : null}
</ScrollView>

<View className="mt-4 flex-row justify-end gap-2">
<Pressable
onPress={onClose}
className="rounded-full bg-muted px-4 py-2"
accessibilityRole="button"
>
<Text className="text-foreground">{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={() => onDownload(release)}
className="rounded-full bg-primary px-4 py-2"
accessibilityRole="button"
>
<Text className="text-primary-foreground">{t('settings.updateNow')}</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
55 changes: 28 additions & 27 deletions src/lib/hooks/use-update-check.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useLatestRelease } from '@/api/update-queries';
import { useTranslate } from '@/lib/i18n/utils';
import { useUpdateStore } from '@/lib/stores/update-store';
import { compareVersions } from '@/lib/updates';
import { type LatestRelease, compareVersions } from '@/lib/updates';
import { Env } from '@env';
import { useCallback, useEffect } from 'react';
import { Alert, Linking, Platform } from 'react-native';
import { useCallback, useEffect, useState } from 'react';
import { Linking, Platform } from 'react-native';
import { showMessage } from 'react-native-flash-message';

export type UpdateCheckState = {
Expand All @@ -15,17 +15,27 @@ export type UpdateCheckState = {
lastCheckedAt: number | undefined;
/** True when the persisted latest version is newer than the running app. */
hasUpdate: boolean;
/** Manual check: prompts when an update is available, toasts otherwise. */
/**
* Release found by the last manual check, when it is newer than the running
* app. Render it with <UpdateDialog />; null means no dialog.
*/
pendingRelease: LatestRelease | null;
/** Manual check: opens the update dialog when newer, toasts otherwise. */
check: () => Promise<void>;
/** Open the download (direct APK on Android, release page on iOS). */
dismissUpdateDialog: () => void;
/** Open the download for a release (direct APK on Android, page on iOS). */
openReleaseDownload: (release: LatestRelease) => void;
/** Open the download for the persisted latest release (banner-style use). */
openDownload: () => void;
};

/**
* GitHub-release update checker for the settings screen. The TanStack Query
* mounts as a silent auto-check (dedup + cache), and every successful fetch
* is persisted to the update store so the hint survives restarts. Declining
* the prompt keeps the row hint until the installed version catches up.
* is persisted to the update store so the hint survives restarts. The dialog
* only ever opens from a manual check; the silent auto-check just refreshes
* the stored hint. Declining keeps the row hint until the installed version
* catches up.
*/
export function useUpdateCheck(): UpdateCheckState {
const t = useTranslate();
Expand All @@ -35,6 +45,7 @@ export function useUpdateCheck(): UpdateCheckState {
const releaseUrl = useUpdateStore((s) => s.releaseUrl);
const apkUrl = useUpdateStore((s) => s.apkUrl);
const recordCheck = useUpdateStore((s) => s.recordCheck);
const [pendingRelease, setPendingRelease] = useState<LatestRelease | null>(null);

const currentVersion = Env.VERSION ?? '0.0.0';
const hasUpdate = !!latestVersion && compareVersions(latestVersion, currentVersion) > 0;
Expand All @@ -45,6 +56,12 @@ export function useUpdateCheck(): UpdateCheckState {
if (data) recordCheck(data, dataUpdatedAt);
}, [data, dataUpdatedAt, recordCheck]);

const openReleaseDownload = useCallback((release: LatestRelease) => {
const url =
Platform.OS === 'android' ? (release.apkUrl ?? release.releaseUrl) : release.releaseUrl;
if (url) void Linking.openURL(url);
}, []);

const openDownload = useCallback(() => {
const url = Platform.OS === 'android' ? (apkUrl ?? releaseUrl) : releaseUrl;
if (url) void Linking.openURL(url);
Expand All @@ -62,26 +79,7 @@ export function useUpdateCheck(): UpdateCheckState {
return;
}
if (compareVersions(release.version, currentVersion) > 0) {
Alert.alert(
t('settings.updateAvailableTitle'),
t('settings.updateAvailableMsg', {
version: release.version,
current: currentVersion,
}),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('settings.updateNow'),
onPress: () => {
const url =
Platform.OS === 'android'
? (release.apkUrl ?? release.releaseUrl)
: release.releaseUrl;
void Linking.openURL(url);
},
},
]
);
setPendingRelease(release);
} else {
showMessage({
message: t('settings.upToDate'),
Expand All @@ -97,7 +95,10 @@ export function useUpdateCheck(): UpdateCheckState {
latestVersion: latestVersion ?? undefined,
lastCheckedAt: lastCheckedAt ?? undefined,
hasUpdate,
pendingRelease,
check,
dismissUpdateDialog: () => setPendingRelease(null),
openReleaseDownload,
openDownload,
};
}
Loading