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:
1 parent
cb60b55166
commit
900b3fd302
9 files changed
+170
-21
No files matched your search
Generated
+15
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user