Skip to content
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/mobile/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ On both platforms the system's own design and components are the standard: Apple

Older rules below that prescribe a custom surface where a native component exists describe the current implementation, not the target. Change them to the native component when the screen is next touched, and update the rule in the same change.

Back closes the topmost surface before it leaves the screen: an in-screen surface that is not its own route, such as the composer's attach tray, holds the screen with Expo Router's `usePreventRemove` and closes itself; the full client steps back through its web history first, while its floating exit still leaves at once. Do not add `BackHandler` listeners for new surfaces. The predictive back preview does not run yet: React Native 0.86's `ReactActivity` registers an always-enabled `OnBackPressedCallback` on targetSdk 36, so the system has no back-to-home animation to show and JS screens do not follow the gesture progress. Do not claim predictive back until that callback is released when the root route cannot go back.

## Application dialogs

- Application-owned confirmations and choice menus use `showAppDialog` (or `confirmDestructive` / `showActionMenu`) and the root `AppDialogHost`. Do not call React Native `Alert.alert` directly from a screen or feature. Per Native first, iOS shows system alerts (menus follow [iOS menus and native controls](#ios-menus-and-native-controls)), and Android shows the Material 3 `AlertDialog` from `@expo/ui/jetpack-compose`, themed by `seedColor` = accent. Operating-system permission prompts, photo pickers and the share sheet remain system-owned.
Expand Down
25 changes: 20 additions & 5 deletions apps/mobile/app/full-client.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Text } from '../src/ui/primitives/system-text';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useLocalSearchParams, useNavigation, usePreventRemove, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useState } from 'react';
import { ActivityIndicator, Pressable, View } from 'react-native';
import { useRef, useState } from 'react';
import { ActivityIndicator, Platform, Pressable, View } from 'react-native';

import { fullClientSource, fullClientTarget, type FullClientParams } from '../src/host/full-client';
import { useHostSession, useHostSnapshot } from '../src/host/host-session';
import { FloatingExit } from '../src/ui/floating-exit';
import { FullClientView } from '../src/ui/full-client-view';
import { FullClientView, type FullClientWeb } from '../src/ui/full-client-view';
import { t } from '../src/ui/strings';
import { useTheme } from '../src/ui/theme';

