From eea52cd4bba03181903ba69106505af20fb002cb Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Fri, 4 Sep 2026 18:13:45 +0800 Subject: [PATCH 1/8] feat(settings): check GitHub releases for app updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version row in Settings was static text. It is now an update check against the repo's latest GitHub release (public REST endpoint, no auth; releases are cut with np so tag/name/body carry the version, title and changelog): - entering Settings runs an automatic check — failures are silent, and a found update only switches the row to 'Update available' with the latest version number; - tapping the row runs a manual check with a spinner on the value: a newer release opens a dialog (version, release title, notes with markdown links flattened), being up to date gets a toast, and a failure opens a dialog carrying the classified network wording (offline / timeout / rate limit / 5xx with status) shared with the list-page toasts; - the timestamp of the last check is recorded in MMKV for future use. Version comparison is numeric per component, so 0.10.0 correctly supersedes 0.9.0. Strings localized in all seven app languages. --- src/app/(app)/settings.tsx | 20 +++- src/lib/hooks/use-update-check.test.ts | 124 +++++++++++++++++++++++++ src/lib/hooks/use-update-check.ts | 87 +++++++++++++++++ src/lib/r34/fetch-error.test.ts | 13 ++- src/lib/r34/fetch-error.ts | 24 +++-- src/lib/updater.test.ts | 80 ++++++++++++++++ src/lib/updater.ts | 97 +++++++++++++++++++ src/translations/en.json | 8 +- src/translations/es.json | 8 +- src/translations/ja.json | 8 +- src/translations/ko.json | 8 +- src/translations/pt.json | 8 +- src/translations/zh-TW.json | 8 +- src/translations/zh.json | 8 +- 14 files changed, 477 insertions(+), 24 deletions(-) create mode 100644 src/lib/hooks/use-update-check.test.ts create mode 100644 src/lib/hooks/use-update-check.ts create mode 100644 src/lib/updater.test.ts create mode 100644 src/lib/updater.ts diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index b76d180..1d94745 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -3,7 +3,7 @@ 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'; @@ -12,6 +12,7 @@ import { ThemeItem } from '@/components/settings/theme-item'; import { FocusAwareStatusBar, SafeAreaView, ScrollView, Text, View, colors } from '@/components/ui'; import { Trash } from '@/components/ui/icons'; 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'; @@ -25,17 +26,20 @@ 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` swaps the value for a spinner (used by the update check). */ function Row({ label, value, icon, onPress, + loading, }: { label: string; value?: string; icon?: React.ReactNode; onPress?: () => void; + loading?: boolean; }) { const actionable = onPress !== undefined; return ( @@ -48,7 +52,11 @@ function Row({ {icon ? {icon} : null} {label} - {value ? {value} : null} + {loading ? ( + + ) : value ? ( + {value} + ) : null} ); } @@ -65,6 +73,7 @@ export default function Settings() { const { appLock, setAppLock, lockTimeoutMs, setLockTimeoutMs, hidePreview, setHidePreview } = useSecuritySettings(); const [biometricsAvailable, setBiometricsAvailable] = useState(false); + const { checking, newerRelease, runCheck } = useUpdateCheck(Env.VERSION); useEffect(() => { LocalAuthentication.hasHardwareAsync().then((has) => { @@ -295,7 +304,12 @@ export default function Settings() { {/* About */} - + runCheck(true)} + /> diff --git a/src/lib/hooks/use-update-check.test.ts b/src/lib/hooks/use-update-check.test.ts new file mode 100644 index 0000000..3721b4c --- /dev/null +++ b/src/lib/hooks/use-update-check.test.ts @@ -0,0 +1,124 @@ +jest.mock('react-native-flash-message', () => ({ + showMessage: jest.fn(), +})); + +jest.mock('@/lib/r34/fetch-error', () => ({ + fetchErrorMessage: jest.fn(() => 'classified network message'), +})); + +const mockUpdater = { + release: { version: '0.4.0', title: 'v0.4.0', notes: "What's Changed\n* fix x" }, + error: null as Error | null, + fetchLatestRelease: jest.fn(), + recordLastUpdateCheck: jest.fn(), +}; + +jest.mock('@/lib/updater', () => ({ + fetchLatestRelease: (...args: unknown[]) => mockUpdater.fetchLatestRelease(...args), + isNewerVersion: (latest: string, current: string) => latest !== current && latest > current, + recordLastUpdateCheck: (...args: unknown[]) => mockUpdater.recordLastUpdateCheck(...args), +})); + +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { Alert } from 'react-native'; +import { showMessage } from 'react-native-flash-message'; + +import { useUpdateCheck } from './use-update-check'; + +jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + +beforeEach(() => { + jest.clearAllMocks(); + mockUpdater.error = null; + mockUpdater.fetchLatestRelease.mockImplementation(async () => { + if (mockUpdater.error) throw mockUpdater.error; + return mockUpdater.release; + }); +}); + +describe('useUpdateCheck', () => { + it('checks automatically on mount and only flags the row (no dialogs)', async () => { + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + await waitFor(() => expect(result.current.checking).toBe(false)); + expect(result.current.newerRelease?.version).toBe('0.4.0'); + expect(mockUpdater.recordLastUpdateCheck).toHaveBeenCalledTimes(1); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(showMessage).not.toHaveBeenCalled(); + }); + + it('stays completely silent when the automatic check fails', async () => { + mockUpdater.error = new Error('Network request failed'); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + await waitFor(() => expect(result.current.checking).toBe(false)); + expect(result.current.newerRelease).toBeNull(); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(showMessage).not.toHaveBeenCalled(); + }); + + it('manual check announces a new release with a dialog (title + notes)', async () => { + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + + await act(async () => { + await result.current.runCheck(true); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + const [title, body] = (Alert.alert as jest.Mock).mock.calls[0]; + expect(title).toContain('0.4.0'); + expect(body).toContain("What's Changed"); + }); + + it('manual check toasts when already up to date', async () => { + // Same version installed as the latest release. + const { result } = renderHook(() => useUpdateCheck('0.4.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + + await act(async () => { + await result.current.runCheck(true); + }); + + expect(Alert.alert).not.toHaveBeenCalled(); + expect(showMessage).toHaveBeenCalledTimes(1); + expect((showMessage as jest.Mock).mock.calls[0][0].message).toContain('0.4.0'); + }); + + it('manual check shows a failure dialog with the classified cause', async () => { + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + mockUpdater.error = new Error('Failed to fetch release info: 503'); + + await act(async () => { + await result.current.runCheck(true); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + const [title, message] = (Alert.alert as jest.Mock).mock.calls[0]; + expect(title).toBeTruthy(); + expect(message).toBe('classified network message'); + }); + + it('ignores concurrent triggers while a check is in flight', async () => { + let resolveFetch: (v: unknown) => void = () => {}; + mockUpdater.fetchLatestRelease.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + // Mount fired the automatic check; tapping while it runs must not start + // a second one. + await act(async () => { + await result.current.runCheck(true); + }); + + expect(mockUpdater.fetchLatestRelease).toHaveBeenCalledTimes(1); + resolveFetch(mockUpdater.release); + await waitFor(() => expect(result.current.checking).toBe(false)); + }); +}); diff --git a/src/lib/hooks/use-update-check.ts b/src/lib/hooks/use-update-check.ts new file mode 100644 index 0000000..d644db9 --- /dev/null +++ b/src/lib/hooks/use-update-check.ts @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { Alert } from 'react-native'; +import { showMessage } from 'react-native-flash-message'; + +import i18n from '@/lib/i18n'; +import { fetchErrorMessage } from '@/lib/r34/fetch-error'; +import { + type ReleaseInfo, + fetchLatestRelease, + isNewerVersion, + recordLastUpdateCheck, +} from '@/lib/updater'; + +type UseUpdateCheck = { + /** True while a check is in flight (drives the row's spinner). */ + checking: boolean; + /** The newer release, when one was found; null otherwise. */ + newerRelease: ReleaseInfo | null; + /** Runs a check. Manual checks surface every outcome; automatic ones are + * silent unless a newer release is found (then only the row text changes). */ + runCheck: (manual: boolean) => Promise; +}; + +/** + * Update check against the repo's latest GitHub release. Checking runs once + * automatically on mount (i.e. every Settings visit); failures are silent in + * that mode. Manual checks (tapping the version row) report every outcome: + * a dialog for a new release or a failure, a toast when already up to date. + */ +export function useUpdateCheck(currentVersion: string): UseUpdateCheck { + const [checking, setChecking] = React.useState(false); + const [newerRelease, setNewerRelease] = React.useState(null); + const inFlight = React.useRef(false); + + const runCheck = React.useCallback( + async (manual: boolean) => { + if (inFlight.current) return; + inFlight.current = true; + setChecking(true); + try { + const release = await fetchLatestRelease(); + recordLastUpdateCheck(); + if (isNewerVersion(release.version, currentVersion)) { + setNewerRelease(release); + if (manual) { + announceNewRelease(release); + } + } else { + setNewerRelease(null); + if (manual) { + showMessage({ + message: translate('settings.up_to_date', { version: currentVersion }), + type: 'success', + position: 'center', + duration: 2500, + }); + } + } + } catch (error) { + // Automatic checks stay silent — the next Settings visit retries. + if (manual) { + Alert.alert(translate('settings.update_check_failed'), fetchErrorMessage(error)); + } + } finally { + inFlight.current = false; + setChecking(false); + } + }, + [currentVersion] + ); + + React.useEffect(() => { + runCheck(false); + }, [runCheck]); + + return { checking, newerRelease, runCheck }; +} + +function translate(key: string, options?: Record): string { + const t = i18n.t.bind(i18n) as (key: string, options?: Record) => string; + return t(key, options ?? {}); +} + +function announceNewRelease(release: ReleaseInfo): void { + const body = [release.title, release.notes].filter(Boolean).join('\n\n'); + Alert.alert(translate('settings.update_available_title', { version: release.version }), body); +} diff --git a/src/lib/r34/fetch-error.test.ts b/src/lib/r34/fetch-error.test.ts index 5bd6202..231d0c8 100644 --- a/src/lib/r34/fetch-error.test.ts +++ b/src/lib/r34/fetch-error.test.ts @@ -6,7 +6,7 @@ import { showMessage } from 'react-native-flash-message'; import { resources } from '@/lib/i18n/resources'; -import { classifyFetchError, showFetchErrorToast } from './fetch-error'; +import { classifyFetchError, fetchErrorMessage, showFetchErrorToast } from './fetch-error'; const httpError = (status: number) => { const error = new Error(`Failed to fetch https://rule34video.com/x/: ${status}`) as Error & { @@ -38,6 +38,17 @@ describe('classifyFetchError', () => { }); }); +describe('fetchErrorMessage', () => { + it('returns the same localized wording the toast uses, for dialogs', () => { + expect(fetchErrorMessage(httpError(502))).toBe( + resources.en.translation.net.server.replace('{{status}}', '502') + ); + expect(fetchErrorMessage(new Error('Network request failed'))).toBe( + resources.en.translation.net.offline + ); + }); +}); + describe('showFetchErrorToast', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/lib/r34/fetch-error.ts b/src/lib/r34/fetch-error.ts index 91b5e76..3948ba1 100644 --- a/src/lib/r34/fetch-error.ts +++ b/src/lib/r34/fetch-error.ts @@ -38,16 +38,28 @@ const MESSAGE_KEYS: Record = { }; /** - * Small centered toast describing a failed fetch. Messages are classified - * (see `classifyFetchError`) and localized; server/other-HTTP messages carry - * the status code, e.g. "The site is having trouble (502) — try again later". + * The localized message for a classified fetch error (see MESSAGE_KEYS) — + * used by the toast below and by dialogs that need the same wording. */ -export function showFetchErrorToast(error: unknown): void { +export function fetchErrorMessage(error: unknown): string { const kind = classifyFetchError(error); const status = (error as (Error & { status?: number }) | null)?.status; const translate = i18n.t.bind(i18n) as (key: string, options?: Record) => string; - const message = translate(MESSAGE_KEYS[kind], { + return translate(MESSAGE_KEYS[kind], { ...(typeof status === 'number' ? { status: String(status) } : {}), }); - showMessage({ message, type: 'warning', position: 'center', duration: 2500 }); +} + +/** + * Small centered toast describing a failed fetch. Messages are classified + * (see `classifyFetchError`) and localized; server/other-HTTP messages carry + * the status code, e.g. "The site is having trouble (502) — try again later". + */ +export function showFetchErrorToast(error: unknown): void { + showMessage({ + message: fetchErrorMessage(error), + type: 'warning', + position: 'center', + duration: 2500, + }); } diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts new file mode 100644 index 0000000..3eed2de --- /dev/null +++ b/src/lib/updater.test.ts @@ -0,0 +1,80 @@ +jest.mock('@/lib/storage', () => { + const store: Record = {}; + return { + __esModule: true, + getItem: (key: string): T | null => (key in store ? JSON.parse(store[key]) : null), + setItem: (key: string, value: unknown) => { + store[key] = JSON.stringify(value); + }, + }; +}); + +import { + fetchLatestRelease, + getLastUpdateCheck, + isNewerVersion, + recordLastUpdateCheck, +} from './updater'; + +const jsonResponse = (body: unknown, status = 200) => + ({ ok: status >= 200 && status < 300, status, json: async () => body }) as unknown as Response; + +describe('isNewerVersion', () => { + it('compares numerically per component', () => { + expect(isNewerVersion('0.4.0', '0.3.0')).toBe(true); + expect(isNewerVersion('0.3.0', '0.3.0')).toBe(false); + expect(isNewerVersion('0.3.0', '0.4.0')).toBe(false); + expect(isNewerVersion('0.10.0', '0.9.0')).toBe(true); // not lexicographic + expect(isNewerVersion('1.0', '0.99.9')).toBe(true); + }); + + it('treats malformed components as zero', () => { + expect(isNewerVersion('abc', '0.0.1')).toBe(false); + expect(isNewerVersion('0.0.2', '0.0.x')).toBe(true); + }); +}); + +describe('fetchLatestRelease', () => { + const realFetch = global.fetch; + const mockFetch = (r: Response) => { + global.fetch = jest.fn(() => r) as unknown as typeof fetch; + }; + + afterEach(() => { + global.fetch = realFetch; + jest.restoreAllMocks(); + }); + + it('normalizes the tag, keeps the title and flattens markdown links', async () => { + mockFetch( + jsonResponse({ + tag_name: 'v0.4.0', + name: 'v0.4.0', + body: '## What\u2019s Changed\n* fix by [@me](https://github.com/me) in [PR](https://github.com/x)', + }) + ); + + const release = await fetchLatestRelease(); + + expect(release.version).toBe('0.4.0'); + expect(release.title).toBe('v0.4.0'); + expect(release.notes).toContain('fix by @me in PR'); + expect(release.notes).not.toContain('](http'); + }); + + it('throws with the HTTP status attached for non-OK responses', async () => { + mockFetch(jsonResponse({ message: 'rate limited' }, 403)); + + await expect(fetchLatestRelease()).rejects.toMatchObject({ status: 403 }); + }); +}); + +describe('last-check timestamp', () => { + it('records and reads back the check time', () => { + expect(getLastUpdateCheck()).toBeNull(); + + recordLastUpdateCheck(); + + expect(getLastUpdateCheck()).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/updater.ts b/src/lib/updater.ts new file mode 100644 index 0000000..13647cf --- /dev/null +++ b/src/lib/updater.ts @@ -0,0 +1,97 @@ +import { getItem, setItem } from '@/lib/storage'; + +const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/ghostcoder42/r34/releases/latest'; +const REQUEST_TIMEOUT_MS = 10000; +const LAST_CHECK_KEY = 'update.last_check_at'; +const MAX_NOTES_LENGTH = 1000; + +export type ReleaseInfo = { + /** Plain version, e.g. "0.4.0" (leading "v" from the git tag stripped). */ + version: string; + /** Release title (np uses the tag, e.g. "v0.4.0"). */ + title: string; + /** Release notes with markdown links flattened to their text. */ + notes: string; +}; + +/** + * Fetches the latest published GitHub release. The repo is public, so the + * unauthenticated REST endpoint works (60 req/h rate limit is plenty for a + * check per Settings visit). Errors carry the HTTP status where applicable, + * so callers can reuse the classified fetch-error wording. + */ +export async function fetchLatestRelease(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(GITHUB_LATEST_RELEASE_URL, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'r34-app', + }, + signal: controller.signal, + }); + if (!response.ok) { + const error = new Error(`Failed to fetch release info: ${response.status}`) as Error & { + status?: number; + }; + error.status = response.status; + throw error; + } + const json = (await response.json()) as { tag_name?: unknown; name?: unknown; body?: unknown }; + const tag = typeof json.tag_name === 'string' ? json.tag_name : ''; + return { + version: normalizeVersion(tag), + title: (typeof json.name === 'string' && json.name) || tag, + notes: cleanNotes(typeof json.body === 'string' ? json.body : ''), + }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timed out: release info'); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +/** Strips a leading "v" from a git tag, e.g. "v0.4.0" → "0.4.0". */ +function normalizeVersion(tag: string): string { + return tag.replace(/^v/, ''); +} + +/** Numeric component-wise comparison; true when `latest` is newer than `current`. */ +export function isNewerVersion(latest: string, current: string): boolean { + const parse = (v: string) => + v + .split('.') + .map((part) => Number.parseInt(part, 10)) + .map((n) => (Number.isNaN(n) ? 0 : n)); + const a = parse(latest); + const b = parse(current); + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x !== y) return x > y; + } + return false; +} + +/** Markdown links → their text, collapsed whitespace, capped for a dialog. */ +function cleanNotes(body: string): string { + const cleaned = body + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\n{3,}/g, '\n\n') + .trim(); + return cleaned.length > MAX_NOTES_LENGTH ? `${cleaned.slice(0, MAX_NOTES_LENGTH)}…` : cleaned; +} + +/** Timestamp (ms) of the last update check, for diagnostics/throttling. */ +export function recordLastUpdateCheck(): void { + setItem(LAST_CHECK_KEY, Date.now()); +} + +export function getLastUpdateCheck(): number | null { + return getItem(LAST_CHECK_KEY); +} diff --git a/src/translations/en.json b/src/translations/en.json index f423c78..a23b896 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "Delete all downloaded videos? This cannot be undone.", "clear_downloads_title": "Clear Downloads", "data_storage": "Data & Storage", - "manage_links": "Manage", "general": "General", "github": "Github", "hide_preview": "Hide Preview", @@ -40,6 +39,7 @@ "language": "Language", "links": "Links", "logout": "Logout", + "manage_links": "Manage", "minute_1": "1 minute", "minutes_5": "5 minutes", "more": "More", @@ -50,8 +50,8 @@ "privacy": "Privacy Policy", "privacy_security": "Privacy & Security", "rate": "Rate", - "seconds_5": "5 seconds", "seconds_30": "30 seconds", + "seconds_5": "5 seconds", "share": "Share", "show_all": "Show All", "support": "Support", @@ -64,6 +64,10 @@ "title": "Theme" }, "title": "Settings", + "up_to_date": "Up to date (v{{version}})", + "update_available": "Update available", + "update_available_title": "New version v{{version}}", + "update_check_failed": "Update check failed", "version": "Version", "website": "Website", "wifi_only": "WiFi Only Downloads" diff --git a/src/translations/es.json b/src/translations/es.json index ba08085..0b1c47b 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "¿Eliminar todos los videos descargados? Esto no se puede deshacer.", "clear_downloads_title": "Borrar descargas", "data_storage": "Datos y almacenamiento", - "manage_links": "Gestionar", "general": "General", "github": "Github", "hide_preview": "Ocultar vista previa", @@ -40,6 +39,7 @@ "language": "Idioma", "links": "Enlaces", "logout": "Cerrar sesión", + "manage_links": "Gestionar", "minute_1": "1 minuto", "minutes_5": "5 minutos", "more": "Más", @@ -50,8 +50,8 @@ "privacy": "Política de privacidad", "privacy_security": "Privacidad y seguridad", "rate": "Calificar", - "seconds_5": "5 segundos", "seconds_30": "30 segundos", + "seconds_5": "5 segundos", "share": "Compartir", "show_all": "Mostrar todo", "support": "Soporte", @@ -64,6 +64,10 @@ "title": "Tema" }, "title": "Ajustes", + "up_to_date": "Actualizado (v{{version}})", + "update_available": "Actualización disponible", + "update_available_title": "Nueva versión v{{version}}", + "update_check_failed": "Error al buscar actualizaciones", "version": "Versión", "website": "Sitio web", "wifi_only": "Descargas solo por Wi-Fi" diff --git a/src/translations/ja.json b/src/translations/ja.json index d92d6e1..8baf393 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "すべてのダウンロード済み動画を削除しますか?この操作は元に戻せません。", "clear_downloads_title": "ダウンロードを削除", "data_storage": "データとストレージ", - "manage_links": "管理", "general": "一般", "github": "Github", "hide_preview": "プレビューを隠す", @@ -40,6 +39,7 @@ "language": "言語", "links": "リンク", "logout": "ログアウト", + "manage_links": "管理", "minute_1": "1 分", "minutes_5": "5 分", "more": "その他", @@ -50,8 +50,8 @@ "privacy": "プライバシーポリシー", "privacy_security": "プライバシーとセキュリティ", "rate": "評価", - "seconds_5": "5 秒", "seconds_30": "30 秒", + "seconds_5": "5 秒", "share": "共有", "show_all": "すべて表示", "support": "サポート", @@ -64,6 +64,10 @@ "title": "テーマ" }, "title": "設定", + "up_to_date": "最新バージョンです(v{{version}})", + "update_available": "新しいバージョンがあります", + "update_available_title": "新しいバージョン v{{version}}", + "update_check_failed": "アップデートの確認に失敗しました", "version": "バージョン", "website": "ウェブサイト", "wifi_only": "Wi-Fi のみダウンロード" diff --git a/src/translations/ko.json b/src/translations/ko.json index f6fe085..75892a6 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "다운로드한 모든 동영상을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "clear_downloads_title": "다운로드 삭제", "data_storage": "데이터 및 저장공간", - "manage_links": "관리", "general": "일반", "github": "Github", "hide_preview": "미리보기 숨기기", @@ -40,6 +39,7 @@ "language": "언어", "links": "링크", "logout": "로그아웃", + "manage_links": "관리", "minute_1": "1분", "minutes_5": "5분", "more": "더보기", @@ -50,8 +50,8 @@ "privacy": "개인정보 처리방침", "privacy_security": "개인정보 및 보안", "rate": "평가", - "seconds_5": "5초", "seconds_30": "30초", + "seconds_5": "5초", "share": "공유", "show_all": "모두 표시", "support": "지원", @@ -64,6 +64,10 @@ "title": "테마" }, "title": "설정", + "up_to_date": "최신 버전입니다(v{{version}})", + "update_available": "새 버전이 있습니다", + "update_available_title": "새 버전 v{{version}}", + "update_check_failed": "업데이트 확인에 실패했습니다", "version": "버전", "website": "웹사이트", "wifi_only": "Wi-Fi 전용 다운로드" diff --git a/src/translations/pt.json b/src/translations/pt.json index 88754c0..25f4937 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "Excluir todos os vídeos baixados? Isso não pode ser desfeito.", "clear_downloads_title": "Limpar downloads", "data_storage": "Dados e armazenamento", - "manage_links": "Gerenciar", "general": "Geral", "github": "Github", "hide_preview": "Ocultar pré-visualização", @@ -40,6 +39,7 @@ "language": "Idioma", "links": "Links", "logout": "Sair", + "manage_links": "Gerenciar", "minute_1": "1 minuto", "minutes_5": "5 minutos", "more": "Mais", @@ -50,8 +50,8 @@ "privacy": "Política de privacidade", "privacy_security": "Privacidade e segurança", "rate": "Avaliar", - "seconds_5": "5 segundos", "seconds_30": "30 segundos", + "seconds_5": "5 segundos", "share": "Compartilhar", "show_all": "Mostrar tudo", "support": "Suporte", @@ -64,6 +64,10 @@ "title": "Tema" }, "title": "Configurações", + "up_to_date": "Atualizado (v{{version}})", + "update_available": "Atualização disponível", + "update_available_title": "Nova versão v{{version}}", + "update_check_failed": "Falha ao verificar atualizações", "version": "Versão", "website": "Site", "wifi_only": "Downloads somente no Wi-Fi" diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json index 4b00965..1474b39 100644 --- a/src/translations/zh-TW.json +++ b/src/translations/zh-TW.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "刪除所有已下載的影片?此操作無法復原。", "clear_downloads_title": "清除下載", "data_storage": "資料與儲存", - "manage_links": "管理", "general": "一般", "github": "Github", "hide_preview": "隱藏預覽", @@ -40,6 +39,7 @@ "language": "語言", "links": "連結", "logout": "登出", + "manage_links": "管理", "minute_1": "1 分鐘", "minutes_5": "5 分鐘", "more": "更多", @@ -50,8 +50,8 @@ "privacy": "隱私權政策", "privacy_security": "隱私與安全", "rate": "評分", - "seconds_5": "5 秒", "seconds_30": "30 秒", + "seconds_5": "5 秒", "share": "分享", "show_all": "顯示全部", "support": "支援", @@ -64,6 +64,10 @@ "title": "主題" }, "title": "設定", + "up_to_date": "已是最新版本(v{{version}})", + "update_available": "有新版本可用", + "update_available_title": "發現新版本 v{{version}}", + "update_check_failed": "檢查更新失敗", "version": "版本", "website": "網站", "wifi_only": "僅 Wi-Fi 下載" diff --git a/src/translations/zh.json b/src/translations/zh.json index 860034d..4dcc552 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -31,7 +31,6 @@ "clear_downloads_msg": "删除所有已下载的视频?此操作无法撤销。", "clear_downloads_title": "清除下载", "data_storage": "数据与存储", - "manage_links": "管理", "general": "通用", "github": "Github", "hide_preview": "隐藏预览", @@ -40,6 +39,7 @@ "language": "语言", "links": "链接", "logout": "退出登录", + "manage_links": "管理", "minute_1": "1 分钟", "minutes_5": "5 分钟", "more": "更多", @@ -50,8 +50,8 @@ "privacy": "隐私政策", "privacy_security": "隐私与安全", "rate": "评分", - "seconds_5": "5 秒", "seconds_30": "30 秒", + "seconds_5": "5 秒", "share": "分享", "show_all": "显示全部", "support": "支持", @@ -64,6 +64,10 @@ "title": "主题" }, "title": "设置", + "up_to_date": "已是最新版本(v{{version}})", + "update_available": "有新版本可用", + "update_available_title": "发现新版本 v{{version}}", + "update_check_failed": "检查更新失败", "version": "版本", "website": "网站", "wifi_only": "仅 WiFi 下载" From e4f5f4539eb2d7d5f06df59a264aede7ed663f17 Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Fri, 4 Sep 2026 19:06:54 +0800 Subject: [PATCH 2/8] fix(updater): normalize CRLF and strip markdown from release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub release bodies use CRLF line endings; the leftover \r glues multi-line notes into a single line on Android. Notes cleaning now normalizes \r\n first and also flattens headings, emphasis/code markers and list bullets (•), instead of only stripping links. The length cap rises to 2000 chars — the update dialog scrolls, so notes no longer need aggressive truncation. --- src/lib/updater.test.ts | 23 +++++++++++++++++++++++ src/lib/updater.ts | 22 ++++++++++++++++++---- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts index 3eed2de..bca2332 100644 --- a/src/lib/updater.test.ts +++ b/src/lib/updater.test.ts @@ -62,6 +62,29 @@ describe('fetchLatestRelease', () => { expect(release.notes).not.toContain('](http'); }); + it('normalizes CRLF bodies and strips headings/emphasis/list markers', async () => { + // GitHub release bodies really use \r\n; without normalization the stray + // \r glues lines together on Android. + mockFetch( + jsonResponse({ + tag_name: 'v0.4.0', + body: "## What's Changed\r\n\r\n* fix one\r\n* fix two\r\n\r\nsome **bold** and `code`\r\n", + }) + ); + + const release = await fetchLatestRelease(); + + expect(release.notes).not.toContain('\r'); + expect(release.notes.split('\n')).toEqual([ + "What's Changed", + '', + '• fix one', + '• fix two', + '', + 'some bold and code', + ]); + }); + it('throws with the HTTP status attached for non-OK responses', async () => { mockFetch(jsonResponse({ message: 'rate limited' }, 403)); diff --git a/src/lib/updater.ts b/src/lib/updater.ts index 13647cf..b7fa377 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -3,7 +3,7 @@ import { getItem, setItem } from '@/lib/storage'; const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/ghostcoder42/r34/releases/latest'; const REQUEST_TIMEOUT_MS = 10000; const LAST_CHECK_KEY = 'update.last_check_at'; -const MAX_NOTES_LENGTH = 1000; +const MAX_NOTES_LENGTH = 2000; export type ReleaseInfo = { /** Plain version, e.g. "0.4.0" (leading "v" from the git tag stripped). */ @@ -78,13 +78,27 @@ export function isNewerVersion(latest: string, current: string): boolean { return false; } -/** Markdown links → their text, collapsed whitespace, capped for a dialog. */ +/** + * Flatten release-note markdown to plain text (dialogs show no formatting). + * GitHub bodies use CRLF — normalize first, or the stray \r glues lines + * together on Android. Heading regex uses [ \t]* (not \s*) so it doesn't eat + * the line break and glue the heading to the body. + */ function cleanNotes(body: string): string { const cleaned = body - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\r\n?/g, '\n') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/^#{1,6}[ \t]*/gm, '') + .replace(/(\*\*|__|`+)/g, '') + .replace(/^[ \t]*[-*+][ \t]+/gm, '• ') .replace(/\n{3,}/g, '\n\n') .trim(); - return cleaned.length > MAX_NOTES_LENGTH ? `${cleaned.slice(0, MAX_NOTES_LENGTH)}…` : cleaned; + return truncateText(cleaned, MAX_NOTES_LENGTH); +} + +/** Clamp a text to `max` characters with an ellipsis. */ +function truncateText(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max).trimEnd()}…`; } /** Timestamp (ms) of the last update check, for diagnostics/throttling. */ From 2b29832d29215c2226e0dde81db837b2bf4265ff Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Fri, 4 Sep 2026 19:08:00 +0800 Subject: [PATCH 3/8] feat(updater): parse release assets and metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update dialog needs more than version + notes: the release page URL (every platform), the direct APK asset link and size when the release ships one (EAS builds attach r34-.apk), and the publication timestamp. Payload parsing is now strict — a tag that isn't a plain dotted number makes the whole release unusable instead of silently comparing garbage versions. --- src/lib/updater.test.ts | 37 ++++++++++++++++++++ src/lib/updater.ts | 75 ++++++++++++++++++++++++++++++++++------- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts index bca2332..7c61256 100644 --- a/src/lib/updater.test.ts +++ b/src/lib/updater.test.ts @@ -90,6 +90,43 @@ describe('fetchLatestRelease', () => { await expect(fetchLatestRelease()).rejects.toMatchObject({ status: 403 }); }); + + it('extracts the release page URL, APK asset link/size and publish date', async () => { + mockFetch( + jsonResponse({ + tag_name: 'v0.4.0', + html_url: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0', + published_at: '2026-09-05T10:00:00Z', + assets: [ + { name: 'source.zip', browser_download_url: 'https://x/source.zip' }, + { name: 'r34-0.4.0.apk', size: 42000000, browser_download_url: 'https://x/r34.apk' }, + ], + }) + ); + + const release = await fetchLatestRelease(); + + expect(release.releaseUrl).toBe('https://github.com/ghostcoder42/r34/releases/tag/v0.4.0'); + expect(release.apkUrl).toBe('https://x/r34.apk'); + expect(release.apkSize).toBe(42000000); + expect(release.publishedAt).toBe('2026-09-05T10:00:00Z'); + }); + + it('keeps apkUrl null when the release ships no APK asset', async () => { + mockFetch(jsonResponse({ tag_name: 'v0.4.0', assets: [] })); + + const release = await fetchLatestRelease(); + + expect(release.apkUrl).toBeNull(); + expect(release.apkSize).toBeUndefined(); + expect(release.releaseUrl).toBe('https://github.com/ghostcoder42/r34/releases/latest'); + }); + + it('rejects payloads without a numeric dotted tag', async () => { + mockFetch(jsonResponse({ tag_name: 'nightly', name: 'Nightly', body: 'x' })); + + await expect(fetchLatestRelease()).rejects.toThrow('Unexpected release payload'); + }); }); describe('last-check timestamp', () => { diff --git a/src/lib/updater.ts b/src/lib/updater.ts index b7fa377..5ece94b 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -10,10 +10,70 @@ export type ReleaseInfo = { version: string; /** Release title (np uses the tag, e.g. "v0.4.0"). */ title: string; - /** Release notes with markdown links flattened to their text. */ + /** Release notes with markdown flattened to plain text. */ notes: string; + /** Human-facing release page (works on every platform). */ + releaseUrl: string; + /** Direct APK download URL when the release ships one (null otherwise). */ + apkUrl: string | null; + /** APK asset size in bytes when an APK asset exists. */ + apkSize?: number; + /** ISO publication timestamp, shown as a plain date (no Intl needed). */ + publishedAt?: string; }; +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; +}; + +/** Normalizes the GitHub /releases/latest payload; throws on unusable data. */ +function parseRelease(json: GithubReleaseJson): ReleaseInfo { + const tag = typeof json.tag_name === 'string' ? json.tag_name.replace(/^v/, '').trim() : ''; + if (!tag || !/^\d+(\.\d+)*$/.test(tag)) { + throw new Error('Unexpected release payload'); + } + + const releaseUrl = + typeof json.html_url === 'string' && json.html_url + ? json.html_url + : 'https://github.com/ghostcoder42/r34/releases/latest'; + + let apkUrl: string | null = null; + let apkSize: number | undefined; + if (Array.isArray(json.assets)) { + for (const asset of json.assets as GithubReleaseAsset[]) { + if ( + typeof asset.name === 'string' && + asset.name.endsWith('.apk') && + typeof asset.browser_download_url === 'string' + ) { + apkUrl = asset.browser_download_url; + if (typeof asset.size === 'number' && asset.size > 0) apkSize = asset.size; + break; + } + } + } + + const publishedAt = + typeof json.published_at === 'string' && json.published_at ? json.published_at : undefined; + + return { + version: tag, + title: (typeof json.name === 'string' && json.name.trim()) || `v${tag}`, + notes: cleanNotes(typeof json.body === 'string' ? json.body : ''), + releaseUrl, + apkUrl, + apkSize, + publishedAt, + }; +} + /** * Fetches the latest published GitHub release. The repo is public, so the * unauthenticated REST endpoint works (60 req/h rate limit is plenty for a @@ -38,13 +98,7 @@ export async function fetchLatestRelease(): Promise { error.status = response.status; throw error; } - const json = (await response.json()) as { tag_name?: unknown; name?: unknown; body?: unknown }; - const tag = typeof json.tag_name === 'string' ? json.tag_name : ''; - return { - version: normalizeVersion(tag), - title: (typeof json.name === 'string' && json.name) || tag, - notes: cleanNotes(typeof json.body === 'string' ? json.body : ''), - }; + return parseRelease(await response.json()); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error('Request timed out: release info'); @@ -55,11 +109,6 @@ export async function fetchLatestRelease(): Promise { } } -/** Strips a leading "v" from a git tag, e.g. "v0.4.0" → "0.4.0". */ -function normalizeVersion(tag: string): string { - return tag.replace(/^v/, ''); -} - /** Numeric component-wise comparison; true when `latest` is newer than `current`. */ export function isNewerVersion(latest: string, current: string): boolean { const parse = (v: string) => From ffa06a72e3d587b07bf15e486a2642a889e803bd Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Fri, 4 Sep 2026 19:09:23 +0800 Subject: [PATCH 4/8] feat(settings): persist the update-check outcome across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'update available' hint lived only in hook state, so it vanished on every app restart until Settings re-fetched successfully. The last successful check is now persisted (version + release metadata): the hint is seeded on mount — visible immediately, even offline — and a stale hint whose version no longer beats the installed one is not seeded back after the user updates. --- src/lib/hooks/use-update-check.test.ts | 32 ++++++++++++++++++++++++++ src/lib/hooks/use-update-check.ts | 15 +++++++++++- src/lib/updater.ts | 15 ++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/lib/hooks/use-update-check.test.ts b/src/lib/hooks/use-update-check.test.ts index 3721b4c..35d9463 100644 --- a/src/lib/hooks/use-update-check.test.ts +++ b/src/lib/hooks/use-update-check.test.ts @@ -9,14 +9,18 @@ jest.mock('@/lib/r34/fetch-error', () => ({ const mockUpdater = { release: { version: '0.4.0', title: 'v0.4.0', notes: "What's Changed\n* fix x" }, error: null as Error | null, + persisted: null as Record | null, fetchLatestRelease: jest.fn(), recordLastUpdateCheck: jest.fn(), + saveLastKnownRelease: jest.fn(), }; jest.mock('@/lib/updater', () => ({ fetchLatestRelease: (...args: unknown[]) => mockUpdater.fetchLatestRelease(...args), isNewerVersion: (latest: string, current: string) => latest !== current && latest > current, recordLastUpdateCheck: (...args: unknown[]) => mockUpdater.recordLastUpdateCheck(...args), + saveLastKnownRelease: (...args: unknown[]) => mockUpdater.saveLastKnownRelease(...args), + getLastKnownRelease: () => mockUpdater.persisted, })); import { act, renderHook, waitFor } from '@testing-library/react-native'; @@ -30,6 +34,7 @@ jest.spyOn(Alert, 'alert').mockImplementation(() => {}); beforeEach(() => { jest.clearAllMocks(); mockUpdater.error = null; + mockUpdater.persisted = null; mockUpdater.fetchLatestRelease.mockImplementation(async () => { if (mockUpdater.error) throw mockUpdater.error; return mockUpdater.release; @@ -37,6 +42,33 @@ beforeEach(() => { }); describe('useUpdateCheck', () => { + it('seeds the hint from the persisted last check so it survives restarts', () => { + mockUpdater.persisted = { version: '0.4.0', title: 'v0.4.0', notes: '' }; + // The automatic fetch never resolves — the seed alone must light the row. + mockUpdater.fetchLatestRelease.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + expect(result.current.newerRelease?.version).toBe('0.4.0'); + }); + + it('does not seed a persisted hint that no longer beats the installed version', () => { + // User already updated past the persisted find (e.g. installed manually). + mockUpdater.persisted = { version: '0.3.0', title: 'v0.3.0', notes: '' }; + mockUpdater.fetchLatestRelease.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + expect(result.current.newerRelease).toBeNull(); + }); + + it('persists every successful check, up to date or not', async () => { + const { result } = renderHook(() => useUpdateCheck('0.4.0')); // same as latest + await waitFor(() => expect(result.current.checking).toBe(false)); + + expect(mockUpdater.saveLastKnownRelease).toHaveBeenCalledWith(mockUpdater.release); + }); + it('checks automatically on mount and only flags the row (no dialogs)', async () => { const { result } = renderHook(() => useUpdateCheck('0.3.0')); diff --git a/src/lib/hooks/use-update-check.ts b/src/lib/hooks/use-update-check.ts index d644db9..7feef99 100644 --- a/src/lib/hooks/use-update-check.ts +++ b/src/lib/hooks/use-update-check.ts @@ -7,8 +7,10 @@ import { fetchErrorMessage } from '@/lib/r34/fetch-error'; import { type ReleaseInfo, fetchLatestRelease, + getLastKnownRelease, isNewerVersion, recordLastUpdateCheck, + saveLastKnownRelease, } from '@/lib/updater'; type UseUpdateCheck = { @@ -26,10 +28,18 @@ type UseUpdateCheck = { * automatically on mount (i.e. every Settings visit); failures are silent in * that mode. Manual checks (tapping the version row) report every outcome: * a dialog for a new release or a failure, a toast when already up to date. + * + * The last successful check is persisted, so the "update available" hint is + * seeded on mount and survives restarts even before the fresh fetch resolves. */ export function useUpdateCheck(currentVersion: string): UseUpdateCheck { const [checking, setChecking] = React.useState(false); - const [newerRelease, setNewerRelease] = React.useState(null); + const [newerRelease, setNewerRelease] = React.useState(() => { + // Only seed when the persisted find still beats the installed version — + // after updating the app the stale hint must not come back. + const known = getLastKnownRelease(); + return known && isNewerVersion(known.version, currentVersion) ? known : null; + }); const inFlight = React.useRef(false); const runCheck = React.useCallback( @@ -40,6 +50,9 @@ export function useUpdateCheck(currentVersion: string): UseUpdateCheck { try { const release = await fetchLatestRelease(); recordLastUpdateCheck(); + // Persist every successful outcome (also clears a stale newer-hint + // once the installed version catches up). + saveLastKnownRelease(release); if (isNewerVersion(release.version, currentVersion)) { setNewerRelease(release); if (manual) { diff --git a/src/lib/updater.ts b/src/lib/updater.ts index 5ece94b..0981cfa 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -158,3 +158,18 @@ export function recordLastUpdateCheck(): void { export function getLastUpdateCheck(): number | null { return getItem(LAST_CHECK_KEY); } + +const LAST_KNOWN_KEY = 'update.last_known_release'; + +/** + * Persists the outcome of the last successful check so the "update available" + * hint survives an app restart — Settings can show it before/without a fresh + * fetch (e.g. offline). + */ +export function saveLastKnownRelease(release: ReleaseInfo): void { + setItem(LAST_KNOWN_KEY, release); +} + +export function getLastKnownRelease(): ReleaseInfo | null { + return getItem(LAST_KNOWN_KEY); +} From 9aa0cd2d19b981dba837c715cf59a734e6cea1d2 Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Fri, 4 Sep 2026 19:15:55 +0800 Subject: [PATCH 5/8] feat(settings): in-app update dialog with release details and download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-release announcement was a system Alert, whose height can't be capped — long changelogs would flood the screen. It is now an in-app card clamped to 80% of the screen with the body scrolling inside, showing the version comparison, release date (ISO slice, Hermes-safe), APK size when the release ships one, and the flattened notes. 'Update now' opens the direct APK asset on Android (EAS attaches r34-.apk per release) and the release page everywhere else; backdrop or Cancel dismisses, keeping the hint row. Dialog strings localized in all seven app languages. --- src/app/(app)/settings.tsx | 17 +++- src/components/update-dialog.test.tsx | 86 ++++++++++++++++++++ src/components/update-dialog.tsx | 106 +++++++++++++++++++++++++ src/lib/hooks/use-update-check.test.ts | 47 +++++++++-- src/lib/hooks/use-update-check.ts | 37 +++++++-- src/translations/en.json | 4 + src/translations/es.json | 4 + src/translations/ja.json | 4 + src/translations/ko.json | 4 + src/translations/pt.json | 4 + src/translations/zh-TW.json | 4 + src/translations/zh.json | 4 + 12 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 src/components/update-dialog.test.tsx create mode 100644 src/components/update-dialog.tsx diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 1d94745..0f83e1d 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -11,6 +11,7 @@ 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'; @@ -73,7 +74,14 @@ export default function Settings() { const { appLock, setAppLock, lockTimeoutMs, setLockTimeoutMs, hidePreview, setHidePreview } = useSecuritySettings(); const [biometricsAvailable, setBiometricsAvailable] = useState(false); - const { checking, newerRelease, runCheck } = useUpdateCheck(Env.VERSION); + const { + checking, + newerRelease, + pendingRelease, + runCheck, + dismissUpdateDialog, + openReleaseDownload, + } = useUpdateCheck(Env.VERSION); useEffect(() => { LocalAuthentication.hasHardwareAsync().then((has) => { @@ -316,6 +324,13 @@ export default function Settings() { + + ); } diff --git a/src/components/update-dialog.test.tsx b/src/components/update-dialog.test.tsx new file mode 100644 index 0000000..219ddd1 --- /dev/null +++ b/src/components/update-dialog.test.tsx @@ -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(); + + expect(screen.queryByText(/0\.4\.0/)).toBeNull(); + }); + + it('shows version, message, meta line and notes', () => { + render( + + ); + + // 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( + + ); + + 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( + + ); + + 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); + }); +}); diff --git a/src/components/update-dialog.tsx b/src/components/update-dialog.tsx new file mode 100644 index 0000000..efed92d --- /dev/null +++ b/src/components/update-dialog.tsx @@ -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 ( + + + + + {t('settings.update_available_title', { version: release.version })} + + + + {t('settings.update_available_msg', { + version: release.version, + current: currentVersion, + })} + + {meta.length > 0 ? ( + + {meta.join(' · ')} + + ) : null} + {showName ? ( + + {release.title} + + ) : null} + {release.notes ? ( + + {t('settings.release_notes')} + {'\n'} + {release.notes} + + ) : null} + + + + {t('common.cancel')} + + onDownload(release)} + className="rounded-full bg-primary-500 px-4 py-2" + accessibilityRole="button" + testID="update-dialog-download" + > + {t('settings.update_now')} + + + + + + ); +} diff --git a/src/lib/hooks/use-update-check.test.ts b/src/lib/hooks/use-update-check.test.ts index 35d9463..025d909 100644 --- a/src/lib/hooks/use-update-check.test.ts +++ b/src/lib/hooks/use-update-check.test.ts @@ -6,8 +6,16 @@ jest.mock('@/lib/r34/fetch-error', () => ({ fetchErrorMessage: jest.fn(() => 'classified network message'), })); +const defaultRelease: import('@/lib/updater').ReleaseInfo = { + version: '0.4.0', + title: 'v0.4.0', + notes: "What's Changed\n* fix x", + releaseUrl: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0', + apkUrl: null, +}; + const mockUpdater = { - release: { version: '0.4.0', title: 'v0.4.0', notes: "What's Changed\n* fix x" }, + release: defaultRelease, error: null as Error | null, persisted: null as Record | null, fetchLatestRelease: jest.fn(), @@ -24,15 +32,19 @@ jest.mock('@/lib/updater', () => ({ })); import { act, renderHook, waitFor } from '@testing-library/react-native'; -import { Alert } from 'react-native'; +import { Alert, Linking, Platform } from 'react-native'; import { showMessage } from 'react-native-flash-message'; import { useUpdateCheck } from './use-update-check'; jest.spyOn(Alert, 'alert').mockImplementation(() => {}); +jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined as never); +const setPlatform = (os: 'android' | 'ios') => + Object.defineProperty(Platform, 'OS', { value: os, configurable: true, writable: true }); beforeEach(() => { jest.clearAllMocks(); + mockUpdater.release = defaultRelease; mockUpdater.error = null; mockUpdater.persisted = null; mockUpdater.fetchLatestRelease.mockImplementation(async () => { @@ -90,7 +102,7 @@ describe('useUpdateCheck', () => { expect(showMessage).not.toHaveBeenCalled(); }); - it('manual check announces a new release with a dialog (title + notes)', async () => { + it('manual check with a new release opens the in-app dialog, not an Alert', async () => { const { result } = renderHook(() => useUpdateCheck('0.3.0')); await waitFor(() => expect(result.current.checking).toBe(false)); @@ -98,10 +110,31 @@ describe('useUpdateCheck', () => { await result.current.runCheck(true); }); - expect(Alert.alert).toHaveBeenCalledTimes(1); - const [title, body] = (Alert.alert as jest.Mock).mock.calls[0]; - expect(title).toContain('0.4.0'); - expect(body).toContain("What's Changed"); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(result.current.pendingRelease?.version).toBe('0.4.0'); + + act(() => result.current.dismissUpdateDialog()); + expect(result.current.pendingRelease).toBeNull(); + }); + + it('opens the APK direct link on Android and the release page on iOS', async () => { + mockUpdater.release = { + version: '0.4.0', + title: 'v0.4.0', + notes: '', + 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', + }; + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + await waitFor(() => expect(result.current.checking).toBe(false)); + + setPlatform('android'); + result.current.openReleaseDownload(mockUpdater.release); + expect(Linking.openURL).toHaveBeenLastCalledWith(mockUpdater.release.apkUrl); + + setPlatform('ios'); + result.current.openReleaseDownload(mockUpdater.release); + expect(Linking.openURL).toHaveBeenLastCalledWith(mockUpdater.release.releaseUrl); }); it('manual check toasts when already up to date', async () => { diff --git a/src/lib/hooks/use-update-check.ts b/src/lib/hooks/use-update-check.ts index 7feef99..9fe89a4 100644 --- a/src/lib/hooks/use-update-check.ts +++ b/src/lib/hooks/use-update-check.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Alert } from 'react-native'; +import { Alert, Linking, Platform } from 'react-native'; import { showMessage } from 'react-native-flash-message'; import i18n from '@/lib/i18n'; @@ -21,6 +21,11 @@ type UseUpdateCheck = { /** Runs a check. Manual checks surface every outcome; automatic ones are * silent unless a newer release is found (then only the row text changes). */ runCheck: (manual: boolean) => Promise; + /** Release shown in the in-app update dialog (manual checks only). */ + pendingRelease: ReleaseInfo | null; + dismissUpdateDialog: () => void; + /** Opens the APK direct link on Android, the release page elsewhere. */ + openReleaseDownload: (release: ReleaseInfo) => void; }; /** @@ -34,6 +39,7 @@ type UseUpdateCheck = { */ export function useUpdateCheck(currentVersion: string): UseUpdateCheck { const [checking, setChecking] = React.useState(false); + const [pendingRelease, setPendingRelease] = React.useState(null); const [newerRelease, setNewerRelease] = React.useState(() => { // Only seed when the persisted find still beats the installed version — // after updating the app the stale hint must not come back. @@ -56,7 +62,7 @@ export function useUpdateCheck(currentVersion: string): UseUpdateCheck { if (isNewerVersion(release.version, currentVersion)) { setNewerRelease(release); if (manual) { - announceNewRelease(release); + setPendingRelease(release); } } else { setNewerRelease(null); @@ -86,15 +92,30 @@ export function useUpdateCheck(currentVersion: string): UseUpdateCheck { runCheck(false); }, [runCheck]); - return { checking, newerRelease, runCheck }; + /** + * Android prefers the release's direct APK asset (what EAS attaches); + * everything else lands on the human-facing release page. + */ + const openReleaseDownload = React.useCallback((release: ReleaseInfo) => { + const url = + Platform.OS === 'android' ? (release.apkUrl ?? release.releaseUrl) : release.releaseUrl; + Linking.openURL(url).catch(() => { + // Nothing sensible to do when no browser/installer picks it up. + }); + }, []); + + return { + checking, + newerRelease, + /** Release shown in the in-app dialog; set only by manual checks. */ + pendingRelease, + runCheck, + dismissUpdateDialog: () => setPendingRelease(null), + openReleaseDownload, + }; } function translate(key: string, options?: Record): string { const t = i18n.t.bind(i18n) as (key: string, options?: Record) => string; return t(key, options ?? {}); } - -function announceNewRelease(release: ReleaseInfo): void { - const body = [release.title, release.notes].filter(Boolean).join('\n\n'); - Alert.alert(translate('settings.update_available_title', { version: release.version }), body); -} diff --git a/src/translations/en.json b/src/translations/en.json index a23b896..a395c8c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -50,6 +50,8 @@ "privacy": "Privacy Policy", "privacy_security": "Privacy & Security", "rate": "Rate", + "release_date": "Released", + "release_notes": "What's new:", "seconds_30": "30 seconds", "seconds_5": "5 seconds", "share": "Share", @@ -66,8 +68,10 @@ "title": "Settings", "up_to_date": "Up to date (v{{version}})", "update_available": "Update available", + "update_available_msg": "Version {{version}} is available (current: {{current}}). Download it now?", "update_available_title": "New version v{{version}}", "update_check_failed": "Update check failed", + "update_now": "Update now", "version": "Version", "website": "Website", "wifi_only": "WiFi Only Downloads" diff --git a/src/translations/es.json b/src/translations/es.json index 0b1c47b..2d8c2a3 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -50,6 +50,8 @@ "privacy": "Política de privacidad", "privacy_security": "Privacidad y seguridad", "rate": "Calificar", + "release_date": "Publicado", + "release_notes": "Novedades:", "seconds_30": "30 segundos", "seconds_5": "5 segundos", "share": "Compartir", @@ -66,8 +68,10 @@ "title": "Ajustes", "up_to_date": "Actualizado (v{{version}})", "update_available": "Actualización disponible", + "update_available_msg": "La versión {{version}} está disponible (actual: {{current}}). ¿Descargarla ahora?", "update_available_title": "Nueva versión v{{version}}", "update_check_failed": "Error al buscar actualizaciones", + "update_now": "Actualizar ahora", "version": "Versión", "website": "Sitio web", "wifi_only": "Descargas solo por Wi-Fi" diff --git a/src/translations/ja.json b/src/translations/ja.json index 8baf393..eaa8195 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -50,6 +50,8 @@ "privacy": "プライバシーポリシー", "privacy_security": "プライバシーとセキュリティ", "rate": "評価", + "release_date": "リリース日", + "release_notes": "更新内容:", "seconds_30": "30 秒", "seconds_5": "5 秒", "share": "共有", @@ -66,8 +68,10 @@ "title": "設定", "up_to_date": "最新バージョンです(v{{version}})", "update_available": "新しいバージョンがあります", + "update_available_msg": "新しいバージョン {{version}} が利用可能です(現在 {{current}})。今すぐダウンロードしますか?", "update_available_title": "新しいバージョン v{{version}}", "update_check_failed": "アップデートの確認に失敗しました", + "update_now": "今すぐ更新", "version": "バージョン", "website": "ウェブサイト", "wifi_only": "Wi-Fi のみダウンロード" diff --git a/src/translations/ko.json b/src/translations/ko.json index 75892a6..183de2d 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -50,6 +50,8 @@ "privacy": "개인정보 처리방침", "privacy_security": "개인정보 및 보안", "rate": "평가", + "release_date": "출시일", + "release_notes": "업데이트 내용:", "seconds_30": "30초", "seconds_5": "5초", "share": "공유", @@ -66,8 +68,10 @@ "title": "설정", "up_to_date": "최신 버전입니다(v{{version}})", "update_available": "새 버전이 있습니다", + "update_available_msg": "새 버전 {{version}}이(가) 출시되었습니다(현재 {{current}}). 지금 다운로드할까요?", "update_available_title": "새 버전 v{{version}}", "update_check_failed": "업데이트 확인에 실패했습니다", + "update_now": "지금 업데이트", "version": "버전", "website": "웹사이트", "wifi_only": "Wi-Fi 전용 다운로드" diff --git a/src/translations/pt.json b/src/translations/pt.json index 25f4937..a9f2f7b 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -50,6 +50,8 @@ "privacy": "Política de privacidade", "privacy_security": "Privacidade e segurança", "rate": "Avaliar", + "release_date": "Publicado", + "release_notes": "Novidades:", "seconds_30": "30 segundos", "seconds_5": "5 segundos", "share": "Compartilhar", @@ -66,8 +68,10 @@ "title": "Configurações", "up_to_date": "Atualizado (v{{version}})", "update_available": "Atualização disponível", + "update_available_msg": "A versão {{version}} está disponível (atual: {{current}}). Baixar agora?", "update_available_title": "Nova versão v{{version}}", "update_check_failed": "Falha ao verificar atualizações", + "update_now": "Atualizar agora", "version": "Versão", "website": "Site", "wifi_only": "Downloads somente no Wi-Fi" diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json index 1474b39..ea34803 100644 --- a/src/translations/zh-TW.json +++ b/src/translations/zh-TW.json @@ -50,6 +50,8 @@ "privacy": "隱私權政策", "privacy_security": "隱私與安全", "rate": "評分", + "release_date": "發布於", + "release_notes": "更新說明:", "seconds_30": "30 秒", "seconds_5": "5 秒", "share": "分享", @@ -66,8 +68,10 @@ "title": "設定", "up_to_date": "已是最新版本(v{{version}})", "update_available": "有新版本可用", + "update_available_msg": "新版本 {{version}} 已發布(目前 {{current}}),是否前往下載?", "update_available_title": "發現新版本 v{{version}}", "update_check_failed": "檢查更新失敗", + "update_now": "立即更新", "version": "版本", "website": "網站", "wifi_only": "僅 Wi-Fi 下載" diff --git a/src/translations/zh.json b/src/translations/zh.json index 4dcc552..8d161f8 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -50,6 +50,8 @@ "privacy": "隐私政策", "privacy_security": "隐私与安全", "rate": "评分", + "release_date": "发布于", + "release_notes": "更新说明:", "seconds_30": "30 秒", "seconds_5": "5 秒", "share": "分享", @@ -66,8 +68,10 @@ "title": "设置", "up_to_date": "已是最新版本(v{{version}})", "update_available": "有新版本可用", + "update_available_msg": "新版本 {{version}} 已发布(当前 {{current}}),是否前往下载?", "update_available_title": "发现新版本 v{{version}}", "update_check_failed": "检查更新失败", + "update_now": "立即更新", "version": "版本", "website": "网站", "wifi_only": "仅 WiFi 下载" From 7f1a690a49e0c2e72e2cc0140d680a31a8af93dc Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Fri, 4 Sep 2026 19:16:47 +0800 Subject: [PATCH 6/8] feat(settings): highlight the version number when an update is pending The 'Update available' row now shows the new version in danger red and semibold, so the hint reads at a glance instead of blending into the muted value styling of the other rows. --- src/app/(app)/settings.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 0f83e1d..9f6cb12 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -32,12 +32,15 @@ const SITE_DOMAIN = 'rule34video.com'; 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; @@ -56,7 +59,9 @@ function Row({ {loading ? ( ) : value ? ( - {value} + + {value} + ) : null} ); @@ -315,6 +320,9 @@ export default function Settings() { runCheck(true)} /> From 3576d3e0410b22aaab34a04c4de06bec836778d7 Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Fri, 4 Sep 2026 19:20:38 +0800 Subject: [PATCH 7/8] feat(settings): throttle the automatic update check to once a day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking on every Settings visit was noisy and unnecessary — GitHub's anonymous rate limit aside, release cadence is daily at most. The check timestamp now lives in a small zustand store backed by MMKV (update-check-store) and the automatic check only runs when the last successful check is older than 24h (survives restarts; failures don't count and retry on the next visit). Manual checks always run. The persisted-release hint is unaffected: on throttled visits the row still seeds from the last known outcome, so a found update stays visible. --- src/lib/hooks/use-update-check.test.ts | 25 ++++++++++++-- src/lib/hooks/use-update-check.ts | 17 +++++---- src/lib/stores/update-check-store.test.ts | 42 +++++++++++++++++++++++ src/lib/stores/update-check-store.ts | 38 ++++++++++++++++++++ src/lib/updater.test.ts | 17 +-------- src/lib/updater.ts | 10 ------ 6 files changed, 113 insertions(+), 36 deletions(-) create mode 100644 src/lib/stores/update-check-store.test.ts create mode 100644 src/lib/stores/update-check-store.ts diff --git a/src/lib/hooks/use-update-check.test.ts b/src/lib/hooks/use-update-check.test.ts index 025d909..f33634e 100644 --- a/src/lib/hooks/use-update-check.test.ts +++ b/src/lib/hooks/use-update-check.test.ts @@ -6,6 +6,17 @@ jest.mock('@/lib/r34/fetch-error', () => ({ fetchErrorMessage: jest.fn(() => 'classified network message'), })); +// Controllable throttle store (real logic covered in update-check-store.test). +const mockCheckStore = { + autoCheckDue: true, + recordCheckAt: jest.fn(), +}; + +jest.mock('@/lib/stores/update-check-store', () => ({ + shouldAutoCheck: () => mockCheckStore.autoCheckDue, + recordCheckAt: (...args: unknown[]) => mockCheckStore.recordCheckAt(...args), +})); + const defaultRelease: import('@/lib/updater').ReleaseInfo = { version: '0.4.0', title: 'v0.4.0', @@ -19,14 +30,12 @@ const mockUpdater = { error: null as Error | null, persisted: null as Record | null, fetchLatestRelease: jest.fn(), - recordLastUpdateCheck: jest.fn(), saveLastKnownRelease: jest.fn(), }; jest.mock('@/lib/updater', () => ({ fetchLatestRelease: (...args: unknown[]) => mockUpdater.fetchLatestRelease(...args), isNewerVersion: (latest: string, current: string) => latest !== current && latest > current, - recordLastUpdateCheck: (...args: unknown[]) => mockUpdater.recordLastUpdateCheck(...args), saveLastKnownRelease: (...args: unknown[]) => mockUpdater.saveLastKnownRelease(...args), getLastKnownRelease: () => mockUpdater.persisted, })); @@ -44,6 +53,7 @@ const setPlatform = (os: 'android' | 'ios') => beforeEach(() => { jest.clearAllMocks(); + mockCheckStore.autoCheckDue = true; mockUpdater.release = defaultRelease; mockUpdater.error = null; mockUpdater.persisted = null; @@ -81,12 +91,21 @@ describe('useUpdateCheck', () => { expect(mockUpdater.saveLastKnownRelease).toHaveBeenCalledWith(mockUpdater.release); }); + it('skips the automatic check when the daily throttle is not due', async () => { + mockCheckStore.autoCheckDue = false; + + const { result } = renderHook(() => useUpdateCheck('0.3.0')); + + expect(mockUpdater.fetchLatestRelease).not.toHaveBeenCalled(); + expect(result.current.checking).toBe(false); + }); + it('checks automatically on mount and only flags the row (no dialogs)', async () => { const { result } = renderHook(() => useUpdateCheck('0.3.0')); await waitFor(() => expect(result.current.checking).toBe(false)); expect(result.current.newerRelease?.version).toBe('0.4.0'); - expect(mockUpdater.recordLastUpdateCheck).toHaveBeenCalledTimes(1); + expect(mockCheckStore.recordCheckAt).toHaveBeenCalledTimes(1); expect(Alert.alert).not.toHaveBeenCalled(); expect(showMessage).not.toHaveBeenCalled(); }); diff --git a/src/lib/hooks/use-update-check.ts b/src/lib/hooks/use-update-check.ts index 9fe89a4..356eeb5 100644 --- a/src/lib/hooks/use-update-check.ts +++ b/src/lib/hooks/use-update-check.ts @@ -4,12 +4,12 @@ import { showMessage } from 'react-native-flash-message'; import i18n from '@/lib/i18n'; import { fetchErrorMessage } from '@/lib/r34/fetch-error'; +import { recordCheckAt, shouldAutoCheck } from '@/lib/stores/update-check-store'; import { type ReleaseInfo, fetchLatestRelease, getLastKnownRelease, isNewerVersion, - recordLastUpdateCheck, saveLastKnownRelease, } from '@/lib/updater'; @@ -29,10 +29,11 @@ type UseUpdateCheck = { }; /** - * Update check against the repo's latest GitHub release. Checking runs once - * automatically on mount (i.e. every Settings visit); failures are silent in - * that mode. Manual checks (tapping the version row) report every outcome: - * a dialog for a new release or a failure, a toast when already up to date. + * Update check against the repo's latest GitHub release. The automatic check + * runs on mount at most once a day (update-check-store throttles it; failures + * don't count and simply retry on the next visit). Manual checks (tapping the + * version row) always run and report every outcome: a dialog for a new + * release or a failure, a toast when already up to date. * * The last successful check is persisted, so the "update available" hint is * seeded on mount and survives restarts even before the fresh fetch resolves. @@ -55,7 +56,7 @@ export function useUpdateCheck(currentVersion: string): UseUpdateCheck { setChecking(true); try { const release = await fetchLatestRelease(); - recordLastUpdateCheck(); + recordCheckAt(Date.now()); // Persist every successful outcome (also clears a stale newer-hint // once the installed version catches up). saveLastKnownRelease(release); @@ -89,7 +90,9 @@ export function useUpdateCheck(currentVersion: string): UseUpdateCheck { ); React.useEffect(() => { - runCheck(false); + if (shouldAutoCheck()) { + runCheck(false); + } }, [runCheck]); /** diff --git a/src/lib/stores/update-check-store.test.ts b/src/lib/stores/update-check-store.test.ts new file mode 100644 index 0000000..8c89c2a --- /dev/null +++ b/src/lib/stores/update-check-store.test.ts @@ -0,0 +1,42 @@ +jest.mock('@/lib/storage', () => { + const store: Record = {}; + return { + __esModule: true, + getItem: (key: string): T | null => (key in store ? JSON.parse(store[key]) : null), + setItem: (key: string, value: unknown) => { + store[key] = JSON.stringify(value); + }, + }; +}); + +import { AUTO_CHECK_INTERVAL_MS, shouldAutoCheck, useUpdateCheckStore } from './update-check-store'; + +const DAY = AUTO_CHECK_INTERVAL_MS; + +beforeEach(() => { + useUpdateCheckStore.setState({ lastCheckedAt: null }); +}); + +describe('useUpdateCheckStore', () => { + it('records the check time and persists it to storage', () => { + const { getItem } = jest.requireMock('@/lib/storage'); + + useUpdateCheckStore.getState().recordCheck(1000); + + expect(useUpdateCheckStore.getState().lastCheckedAt).toBe(1000); + expect(getItem('update.last_check_at')).toBe(1000); + }); +}); + +describe('shouldAutoCheck', () => { + it('is due before any check ever ran', () => { + expect(shouldAutoCheck()).toBe(true); + }); + + it('is not due within a day of the last check', () => { + useUpdateCheckStore.getState().recordCheck(1_000_000); + + expect(shouldAutoCheck(1_000_000 + DAY - 1)).toBe(false); + expect(shouldAutoCheck(1_000_000 + DAY)).toBe(true); + }); +}); diff --git a/src/lib/stores/update-check-store.ts b/src/lib/stores/update-check-store.ts new file mode 100644 index 0000000..f15548b --- /dev/null +++ b/src/lib/stores/update-check-store.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand'; + +import { getItem, setItem } from '@/lib/storage'; + +const LAST_CHECK_AT_KEY = 'update.last_check_at'; + +/** Automatic checks run at most once per day; manual checks always run. */ +export const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +type UpdateCheckState = { + /** Epoch ms of the last completed successful check (null before the first). */ + lastCheckedAt: number | null; + recordCheck: (checkedAt: number) => void; +}; + +/** + * Tracks when the update check last ran, persisted to MMKV so the daily + * throttle survives restarts (the store seeds from storage on creation). + */ +export const useUpdateCheckStore = create((set) => ({ + lastCheckedAt: getItem(LAST_CHECK_AT_KEY) ?? null, + + recordCheck: (checkedAt) => { + setItem(LAST_CHECK_AT_KEY, checkedAt); + set({ lastCheckedAt: checkedAt }); + }, +})); + +/** True when the last check is missing or older than the daily interval. */ +export function shouldAutoCheck(now: number = Date.now()): boolean { + const { lastCheckedAt } = useUpdateCheckStore.getState(); + return lastCheckedAt === null || now - lastCheckedAt >= AUTO_CHECK_INTERVAL_MS; +} + +/** Records a completed check from non-react callers (e.g. the check hook). */ +export function recordCheckAt(checkedAt: number): void { + useUpdateCheckStore.getState().recordCheck(checkedAt); +} diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts index 7c61256..a8712d4 100644 --- a/src/lib/updater.test.ts +++ b/src/lib/updater.test.ts @@ -9,12 +9,7 @@ jest.mock('@/lib/storage', () => { }; }); -import { - fetchLatestRelease, - getLastUpdateCheck, - isNewerVersion, - recordLastUpdateCheck, -} from './updater'; +import { fetchLatestRelease, isNewerVersion } from './updater'; const jsonResponse = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body }) as unknown as Response; @@ -128,13 +123,3 @@ describe('fetchLatestRelease', () => { await expect(fetchLatestRelease()).rejects.toThrow('Unexpected release payload'); }); }); - -describe('last-check timestamp', () => { - it('records and reads back the check time', () => { - expect(getLastUpdateCheck()).toBeNull(); - - recordLastUpdateCheck(); - - expect(getLastUpdateCheck()).toBeGreaterThan(0); - }); -}); diff --git a/src/lib/updater.ts b/src/lib/updater.ts index 0981cfa..cd942dd 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -2,7 +2,6 @@ import { getItem, setItem } from '@/lib/storage'; const GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/ghostcoder42/r34/releases/latest'; const REQUEST_TIMEOUT_MS = 10000; -const LAST_CHECK_KEY = 'update.last_check_at'; const MAX_NOTES_LENGTH = 2000; export type ReleaseInfo = { @@ -150,15 +149,6 @@ function truncateText(text: string, max: number): string { return text.length <= max ? text : `${text.slice(0, max).trimEnd()}…`; } -/** Timestamp (ms) of the last update check, for diagnostics/throttling. */ -export function recordLastUpdateCheck(): void { - setItem(LAST_CHECK_KEY, Date.now()); -} - -export function getLastUpdateCheck(): number | null { - return getItem(LAST_CHECK_KEY); -} - const LAST_KNOWN_KEY = 'update.last_known_release'; /** From 75c77b60c1029079183e836fbb538c2bde3daae5 Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Sat, 5 Sep 2026 14:31:25 +0800 Subject: [PATCH 8/8] fix(settings): spinner left of the version, v-prefixed version display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update check swapped the version text out for the spinner, hiding the very number the row exists to show — the spinner now renders left of the value. Both version displays (current and available update) carry a 'v' prefix, matching the release tags. --- src/app/(app)/settings.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 9f6cb12..57e5d68 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -27,7 +27,7 @@ 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` swaps the value for a spinner (used by the update check). + * `loading` shows a spinner left of the value (used by the update check). */ function Row({ label, @@ -56,12 +56,15 @@ function Row({ {icon ? {icon} : null} {label} - {loading ? ( + {value ? ( + + {loading ? : null} + + {value} + + + ) : loading ? ( - ) : value ? ( - - {value} - ) : null} ); @@ -319,7 +322,7 @@ export default function Settings() {