New-version notice beside the Usage title from GitHub releases; dismissible per version
This commit is contained in:
1 parent
7762e22e39
commit
e167cd28fa
7 files changed
+254
-6
No files matched your search
@@ -27,6 +27,7 @@
|
||||
- **订阅与花费**:显示服务商返回的套餐到期 / 续费日;为每个账号填上月费,首页汇总你每月在 AI 订阅上花了多少。
|
||||
- **及时提醒**:用量超过你设的阈值、窗口重置额度恢复、套餐快到期、登录失效时,发本地通知提醒你。
|
||||
- **小组件**(iOS):主屏和锁屏小组件,不打开应用也能看余量。
|
||||
- **新版本提示**:有新版时在首页标题旁提示,点一下前往下载;关闭后同一版本不再提示。
|
||||
- **好用的细节**:中文 / English(可跟随系统)、浅色 / 深色、7 种主题色、流畅的动画与触觉反馈,并尊重系统的「减弱动态效果」。
|
||||
|
||||
## 下载与安装
|
||||
@@ -83,6 +84,7 @@
|
||||
- **凭据只存在本机**:API Key 和登录令牌保存在系统钥匙串(iOS Keychain / Android Keystore)中,不会上传。
|
||||
- **用量历史只存在本机**:保存在应用的本地数据库里,可在「设置 → 数据」一键全部删除。
|
||||
- **无广告、无统计**:不收集任何使用数据。
|
||||
- **版本检查**:应用每隔几小时读取一次本仓库在 GitHub 上的最新 Release,用来提示新版本;请求不携带任何账号或用量信息。
|
||||
- 浏览器登录使用本机 `127.0.0.1` 临时回调,只在登录过程中短暂开启,授权完成后立即关闭。
|
||||
|
||||
## 常见问题
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: '关闭',
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
Reference in new issue
Block a user