Keep focused fields above the keyboard; relay balances follow the site's display currency; price currency only with a price

This commit is contained in:
Jude.Chen committed 2026-10-07 03:40:35 +08:00
1 parent cb60b55166
commit 900b3fd302
9 files changed
+170 -21

No files matched your search

+15
View File
@@ -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",
+1
View File
@@ -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",
+4 -1
View File
@@ -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 (
<QueryClientProvider client={queryClient}>
<PreferencesProvider>
<ThemedRoot />
<KeyboardProvider>
<ThemedRoot />
</KeyboardProvider>
</PreferencesProvider>
</QueryClientProvider>
);
+20 -12
View File
@@ -100,18 +100,26 @@ function SubscriptionSection({
placeholder={t('Optional')}
hint={t('Used for the monthly total on the overview.')}
/>
<Segmented<Price['currency']>
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() ? (
<Animated.View entering={enterFade} exiting={exitFade} style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Price currency')}
</ThemedText>
<Segmented<Price['currency']>
label={t('Price currency')}
options={[
{ value: 'USD', label: 'USD $' },
{ value: 'CNY', label: 'CNY ¥' },
]}
value={currency}
onChange={(c) => {
setCurrency(c);
commit(c);
}}
/>
</Animated.View>
) : null}
</Section>
);
}
+37
View File
@@ -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 (
<lib.KeyboardProvider statusBarTranslucent navigationBarTranslucent>
{children}
</lib.KeyboardProvider>
);
}
/** ScrollView that keeps the focused input `bottomOffset` points above the keyboard. */
export const KeyboardAwareScrollView: ComponentType<ScrollViewProps & { bottomOffset?: number }> =
lib
? (lib.KeyboardAwareScrollView as ComponentType<ScrollViewProps & { bottomOffset?: number }>)
: ScrollView;
+6 -3
View File
@@ -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']}
>
<ScrollView
{/* a focused field always scrolls clear of the keyboard, with a little breathing room */}
<KeyboardAwareScrollView
bottomOffset={Spacing.four}
contentContainerStyle={styles.content}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
@@ -41,7 +44,7 @@ export function Screen({
}
>
{children}
</ScrollView>
</KeyboardAwareScrollView>
</SafeAreaView>
</ThemedView>
);
+1
View File
@@ -30,6 +30,7 @@ export const zh: Record<string, string> = {
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',
+49
View File
@@ -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);
+37 -5
View File
@@ -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) {