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) {