diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index b76d180..57e5d68 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';
@@ -11,7 +11,9 @@ import { LanguageItem } from '@/components/settings/language-item';
import { ThemeItem } from '@/components/settings/theme-item';
import { FocusAwareStatusBar, SafeAreaView, ScrollView, Text, View, colors } from '@/components/ui';
import { Trash } from '@/components/ui/icons';
+import { UpdateDialog } from '@/components/update-dialog';
import { LOCK_TIMEOUT_OPTIONS, useSecuritySettings } from '@/lib/hooks/use-security-settings';
+import { useUpdateCheck } from '@/lib/hooks/use-update-check';
import { useTranslate } from '@/lib/i18n';
import { useDownloadedStore } from '@/lib/stores/downloaded-store';
import { ORIENTATIONS, useOrientationStore } from '@/lib/stores/orientation-store';
@@ -25,17 +27,23 @@ const SITE_DOMAIN = 'rule34video.com';
/**
* A plain-text settings row. `label` is a pre-translated string passed in by
* the caller (build it via useTranslate() with a settings.* translation key).
+ * `loading` shows a spinner left of the value (used by the update check).
*/
function Row({
label,
value,
+ valueClassName,
icon,
onPress,
+ loading,
}: {
label: string;
value?: string;
+ /** Extra classes for the value text (e.g. highlighting a new version). */
+ valueClassName?: string;
icon?: React.ReactNode;
onPress?: () => void;
+ loading?: boolean;
}) {
const actionable = onPress !== undefined;
return (
@@ -48,7 +56,16 @@ function Row({
{icon ? {icon} : null}
{label}
- {value ? {value} : null}
+ {value ? (
+
+ {loading ? : null}
+
+ {value}
+
+
+ ) : loading ? (
+
+ ) : null}
);
}
@@ -65,6 +82,14 @@ export default function Settings() {
const { appLock, setAppLock, lockTimeoutMs, setLockTimeoutMs, hidePreview, setHidePreview } =
useSecuritySettings();
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
+ const {
+ checking,
+ newerRelease,
+ pendingRelease,
+ runCheck,
+ dismissUpdateDialog,
+ openReleaseDownload,
+ } = useUpdateCheck(Env.VERSION);
useEffect(() => {
LocalAuthentication.hasHardwareAsync().then((has) => {
@@ -295,13 +320,28 @@ export default function Settings() {
{/* About */}
-
+ runCheck(true)}
+ />
+
+
>
);
}
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
new file mode 100644
index 0000000..f33634e
--- /dev/null
+++ b/src/lib/hooks/use-update-check.test.ts
@@ -0,0 +1,208 @@
+jest.mock('react-native-flash-message', () => ({
+ showMessage: jest.fn(),
+}));
+
+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',
+ notes: "What's Changed\n* fix x",
+ releaseUrl: 'https://github.com/ghostcoder42/r34/releases/tag/v0.4.0',
+ apkUrl: null,
+};
+
+const mockUpdater = {
+ release: defaultRelease,
+ error: null as Error | null,
+ persisted: null as Record | null,
+ fetchLatestRelease: jest.fn(),
+ saveLastKnownRelease: jest.fn(),
+};
+
+jest.mock('@/lib/updater', () => ({
+ fetchLatestRelease: (...args: unknown[]) => mockUpdater.fetchLatestRelease(...args),
+ isNewerVersion: (latest: string, current: string) => latest !== current && latest > current,
+ saveLastKnownRelease: (...args: unknown[]) => mockUpdater.saveLastKnownRelease(...args),
+ getLastKnownRelease: () => mockUpdater.persisted,
+}));
+
+import { act, renderHook, waitFor } from '@testing-library/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();
+ mockCheckStore.autoCheckDue = true;
+ mockUpdater.release = defaultRelease;
+ mockUpdater.error = null;
+ mockUpdater.persisted = null;
+ mockUpdater.fetchLatestRelease.mockImplementation(async () => {
+ if (mockUpdater.error) throw mockUpdater.error;
+ return mockUpdater.release;
+ });
+});
+
+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('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(mockCheckStore.recordCheckAt).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 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));
+
+ await act(async () => {
+ await result.current.runCheck(true);
+ });
+
+ 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 () => {
+ // 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..356eeb5
--- /dev/null
+++ b/src/lib/hooks/use-update-check.ts
@@ -0,0 +1,124 @@
+import * as React from 'react';
+import { Alert, Linking, Platform } from 'react-native';
+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,
+ saveLastKnownRelease,
+} 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;
+ /** 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;
+};
+
+/**
+ * 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.
+ */
+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.
+ const known = getLastKnownRelease();
+ return known && isNewerVersion(known.version, currentVersion) ? known : 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();
+ recordCheckAt(Date.now());
+ // 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) {
+ setPendingRelease(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(() => {
+ if (shouldAutoCheck()) {
+ runCheck(false);
+ }
+ }, [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 ?? {});
+}
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/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
new file mode 100644
index 0000000..a8712d4
--- /dev/null
+++ b/src/lib/updater.test.ts
@@ -0,0 +1,125 @@
+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, isNewerVersion } 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('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));
+
+ 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');
+ });
+});
diff --git a/src/lib/updater.ts b/src/lib/updater.ts
new file mode 100644
index 0000000..cd942dd
--- /dev/null
+++ b/src/lib/updater.ts
@@ -0,0 +1,165 @@
+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 MAX_NOTES_LENGTH = 2000;
+
+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 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
+ * 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;
+ }
+ return parseRelease(await response.json());
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw new Error('Request timed out: release info');
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+/** 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;
+}
+
+/**
+ * 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(/\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 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()}…`;
+}
+
+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);
+}
diff --git a/src/translations/en.json b/src/translations/en.json
index f423c78..a395c8c 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,10 @@
"privacy": "Privacy Policy",
"privacy_security": "Privacy & Security",
"rate": "Rate",
- "seconds_5": "5 seconds",
+ "release_date": "Released",
+ "release_notes": "What's new:",
"seconds_30": "30 seconds",
+ "seconds_5": "5 seconds",
"share": "Share",
"show_all": "Show All",
"support": "Support",
@@ -64,6 +66,12 @@
"title": "Theme"
},
"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 ba08085..2d8c2a3 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,10 @@
"privacy": "Política de privacidad",
"privacy_security": "Privacidad y seguridad",
"rate": "Calificar",
- "seconds_5": "5 segundos",
+ "release_date": "Publicado",
+ "release_notes": "Novedades:",
"seconds_30": "30 segundos",
+ "seconds_5": "5 segundos",
"share": "Compartir",
"show_all": "Mostrar todo",
"support": "Soporte",
@@ -64,6 +66,12 @@
"title": "Tema"
},
"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 d92d6e1..eaa8195 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,10 @@
"privacy": "プライバシーポリシー",
"privacy_security": "プライバシーとセキュリティ",
"rate": "評価",
- "seconds_5": "5 秒",
+ "release_date": "リリース日",
+ "release_notes": "更新内容:",
"seconds_30": "30 秒",
+ "seconds_5": "5 秒",
"share": "共有",
"show_all": "すべて表示",
"support": "サポート",
@@ -64,6 +66,12 @@
"title": "テーマ"
},
"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 f6fe085..183de2d 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,10 @@
"privacy": "개인정보 처리방침",
"privacy_security": "개인정보 및 보안",
"rate": "평가",
- "seconds_5": "5초",
+ "release_date": "출시일",
+ "release_notes": "업데이트 내용:",
"seconds_30": "30초",
+ "seconds_5": "5초",
"share": "공유",
"show_all": "모두 표시",
"support": "지원",
@@ -64,6 +66,12 @@
"title": "테마"
},
"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 88754c0..a9f2f7b 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,10 @@
"privacy": "Política de privacidade",
"privacy_security": "Privacidade e segurança",
"rate": "Avaliar",
- "seconds_5": "5 segundos",
+ "release_date": "Publicado",
+ "release_notes": "Novidades:",
"seconds_30": "30 segundos",
+ "seconds_5": "5 segundos",
"share": "Compartilhar",
"show_all": "Mostrar tudo",
"support": "Suporte",
@@ -64,6 +66,12 @@
"title": "Tema"
},
"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 4b00965..ea34803 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,10 @@
"privacy": "隱私權政策",
"privacy_security": "隱私與安全",
"rate": "評分",
- "seconds_5": "5 秒",
+ "release_date": "發布於",
+ "release_notes": "更新說明:",
"seconds_30": "30 秒",
+ "seconds_5": "5 秒",
"share": "分享",
"show_all": "顯示全部",
"support": "支援",
@@ -64,6 +66,12 @@
"title": "主題"
},
"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 860034d..8d161f8 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,10 @@
"privacy": "隐私政策",
"privacy_security": "隐私与安全",
"rate": "评分",
- "seconds_5": "5 秒",
+ "release_date": "发布于",
+ "release_notes": "更新说明:",
"seconds_30": "30 秒",
+ "seconds_5": "5 秒",
"share": "分享",
"show_all": "显示全部",
"support": "支持",
@@ -64,6 +66,12 @@
"title": "主题"
},
"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 下载"