diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index a628e32..1ce03fe 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -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';
@@ -51,7 +53,8 @@ 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 (
@@ -59,7 +62,11 @@ function Row({
{label}
{description ? (
- {description}
+ typeof description === 'string' ? (
+ {description}
+ ) : (
+ description
+ )
) : null}
{children}
@@ -207,24 +214,46 @@ export default function SettingsScreen() {
+ {t('settings.updateAvailablePrefix')}{' '}
+ {update.latestVersion}{' '}
+ {t('settings.updateAvailableSuffix')}
+
+ ) : undefined
}
>
- {update.checking ? (
-
- ) : update.hasUpdate ? (
- {update.latestVersion}
- ) : (
- {t('settings.checkNow')}
- )}
+ {/* 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. */}
+
+ {update.checking ? (
+
+ ) : (
+
+ )}
+
+ {t(update.checking ? 'settings.checking' : 'settings.checkNow')}
+
+
{VERSION}
+
+
);
}
diff --git a/src/components/update-dialog.test.tsx b/src/components/update-dialog.test.tsx
new file mode 100644
index 0000000..15161ec
--- /dev/null
+++ b/src/components/update-dialog.test.tsx
@@ -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[0]> = {}) {
+ const onClose = jest.fn();
+ const onDownload = jest.fn();
+ const utils = await render(
+
+ );
+ 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);
+ });
+});
diff --git a/src/components/update-dialog.tsx b/src/components/update-dialog.tsx
new file mode 100644
index 0000000..736b90c
--- /dev/null
+++ b/src/components/update-dialog.tsx
@@ -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 (
+
+
+ {/* Pressable card with no onPress = tap sink, so only the backdrop
+ dismisses while scrolls/buttons inside still work. */}
+
+
+ {t('settings.updateAvailableTitle')}
+
+
+
+
+ {t('settings.updateAvailableMsg', {
+ version: release.version,
+ current: currentVersion,
+ })}
+
+ {meta.length > 0 ? (
+ {meta.join(' · ')}
+ ) : null}
+ {showName ? (
+ {release.name}
+ ) : null}
+ {notes ? (
+
+ {t('settings.releaseNotes')}
+ {'\n'}
+ {notes}
+
+ ) : null}
+
+
+
+
+ {t('common.cancel')}
+
+ onDownload(release)}
+ className="rounded-full bg-primary px-4 py-2"
+ accessibilityRole="button"
+ >
+ {t('settings.updateNow')}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/hooks/use-update-check.ts b/src/lib/hooks/use-update-check.ts
index fe6a7ee..57d471a 100644
--- a/src/lib/hooks/use-update-check.ts
+++ b/src/lib/hooks/use-update-check.ts
@@ -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 = {
@@ -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 ; null means no dialog.
+ */
+ pendingRelease: LatestRelease | null;
+ /** Manual check: opens the update dialog when newer, toasts otherwise. */
check: () => Promise;
- /** 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();
@@ -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(null);
const currentVersion = Env.VERSION ?? '0.0.0';
const hasUpdate = !!latestVersion && compareVersions(latestVersion, currentVersion) > 0;
@@ -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);
@@ -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'),
@@ -97,7 +95,10 @@ export function useUpdateCheck(): UpdateCheckState {
latestVersion: latestVersion ?? undefined,
lastCheckedAt: lastCheckedAt ?? undefined,
hasUpdate,
+ pendingRelease,
check,
+ dismissUpdateDialog: () => setPendingRelease(null),
+ openReleaseDownload,
openDownload,
};
}
diff --git a/src/lib/updates.test.ts b/src/lib/updates.test.ts
index 493ba77..0ba665f 100644
--- a/src/lib/updates.test.ts
+++ b/src/lib/updates.test.ts
@@ -1,4 +1,4 @@
-import { compareVersions, parseGithubRelease } from './updates';
+import { compareVersions, parseGithubRelease, stripMarkdown, truncateText } from './updates';
describe('compareVersions', () => {
it.each([
@@ -18,9 +18,16 @@ describe('compareVersions', () => {
describe('parseGithubRelease', () => {
const payload = {
tag_name: 'v0.2.0',
+ name: 'v0.2.0 — fixes',
+ published_at: '2026-08-26T10:00:00Z',
+ body: '## Fixes\n- crash on open\n- **dark mode**',
html_url: 'https://github.com/ghostcoder42/hanime1/releases/tag/v0.2.0',
assets: [
- { name: 'HAnime1-0.2.0.apk', browser_download_url: 'https://example.com/app.apk' },
+ {
+ name: 'HAnime1-0.2.0.apk',
+ size: 77013559,
+ browser_download_url: 'https://example.com/app.apk',
+ },
{ name: 'HAnime1-0.2.0.ipa', browser_download_url: 'https://example.com/app.ipa' },
],
};
@@ -30,12 +37,25 @@ describe('parseGithubRelease', () => {
version: '0.2.0',
releaseUrl: 'https://github.com/ghostcoder42/hanime1/releases/tag/v0.2.0',
apkUrl: 'https://example.com/app.apk',
+ name: 'v0.2.0 — fixes',
+ publishedAt: '2026-08-26T10:00:00Z',
+ notes: '## Fixes\n- crash on open\n- **dark mode**',
+ apkSize: 77013559,
});
});
it('returns apkUrl null when there is no apk asset', () => {
const noApk = { ...payload, assets: [{ name: 'x.ipa', browser_download_url: 'u' }] };
- expect(parseGithubRelease(noApk)?.apkUrl).toBeNull();
+ const parsed = parseGithubRelease(noApk);
+ expect(parsed?.apkUrl).toBeNull();
+ expect(parsed?.apkSize).toBeUndefined();
+ });
+
+ it('omits optional fields when the release has none', () => {
+ const parsed = parseGithubRelease({ tag_name: '0.3.0', name: ' ', body: '' });
+ expect(parsed?.name).toBeUndefined();
+ expect(parsed?.notes).toBeUndefined();
+ expect(parsed?.publishedAt).toBeUndefined();
});
it('falls back to the canonical release page when html_url is missing', () => {
@@ -50,3 +70,30 @@ describe('parseGithubRelease', () => {
expect(parseGithubRelease('string')).toBeNull();
});
});
+
+describe('stripMarkdown / truncateText', () => {
+ it('flattens headings, emphasis, links and list bullets', () => {
+ const md =
+ '## What changed\nFixed [the bug](https://ex.com/a) and **crash** `on open`\n- item one\n- item two';
+ expect(stripMarkdown(md)).toBe(
+ 'What changed\nFixed the bug and crash on open\n• item one\n• item two'
+ );
+ });
+
+ it('normalizes CRLF so lines never glue together (GitHub body shape)', () => {
+ // GitHub release notes come with \r\n line endings; stray \r made list
+ // items render on one line in the Alert.
+ const body =
+ "## What's Changed\r\n* PR one by @user in https://ex.com/1\r\n* PR two by @user in https://ex.com/2";
+ const out = stripMarkdown(body);
+ expect(out).not.toContain('\r');
+ expect(out).toBe(
+ "What's Changed\n• PR one by @user in https://ex.com/1\n• PR two by @user in https://ex.com/2"
+ );
+ });
+
+ it('truncates long text with an ellipsis and keeps short text intact', () => {
+ expect(truncateText('abc', 10)).toBe('abc');
+ expect(truncateText('a'.repeat(20), 10)).toBe(`${'a'.repeat(10)}…`);
+ });
+});
diff --git a/src/lib/updates.ts b/src/lib/updates.ts
index 2dd7045..4aebc75 100644
--- a/src/lib/updates.ts
+++ b/src/lib/updates.ts
@@ -18,10 +18,25 @@ export type LatestRelease = {
releaseUrl: string;
/** Direct APK download URL when the release has one (null otherwise). */
apkUrl: string | null;
+ /** Release title as published (often just the tag, e.g. "v0.3.0"). */
+ name?: string;
+ /** ISO timestamp of publication, for the update dialog. */
+ publishedAt?: string;
+ /** Release notes body (raw markdown from GitHub). */
+ notes?: string;
+ /** APK asset size in bytes when an APK asset exists. */
+ apkSize?: number;
};
-type GithubReleaseAsset = { name?: unknown; browser_download_url?: unknown };
-type GithubReleaseJson = { tag_name?: unknown; html_url?: unknown; assets?: unknown };
+type GithubReleaseAsset = { name?: unknown; size?: unknown; browser_download_url?: unknown };
+type GithubReleaseJson = {
+ tag_name?: unknown;
+ name?: unknown;
+ published_at?: unknown;
+ body?: unknown;
+ html_url?: unknown;
+ assets?: unknown;
+};
/** Compare dotted numeric versions ("v0.2.0" vs "0.1.1") → -1 | 0 | 1. */
export function compareVersions(a: string, b: string): number {
@@ -55,6 +70,7 @@ export function parseGithubRelease(json: unknown): LatestRelease | null {
: `https://github.com/${GITHUB_REPO}/releases/latest`;
let apkUrl: string | null = null;
+ let apkSize: number | undefined;
if (Array.isArray(data.assets)) {
for (const asset of data.assets as GithubReleaseAsset[]) {
if (
@@ -63,11 +79,33 @@ export function parseGithubRelease(json: unknown): LatestRelease | null {
typeof asset.browser_download_url === 'string'
) {
apkUrl = asset.browser_download_url;
+ if (typeof asset.size === 'number' && asset.size > 0) apkSize = asset.size;
break;
}
}
}
- return { version: tag, releaseUrl, apkUrl };
+ const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : undefined;
+ const publishedAt =
+ typeof data.published_at === 'string' && data.published_at ? data.published_at : undefined;
+ const notes = typeof data.body === 'string' && data.body.trim() ? data.body : undefined;
+ return { version: tag, releaseUrl, apkUrl, name, publishedAt, notes, apkSize };
+}
+
+/** Flatten release-note markdown to plain text (Alert shows no formatting). */
+export function stripMarkdown(text: string): string {
+ return text
+ .replace(/\r\n?/g, '\n') // normalize CRLF — stray \r glues lines together on Android
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [label](url) -> label
+ .replace(/^#{1,6}[ \t]*/gm, '') // headings (keep the line break after)
+ .replace(/(\*\*|__|`+)/g, '') // emphasis / code
+ .replace(/^[ \t]*[-*+][ \t]+/gm, '• ') // list items
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+/** Clamp a text to `max` characters with an ellipsis. */
+export function truncateText(text: string, max: number): string {
+ return text.length <= max ? text : `${text.slice(0, max).trimEnd()}…`;
}
/** Fetch the latest published release from GitHub (null on any failure). */
diff --git a/src/translations/en.json b/src/translations/en.json
index 0ef62cd..169c459 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -83,12 +83,16 @@
"version": "Version",
"checkUpdates": "Check for updates",
"checkNow": "Check",
+ "checking": "Checking…",
"upToDate": "You're on the latest version",
"updateCheckFailed": "Failed to check for updates",
"updateAvailableTitle": "Update available",
"updateAvailableMsg": "Version {{version}} is available (current: {{current}}). Download it now?",
"updateNow": "Update now",
- "updateAvailableHint": "New version {{version}} available"
+ "updateAvailablePrefix": "New version",
+ "updateAvailableSuffix": "available",
+ "releaseDate": "Released",
+ "releaseNotes": "What's new:"
},
"actions": {
"addToFavorites": "Add to favorites",
diff --git a/src/translations/ja.json b/src/translations/ja.json
index ceb6234..85a0d1b 100644
--- a/src/translations/ja.json
+++ b/src/translations/ja.json
@@ -83,12 +83,16 @@
"version": "バージョン",
"checkUpdates": "アップデートを確認",
"checkNow": "確認",
+ "checking": "確認中…",
"upToDate": "すでに最新バージョンです",
"updateCheckFailed": "アップデートの確認に失敗しました",
"updateAvailableTitle": "新しいバージョンがあります",
"updateAvailableMsg": "新しいバージョン {{version}} が利用可能です(現在 {{current}})。今すぐダウンロードしますか?",
"updateNow": "今すぐ更新",
- "updateAvailableHint": "新しいバージョン {{version}} があります"
+ "updateAvailablePrefix": "新しいバージョン",
+ "updateAvailableSuffix": "があります",
+ "releaseDate": "リリース日",
+ "releaseNotes": "更新内容:"
},
"actions": {
"addToFavorites": "お気に入りに追加",
diff --git a/src/translations/ko.json b/src/translations/ko.json
index f71f38b..d312dd5 100644
--- a/src/translations/ko.json
+++ b/src/translations/ko.json
@@ -83,12 +83,16 @@
"version": "버전",
"checkUpdates": "업데이트 확인",
"checkNow": "확인",
+ "checking": "확인 중…",
"upToDate": "이미 최신 버전입니다",
"updateCheckFailed": "업데이트 확인 실패",
"updateAvailableTitle": "새 버전 있음",
"updateAvailableMsg": "새 버전 {{version}} 사용 가능(현재 {{current}}). 지금 다운로드하시겠습니까?",
"updateNow": "지금 업데이트",
- "updateAvailableHint": "새 버전 {{version}} 사용 가능"
+ "updateAvailablePrefix": "새 버전",
+ "updateAvailableSuffix": "사용 가능",
+ "releaseDate": "릴리스",
+ "releaseNotes": "업데이트 내용:"
},
"actions": {
"addToFavorites": "즐겨찾기에 추가",
diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json
index 3f8682a..ff3dcec 100644
--- a/src/translations/zh-CN.json
+++ b/src/translations/zh-CN.json
@@ -83,12 +83,16 @@
"version": "版本",
"checkUpdates": "检查更新",
"checkNow": "检查",
+ "checking": "检查中",
"upToDate": "已是最新版本",
"updateCheckFailed": "检查更新失败",
"updateAvailableTitle": "发现新版本",
"updateAvailableMsg": "新版本 {{version}} 已发布(当前 {{current}}),是否前往下载?",
"updateNow": "立即更新",
- "updateAvailableHint": "有新版本 {{version}} 可供更新"
+ "updateAvailablePrefix": "有新版本",
+ "updateAvailableSuffix": "可供更新",
+ "releaseDate": "发布于",
+ "releaseNotes": "更新说明:"
},
"actions": {
"addToFavorites": "加入收藏",
diff --git a/src/translations/zh.json b/src/translations/zh.json
index 3026f6e..5c56cdd 100644
--- a/src/translations/zh.json
+++ b/src/translations/zh.json
@@ -83,12 +83,16 @@
"version": "版本",
"checkUpdates": "檢查更新",
"checkNow": "檢查",
+ "checking": "檢查中",
"upToDate": "已是最新版本",
"updateCheckFailed": "檢查更新失敗",
"updateAvailableTitle": "發現新版本",
"updateAvailableMsg": "新版本 {{version}} 已發布(目前 {{current}}),是否前往下載?",
"updateNow": "立即更新",
- "updateAvailableHint": "有新版本 {{version}} 可供更新"
+ "updateAvailablePrefix": "有新版本",
+ "updateAvailableSuffix": "可供更新",
+ "releaseDate": "發布於",
+ "releaseNotes": "更新說明:"
},
"actions": {
"addToFavorites": "加入收藏",