diff --git a/package-lock.json b/package-lock.json
index 0770afb..b8017c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,6 +41,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
+ "react-native-keyboard-controller": "1.21.9",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
@@ -13661,6 +13662,20 @@
"react-native": "*"
}
},
+ "node_modules/react-native-keyboard-controller": {
+ "version": "1.21.9",
+ "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.9.tgz",
+ "integrity": "sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-is-edge-to-edge": "^1.2.1"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-reanimated": ">=3.0.0"
+ }
+ },
"node_modules/react-native-reanimated": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
diff --git a/package.json b/package.json
index 983d622..b93cb54 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
+ "react-native-keyboard-controller": "1.21.9",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index e742778..c6329c1 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -3,6 +3,7 @@ import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import { useState } from 'react';
import { AppLifecycle } from '@/components/app-lifecycle';
+import { KeyboardProvider } from '@/components/keyboard';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useT } from '@/i18n';
import { PreferencesProvider } from '@/providers/preferences';
@@ -36,7 +37,9 @@ export default function RootLayout() {
return (
-
+
+
+
);
diff --git a/src/app/account/[id].tsx b/src/app/account/[id].tsx
index eda8566..70c9cda 100644
--- a/src/app/account/[id].tsx
+++ b/src/app/account/[id].tsx
@@ -100,18 +100,26 @@ function SubscriptionSection({
placeholder={t('Optional')}
hint={t('Used for the monthly total on the overview.')}
/>
-
- label={t('Currency')}
- options={[
- { value: 'USD', label: 'USD $' },
- { value: 'CNY', label: 'CNY ¥' },
- ]}
- value={currency}
- onChange={(c) => {
- setCurrency(c);
- if (amount.trim()) commit(c);
- }}
- />
+ {/* the currency belongs to the price: offered once there is a price to save it with */}
+ {amount.trim() ? (
+
+
+ {t('Price currency')}
+
+
+ label={t('Price currency')}
+ options={[
+ { value: 'USD', label: 'USD $' },
+ { value: 'CNY', label: 'CNY ¥' },
+ ]}
+ value={currency}
+ onChange={(c) => {
+ setCurrency(c);
+ commit(c);
+ }}
+ />
+
+ ) : null}
);
}
diff --git a/src/components/keyboard.tsx b/src/components/keyboard.tsx
new file mode 100644
index 0000000..1b0708b
--- /dev/null
+++ b/src/components/keyboard.tsx
@@ -0,0 +1,37 @@
+import type { ComponentType, ReactNode } from 'react';
+import { ScrollView, TurboModuleRegistry, type ScrollViewProps } from 'react-native';
+
+/**
+ * Keyboard avoidance for forms: with react-native-keyboard-controller the focused field scrolls
+ * above the keyboard on iOS and on edge-to-edge Android. The library throws as soon as it is
+ * imported when its native module is missing (Expo Go, older builds, tests), so it is loaded only
+ * after checking for the module, and everything falls back to a plain ScrollView otherwise.
+ */
+const hasNative = (() => {
+ try {
+ return TurboModuleRegistry.get('KeyboardController') != null;
+ } catch {
+ return false;
+ }
+})();
+
+type Lib = typeof import('react-native-keyboard-controller');
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const lib: Lib | null = hasNative ? require('react-native-keyboard-controller') : null;
+
+/** Mount once at the root. */
+export function KeyboardProvider({ children }: { children: ReactNode }) {
+ if (!lib) return <>{children}>;
+ // the app is edge-to-edge on Android: keep the system bars translucent while tracking the keyboard
+ return (
+
+ {children}
+
+ );
+}
+
+/** ScrollView that keeps the focused input `bottomOffset` points above the keyboard. */
+export const KeyboardAwareScrollView: ComponentType =
+ lib
+ ? (lib.KeyboardAwareScrollView as ComponentType)
+ : ScrollView;
diff --git a/src/components/screen.tsx b/src/components/screen.tsx
index 506b0be..5644208 100644
--- a/src/components/screen.tsx
+++ b/src/components/screen.tsx
@@ -1,7 +1,8 @@
import type { ReactNode } from 'react';
-import { RefreshControl, ScrollView, StyleSheet } from 'react-native';
+import { RefreshControl, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { KeyboardAwareScrollView } from '@/components/keyboard';
import { ThemedView } from '@/components/themed-view';
import { MaxContentWidth, Spacing } from '@/constants/theme';
import { haptics } from '@/ui/haptics';
@@ -24,7 +25,9 @@ export function Screen({
style={styles.safe}
edges={safeTop ? ['top', 'left', 'right'] : ['left', 'right']}
>
-
{children}
-
+
);
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 7f0ddcc..409a7d0 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -30,6 +30,7 @@ export const zh: Record = {
Refresh: '刷新',
// API relays
'API relay': 'API 中转站',
+ 'Price currency': '月费货币',
'New API, Sub2API, One API and other OpenAI-compatible relays: enter the site address and key, the rest is detected.':
'支持 New API、Sub2API、One API 及其他 OpenAI 兼容中转站:只需填写站点地址和 Key,其余自动识别。',
'Site address + API key': '站点地址 + API Key',
diff --git a/src/providers/__tests__/relay.test.ts b/src/providers/__tests__/relay.test.ts
index 6d6c969..4283160 100644
--- a/src/providers/__tests__/relay.test.ts
+++ b/src/providers/__tests__/relay.test.ts
@@ -2,6 +2,7 @@ import { AuthExpiredError } from '@/core/errors';
import { relayMeters, relayPlugin } from '@/providers/relay';
import {
htmlTitle,
+ newApiDisplay,
inspectProvider,
normalizeBaseUrl,
refreshBilling,
@@ -137,6 +138,54 @@ describe('relay inspection', () => {
});
});
+describe('New API display currency', () => {
+ it('shows the balance in the currency the site is configured for', async () => {
+ const r = await inspect(
+ at({
+ ...newApiSite,
+ '/api/status': {
+ json: {
+ success: true,
+ data: {
+ system_name: 'CN Relay',
+ quota_per_unit: 500_000,
+ quota_display_type: 'CNY',
+ usd_exchange_rate: 7.3,
+ },
+ },
+ },
+ }),
+ );
+ // $16.58 at 7.3 CNY per USD
+ expect(r.billing).toMatchObject({ currency: 'CNY', remaining: 121.034, total: 146 });
+ });
+
+ it('covers custom symbols, raw tokens, the legacy flag and missing rates', () => {
+ expect(newApiDisplay({ quota_per_unit: 500_000 })).toEqual({
+ currency: 'USD',
+ factor: 0.000002,
+ });
+ expect(
+ newApiDisplay({
+ quota_per_unit: 500_000,
+ quota_display_type: 'CUSTOM',
+ custom_currency_symbol: '点',
+ custom_currency_exchange_rate: 10,
+ }),
+ ).toEqual({ currency: '点', factor: 0.00002 });
+ expect(newApiDisplay({ quota_display_type: 'TOKENS' })).toEqual({
+ currency: 'tokens',
+ factor: 1,
+ });
+ expect(newApiDisplay({ display_in_currency: false })).toEqual({
+ currency: 'tokens',
+ factor: 1,
+ });
+ // CNY without a rate cannot be converted honestly
+ expect(newApiDisplay({ quota_display_type: 'CNY' }).currency).toBe('USD');
+ });
+});
+
describe('relay helpers', () => {
it('normalises the address the user typed', () => {
expect(normalizeBaseUrl(' api.example.com/ ')).toBe(BASE);
diff --git a/src/providers/relay/inspect.ts b/src/providers/relay/inspect.ts
index 8d8fd47..533b6ca 100644
--- a/src/providers/relay/inspect.ts
+++ b/src/providers/relay/inspect.ts
@@ -192,22 +192,54 @@ const newApi: RelayAdapter = {
]);
const d = dataOf(token);
if (!d || d.object !== 'token_usage') return oneApi.billing(ctx);
- const perUnit = num(dataOf(status)?.quota_per_unit) || DEFAULT_QUOTA_PER_UNIT;
+ const s = dataOf(status);
+ const display = newApiDisplay(s);
const unlimited = d.unlimited_quota === true;
const granted = num(d.total_granted);
const used = num(d.total_used);
const available = num(d.total_available);
+ const conv = (q: number | undefined) =>
+ q === undefined ? undefined : round(q * display.factor);
return {
supported: used !== undefined || available !== undefined,
- currency: 'USD',
+ currency: display.currency,
unlimited,
- total: unlimited || granted === undefined ? undefined : round(granted / perUnit),
- used: used === undefined ? undefined : round(used / perUnit),
- remaining: unlimited || available === undefined ? undefined : round(available / perUnit),
+ total: unlimited ? undefined : conv(granted),
+ used: conv(used),
+ remaining: unlimited ? undefined : conv(available),
};
},
};
+/**
+ * How the site itself shows quota (New API `/api/status`): USD by default, CNY at the site's
+ * `usd_exchange_rate`, a custom symbol at `custom_currency_exchange_rate`, or raw tokens. Older
+ * versions only have `display_in_currency`. Returns the multiplier from raw quota units.
+ */
+export function newApiDisplay(s: Obj | undefined): { currency: string; factor: number } {
+ const perUnit = num(s?.quota_per_unit) || DEFAULT_QUOTA_PER_UNIT;
+ const usd = 1 / perUnit;
+ const type =
+ str(s?.quota_display_type)?.toUpperCase() ??
+ (s?.display_in_currency === false ? 'TOKENS' : 'USD');
+ if (type === 'TOKENS') return { currency: 'tokens', factor: 1 };
+ if (type === 'CNY') {
+ const rate = num(s?.usd_exchange_rate);
+ // without a rate the CNY figure cannot be derived; stay truthful in dollars
+ return rate && rate > 0
+ ? { currency: 'CNY', factor: rate / perUnit }
+ : { currency: 'USD', factor: usd };
+ }
+ if (type === 'CUSTOM') {
+ const rate = num(s?.custom_currency_exchange_rate);
+ const symbol = str(s?.custom_currency_symbol);
+ return rate && rate > 0 && symbol
+ ? { currency: symbol, factor: rate / perUnit }
+ : { currency: 'USD', factor: usd };
+ }
+ return { currency: 'USD', factor: usd };
+}
+
const sub2api: RelayAdapter = {
type: 'sub2api',
async detect(ctx) {