Expand All @@ -26,7 +26,20 @@ export default function FullClientScreen() {
snapshot.connection.route !== '' ? snapshot.connection.route : (saved?.routes[0] ?? '');
const revoked = saved === null || state === 'refused' || snapshot.connection.status === 'refused';

const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
const navigation = useNavigation();
const web = useRef<FullClientWeb>(null);
const exiting = useRef(false);
const [webBack, setWebBack] = useState(false);
usePreventRemove(Platform.OS === 'android' && webBack, ({ data }) => {
if (exiting.current) navigation.dispatch(data.action);
else web.current?.goBack();
});

const close = () => {
exiting.current = true;
if (router.canGoBack()) router.back();
else router.replace('/');
};
const retry = () => {
setState('loading');
setAttempt((value) => value + 1);
Expand Down Expand Up @@ -75,10 +88,12 @@ export default function FullClientScreen() {
<View style={{ flex: 1 }}>
<FullClientView
key={attempt}
ref={web}
route={route}
source={fullClientSource(route, saved.credential, fullClientTarget(params))}
onLoaded={() => setState((current) => (current === 'loading' ? 'loaded' : current))}
onFailed={setState}
onCanGoBack={setWebBack}
/>
{state === 'loading' ? (
<View
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/ui/chat/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Image,
Keyboard,
PanResponder,
Platform,
Pressable,
ScrollView,
View,
Expand All @@ -17,6 +18,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { usePreventRemove } from 'expo-router';

import type { PickSource, PickedFile } from '../../host/pick-files';
import { pressTint, useDesignTheme } from '../design/theme';
Expand Down Expand Up @@ -426,6 +428,9 @@ export function Composer({
});
};
const listening = dictating(dictation);
usePreventRemove(Platform.OS === 'android' && menu && canAttach && !listening, () =>
setMenu(false),
);
const actions = composerActions({
text: value,
files: files.length,
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/ui/full-client-view.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
import type { Ref } from 'react';
import { Linking } from 'react-native';
import { WebView } from 'react-native-webview';

import { staysInFullClient, type FullClientSource } from '../host/full-client';

export type FullClientWeb = { goBack(): void };

export interface FullClientViewProps {
route: string;
source: FullClientSource;
onLoaded(): void;
onFailed(kind: 'refused' | 'unreachable'): void;
onCanGoBack?(canGoBack: boolean): void;
ref?: Ref<FullClientWeb>;
}

export function FullClientView({ route, source, onLoaded, onFailed }: FullClientViewProps) {
export function FullClientView({
route,
source,
onLoaded,
onFailed,
onCanGoBack,
ref,
}: FullClientViewProps) {
return (
<WebView
{...({ ref } as object)}
source={source}
sharedCookiesEnabled={false}
originWhitelist={['https://*', 'http://*']}
Expand All @@ -24,6 +37,7 @@ export function FullClientView({ route, source, onLoaded, onFailed }: FullClient
return false;
}}
onLoadEnd={onLoaded}
onNavigationStateChange={(state) => onCanGoBack?.(state.canGoBack)}
onHttpError={(event) => {
if (event.nativeEvent.statusCode === 401) onFailed('refused');
else if (event.nativeEvent.statusCode >= 500) onFailed('unreachable');
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/test/composer-dictation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ vi.mock('react-native-reanimated', async () => {
});
vi.mock('react-native-svg', () => ({ default: () => null, Path: () => null }));
vi.mock('expo-haptics', () => import('./expo-haptics-shim'));
vi.mock('expo-router', () => ({ usePreventRemove: () => {} }));
vi.mock('expo-speech-recognition', () => ({
ExpoSpeechRecognitionModule: {
isRecognitionAvailable: () => speech.available,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/test/conversation-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock('../src/ui/shell/recent-chats', () => ({
vi.mock('../src/host/native-source-drafts', () => ({ useSourceDraft: () => ['', () => {}] }));
vi.mock('expo-router', () => ({
useIsFocused: () => true,
usePreventRemove: () => {},
Stack: {
Screen: ({
options,
Expand Down
83 changes: 83 additions & 0 deletions apps/mobile/test/full-client-back-android.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { act, createElement, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';

const nav = vi.hoisted(() => ({
prevent: false,
onPrevent: undefined as undefined | ((event: { data: { action: unknown } }) => void),
dispatched: [] as unknown[],
back: vi.fn(),
goBack: vi.fn(),
canGoBack: undefined as undefined | ((value: boolean) => void),
exit: undefined as undefined | (() => void),
}));

vi.mock('react-native', async () => ({
...(await import('./react-native-shim')),
Platform: { OS: 'android' },
View: ({ children }: { children?: ReactNode }) => createElement('div', {}, children),
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}));
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({}),
useRouter: () => ({ canGoBack: () => true, back: nav.back, replace: vi.fn() }),
useNavigation: () => ({ dispatch: (action: unknown) => nav.dispatched.push(action) }),
usePreventRemove: (
prevent: boolean,
callback: (event: { data: { action: unknown } }) => void,
) => {
nav.prevent = prevent;
nav.onPrevent = callback;
},
}));
vi.mock('../src/host/host-session', () => ({
useHostSession: () => ({
saved: { hostId: 'h', routes: ['https://host.example'], credential: 'c' },
client: undefined,
}),
useHostSnapshot: () => ({ connection: { route: 'https://host.example', status: 'online' } }),
}));
vi.mock('../src/ui/floating-exit', () => ({
FloatingExit: ({ onExit }: { onExit(): void }) => {
nav.exit = onExit;
return null;
},
}));
vi.mock('../src/ui/full-client-view', () => ({
FullClientView: (props: { ref: { current: unknown }; onCanGoBack(value: boolean): void }) => {
props.ref.current = { goBack: nav.goBack };
nav.canGoBack = props.onCanGoBack;
return null;
},
}));
vi.mock('../src/ui/theme', () => ({ useTheme: () => ({ background: '#fff', ink: '#000' }) }));
vi.mock('../src/ui/primitives/system-text', () => ({ Text: () => null }));

import FullClientScreen from '../app/full-client';

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root | undefined;
afterEach(() => act(() => root?.unmount()));

it('steps back through web history on Android Back, and the exit button still leaves', async () => {
const host = document.createElement('div');
root = createRoot(host);
await act(async () => root!.render(<FullClientScreen />));
expect(nav.prevent).toBe(false);

await act(async () => nav.canGoBack!(true));
expect(nav.prevent).toBe(true);
const action = { type: 'GO_BACK' };
nav.onPrevent!({ data: { action } });
expect(nav.goBack).toHaveBeenCalledTimes(1);
expect(nav.dispatched).toEqual([]);

nav.exit!();
expect(nav.back).toHaveBeenCalledTimes(1);
nav.onPrevent!({ data: { action } });
expect(nav.dispatched).toEqual([action]);
expect(nav.goBack).toHaveBeenCalledTimes(1);
});
11 changes: 11 additions & 0 deletions changes/2343-android-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
section: Changed
---

## en

- On Android, Back now closes the composer's attach tray and steps back inside the full client before leaving the screen ([#2343](https://github.com/BotHarness/DeepSeekBot/pull/2343)).

## zh

- Android 手机端的返回键现在会先收起输入框的附件栏、在完整客户端里先回到上一页,再离开当前页面([#2343](https://github.com/BotHarness/DeepSeekBot/pull/2343))。
Loading