New-version notice beside the Usage title from GitHub releases; dismissible per version

This commit is contained in:
Jude.Chen committed 2026-10-07 03:57:44 +08:00
1 parent 7762e22e39
commit e167cd28fa
7 files changed
+254 -6

No files matched your search

+2
View File
@@ -27,6 +27,7 @@
- **订阅与花费**:显示服务商返回的套餐到期 / 续费日;为每个账号填上月费,首页汇总你每月在 AI 订阅上花了多少。
- **及时提醒**:用量超过你设的阈值、窗口重置额度恢复、套餐快到期、登录失效时,发本地通知提醒你。
- **小组件**(iOS):主屏和锁屏小组件,不打开应用也能看余量。
- **新版本提示**:有新版时在首页标题旁提示,点一下前往下载;关闭后同一版本不再提示。
- **好用的细节**:中文 / English(可跟随系统)、浅色 / 深色、7 种主题色、流畅的动画与触觉反馈,并尊重系统的「减弱动态效果」。
## 下载与安装
@@ -83,6 +84,7 @@
- **凭据只存在本机**:API Key 和登录令牌保存在系统钥匙串(iOS Keychain / Android Keystore)中,不会上传。
- **用量历史只存在本机**:保存在应用的本地数据库里,可在「设置 → 数据」一键全部删除。
- **无广告、无统计**:不收集任何使用数据。
- **版本检查**:应用每隔几小时读取一次本仓库在 GitHub 上的最新 Release,用来提示新版本;请求不携带任何账号或用量信息。
- 浏览器登录使用本机 `127.0.0.1` 临时回调,只在登录过程中短暂开启,授权完成后立即关闭。
## 常见问题
+8
View File
@@ -16,6 +16,8 @@ import { AccountCard } from '@/ui/account-card';
import { AccountCardSkeleton } from '@/ui/account-card-skeleton';
import { Button } from '@/ui/controls';
import { RefreshButton } from '@/ui/refresh-button';
import { UpdatePill } from '@/ui/update-pill';
import { useUpdateNotice } from '@/data/update-check';
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
export default function OverviewScreen() {
@@ -26,6 +28,7 @@ export default function OverviewScreen() {
const services = useServices();
const views = useAccountViews();
const refresh = useRefreshAll();
const update = useUpdateNotice();
// Refresh once when the app opens; the rate limiter makes this cheap if data is recent.
const ready = !!services.data;
@@ -44,6 +47,11 @@ export default function OverviewScreen() {
<Screen>
<ScreenHeader
title={t('Usage')}
accessory={
update.notice ? (
<UpdatePill release={update.notice} onDismiss={update.dismiss} />
) : undefined
}
action={
<RefreshButton
refreshing={refresh.isPending}
+19 -6
View File
@@ -5,15 +5,27 @@ import { ThemedText } from '@/components/themed-text';
import { Spacing } from '@/constants/theme';
/**
* Page header for tab screens: title on the left, optional primary action on the right.
* Keeps every tab screen on the same title size and spacing.
* Page header for tab screens: title on the left with an optional accessory right after it (e.g.
* a new-version notice), optional primary action on the right. Keeps every tab screen on the same
* title size and spacing.
*/
export function ScreenHeader({ title, action }: { title: string; action?: ReactNode }) {
export function ScreenHeader({
title,
accessory,
action,
}: {
title: string;
accessory?: ReactNode;
action?: ReactNode;
}) {
return (
<View style={styles.header}>
<ThemedText type="subtitle" style={styles.title}>
{title}
</ThemedText>
<View style={styles.lead}>
<ThemedText type="subtitle" style={styles.title} numberOfLines={1}>
{title}
</ThemedText>
{accessory}
</View>
{action}
</View>
);
@@ -26,5 +38,6 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
gap: Spacing.three,
},
lead: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, flexShrink: 1 },
title: { flexShrink: 1 },
});
+38
View File
@@ -0,0 +1,38 @@
import { compareVersions, fetchLatestRelease, pickNotice, RELEASES_API } from '@/data/update-check';
import { fakeFetch } from '@/test-utils/fetch';
describe('update check', () => {
it('compares dotted versions numerically, with or without a leading v', () => {
expect(compareVersions('1.0.10', '1.0.9')).toBe(1);
expect(compareVersions('v1.2.0', '1.10.0')).toBe(-1);
expect(compareVersions('1.0', '1.0.0')).toBe(0);
expect(compareVersions('nightly', '1.0.0')).toBe(0);
});
it('reads the latest published release and skips drafts and pre-releases', async () => {
const ok = fakeFetch({
[RELEASES_API]: {
json: { tag_name: 'v1.0.3', html_url: 'https://github.com/x/releases/tag/v1.0.3' },
},
});
expect(await fetchLatestRelease(ok.fetch)).toEqual({
version: '1.0.3',
url: 'https://github.com/x/releases/tag/v1.0.3',
});
const pre = fakeFetch({
[RELEASES_API]: { json: { tag_name: 'v2.0.0', html_url: 'u', prerelease: true } },
});
expect(await fetchLatestRelease(pre.fetch)).toBeUndefined();
expect(await fetchLatestRelease(fakeFetch({}).fetch)).toBeUndefined();
});
it('offers only a newer, not yet dismissed version', () => {
const latest = { version: '1.0.3', url: 'u' };
expect(pickNotice(latest, '1.0.2', null)).toBe(latest);
expect(pickNotice(latest, '1.0.3', null)).toBeUndefined();
// dismissing 1.0.3 silences it, but 1.0.4 is offered again
expect(pickNotice(latest, '1.0.2', '1.0.3')).toBeUndefined();
expect(pickNotice({ version: '1.0.4', url: 'u' }, '1.0.2', '1.0.3')).toBeTruthy();
expect(pickNotice(undefined, '1.0.2', null)).toBeUndefined();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Constants from 'expo-constants';
import { useServices } from '@/data/hooks';
/**
* New-version notice: the latest GitHub release of this app against the installed version.
* Checked at most every few hours; the user can dismiss a version, and that version is never
* offered again (a newer one is).
*/
export const RELEASES_API = 'https://api.github.com/repos/nextroad-dev/aiusage/releases/latest';
const DISMISSED_KEY = 'update-dismissed-version';
const CHECK_EVERY_MS = 6 * 3_600_000;
export interface LatestRelease {
/** without the leading "v", e.g. "1.0.3" */
version: string;
url: string;
}
/** Numeric dotted compare: 1 when a > b, -1 when a < b, 0 when equal or not comparable. */
export function compareVersions(a: string, b: string): number {
const parse = (v: string) => v.trim().replace(/^v/i, '').split(/[.+-]/).slice(0, 3).map(Number);
const x = parse(a);
const y = parse(b);
if ([...x, ...y].some((n) => !Number.isFinite(n))) return 0;
for (let i = 0; i < 3; i++) {
const d = (x[i] ?? 0) - (y[i] ?? 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
/** Latest published (non-draft, non-prerelease) release, or undefined when it cannot be read. */
export async function fetchLatestRelease(
doFetch: typeof fetch,
): Promise<LatestRelease | undefined> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
try {
const res = await doFetch(RELEASES_API, {
headers: { Accept: 'application/vnd.github+json' },
signal: ctrl.signal,
});
if (!res.ok) return undefined;
const json = (await res.json()) as {
tag_name?: unknown;
html_url?: unknown;
draft?: unknown;
prerelease?: unknown;
};
if (json.draft === true || json.prerelease === true) return undefined;
if (typeof json.tag_name !== 'string' || typeof json.html_url !== 'string') return undefined;
return { version: json.tag_name.replace(/^v/i, ''), url: json.html_url };
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
/** The release to offer, if it is newer than this build and has not been dismissed. */
export function pickNotice(
latest: LatestRelease | undefined,
installed: string | undefined,
dismissed: string | null,
): LatestRelease | undefined {
if (!latest || !installed) return undefined;
if (compareVersions(latest.version, installed) <= 0) return undefined;
if (dismissed && compareVersions(latest.version, dismissed) <= 0) return undefined;
return latest;
}
export function useUpdateNotice(): { notice?: LatestRelease; dismiss: () => void } {
const services = useServices().data;
const qc = useQueryClient();
const latest = useQuery({
queryKey: ['latestRelease'],
queryFn: () => fetchLatestRelease((...a) => fetch(...a)),
staleTime: CHECK_EVERY_MS,
retry: false,
});
const dismissed = useQuery({
queryKey: ['updateDismissed'],
queryFn: () => services!.repos.settings.get(DISMISSED_KEY),
enabled: !!services,
});
// decide only once the dismissed version is known, so a dismissed notice never flashes
const notice = dismissed.isSuccess
? pickNotice(latest.data, Constants.expoConfig?.version, dismissed.data ?? null)
: undefined;
return {
notice,
dismiss: () => {
if (!notice || !services) return;
qc.setQueryData(['updateDismissed'], notice.version);
void services.repos.settings.set(DISMISSED_KEY, notice.version);
},
};
}
+3
View File
@@ -36,6 +36,9 @@ export const zh: Record<string, string> = {
'账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。',
// API relays
'API relay': '中转站',
'New v{version}': '新版本 v{version}',
'New version {version} available': '有新版本 {version}',
'Dismiss this version': '不再提示此版本',
'Tip the author': '打赏作者',
Afdian: '爱发电',
Close: '关闭',
+84
View File
@@ -0,0 +1,84 @@
import { SymbolView } from 'expo-symbols';
import { Linking, StyleSheet, View } from 'react-native';
import Animated from 'react-native-reanimated';
import { ThemedText } from '@/components/themed-text';
import { Radius, Spacing } from '@/constants/theme';
import type { LatestRelease } from '@/data/update-check';
import { useTheme } from '@/hooks/use-theme';
import { useT } from '@/i18n';
import { haptics } from '@/ui/haptics';
import { enterFade, exitFade, PressableScale } from '@/ui/motion';
/**
* "New version" pill beside a page title: tapping it opens the release page, the ✕ dismisses this
* version for good.
*/
export function UpdatePill({
release,
onDismiss,
}: {
release: LatestRelease;
onDismiss: () => void;
}) {
const theme = useTheme();
const t = useT();
return (
<Animated.View
entering={enterFade}
exiting={exitFade}
style={[styles.pill, { backgroundColor: theme.primary }]}
>
<PressableScale
accessibilityRole="link"
accessibilityLabel={t('New version {version} available', { version: release.version })}
accessibilityHint={t('Open in the browser')}
scaleTo={0.94}
hitSlop={6}
onPress={() => {
haptics.tap();
void Linking.openURL(release.url);
}}
style={styles.label}
>
<ThemedText type="smallBold" numberOfLines={1} style={{ color: theme.onPrimary }}>
{t('New v{version}', { version: release.version })}
</ThemedText>
</PressableScale>
<View style={[styles.divider, { backgroundColor: theme.onPrimary }]} />
<PressableScale
accessibilityRole="button"
accessibilityLabel={t('Dismiss this version')}
scaleTo={0.85}
hitSlop={8}
onPress={() => {
haptics.select();
onDismiss();
}}
style={styles.close}
>
<SymbolView
name={{ ios: 'xmark', android: 'close', web: 'close' }}
size={11}
weight="bold"
tintColor={theme.onPrimary}
/>
</PressableScale>
</Animated.View>
);
}
const styles = StyleSheet.create({
pill: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: Radius.pill,
paddingLeft: Spacing.two + 2,
paddingRight: Spacing.one,
minHeight: 26,
flexShrink: 1,
},
label: { paddingVertical: 3, flexShrink: 1 },
divider: { width: StyleSheet.hairlineWidth * 2, height: 12, opacity: 0.4, marginHorizontal: 6 },
close: { width: 20, height: 20, alignItems: 'center', justifyContent: 'center' },
});