Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/pr-screenshots/2401/chat-2-photos-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/chat-2-photos-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/chat-link-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/chat-link-l.png
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.
Binary file added .github/pr-screenshots/2401/cold-send-to-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/sheet-2-photos-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/sheet-2-photos-l.png
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.
Binary file added .github/pr-screenshots/2401/sheet-link-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/sheet-link-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/unpaired-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/2401/unpaired-l.png
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.
1 change: 1 addition & 0 deletions apps/mobile/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- Swipe for pin and mute; long-press opens a native menu.
- On iOS a long-press on a Host conversation row peeks at the chat, like Messages: the row lifts into the system context-menu preview (expo-router `Link.Preview`) showing the conversation itself, and tapping the preview opens it. A plain tap opens the conversation as before.
- A long-press on the app icon lists the three chats opened most recently (Home Screen quick actions on iOS, app shortcuts on Android), newest first, and tapping one opens that chat on its Host. The list updates whenever a chat opens and is kept with the device's saved Host list.
- **Share to a Bot.** DeepSeekBot is a target in the system share sheet (an iOS Share Extension, Android `SEND` / `SEND_MULTIPLE`). Sharing text, a link, photos, videos or files opens **Send to**: a preview of what arrived above the paired Host's chats, in the Chats order. Picking a chat opens it with the text added below any unsent draft and the files waiting as attachment tiles; nothing is sent until the Human taps Send. Cloud channels are not targets yet, and with no paired Host the screen says to pair first.

**Count chip**

Expand Down
28 changes: 26 additions & 2 deletions apps/mobile/app.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { execFileSync } = require('node:child_process');
const { existsSync } = require('node:fs');
const path = require('node:path');
const withSharingPlugin = require('expo-sharing/app.plugin').default;

const mainCheckout = () => {
try {
Expand All @@ -21,10 +22,33 @@ const googleServicesFile = () => {
return existsSync(local) ? local : undefined;
};

const sharing = {
ios: {
enabled: true,
activationRule: {
supportsText: true,
supportsWebUrlWithMaxCount: 1,
supportsImageWithMaxCount: 10,
supportsMovieWithMaxCount: 10,
supportsFileWithMaxCount: 10,
},
},
android: {
enabled: true,
singleShareMimeTypes: ['text/*', 'image/*', 'video/*', 'audio/*', 'application/*'],
multipleShareMimeTypes: ['image/*', 'video/*', 'audio/*', 'application/*'],
},
};

const withSharing = (resolved) => {
const scheme = Array.isArray(resolved.scheme) ? resolved.scheme[0] : resolved.scheme;
return { ...withSharingPlugin({ ...resolved, scheme }, sharing), scheme: resolved.scheme };
};

module.exports = ({ config }) => {
const store = process.env.DEEPSEEKBOT_APP_VARIANT === 'store';
const firebase = googleServicesFile();
return {
return withSharing({
...config,
owner: 'deepseekbots-team',
updates: { ...config.updates, enabled: false },
Expand All @@ -40,5 +64,5 @@ module.exports = ({ config }) => {
...(firebase ? { googleServicesFile: firebase } : {}),
},
...(store ? { name: 'DeepSeekBot', scheme: 'deepseekbot' } : {}),
};
});
};
1 change: 0 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
],
"expo-font",
"expo-image",
"expo-sharing",
"expo-status-bar",
"expo-web-browser"
],
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/app/+native-intent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import * as SecureStore from 'expo-secure-store';
import { cloudAvailable } from '../src/cloud/available';
import { cloudNativeIntent } from '../src/cloud/native-intent';
import { parseInvitationLink } from '../src/cloud/invitation-link';
import { isSharePath } from '../src/ui/share/share-path';
export async function redirectSystemPath({ path }: { path: string; initial: boolean }) {
if (isSharePath(path)) return '/share';
const destination = cloudNativeIntent(path);
if (!cloudAvailable && destination.startsWith('/cloud-')) return '/';
if (destination.startsWith('/cloud-join?link=')) {
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export default function RootLayout() {
name="pair"
options={{ title: t.pairTitle, ...sheetScreenOptions(design, true) }}
/>
<Stack.Screen
name="share"
options={{ title: t.share.title, ...sheetScreenOptions(design, true) }}
/>
<Stack.Screen name="inbox/[id]" options={{ title: t.inbox }} />
<Stack.Screen
name="cloud-join"
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app/share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '../src/ui/share/share-screen';
11 changes: 11 additions & 0 deletions apps/mobile/src/host/pick-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ export function fromClipboardImage(data: string): PickedFile {
};
}

export function fromSharedFile(uri: string, name: string | null, mime: string | null): PickedFile {
picked += 1;
return {
id: `pick-${picked}`,
kind: mime?.startsWith('image/') === true ? 'image' : 'file',
uri,
name: name ?? `shared-${picked}${extension(mime ?? undefined)}`,
read: readUri(uri),
};
}

export async function pickFiles(source: PickSource, limit: number): Promise<PickedFile[]> {
if (limit <= 0) return [];
if (source === 'file') {
Expand Down
23 changes: 16 additions & 7 deletions apps/mobile/src/ui/chat/conversation-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useSourceDraft } from '../../host/native-source-drafts';
import { hostDraftKey } from '../../host/source-drafts';
import { takeSharedFiles } from '../share/incoming-share';
import { Stack, useIsFocused, useRouter } from 'expo-router';
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Expand Down Expand Up @@ -345,13 +346,12 @@ function ChatConversation({
const playback = useStreamPlayback(conversation, display.loaded && display.mode === 'streaming');
const snapshot = useConversationTimelineSnapshot(conversation, playback);
const shownDrafts = display.loaded && display.mode === 'streaming' ? snapshot.drafts : NO_DRAFTS;
const [draft, setDraft] = useSourceDraft(
hostDraftKey(
saved?.hostId ?? '',
place.kind,
(place.kind === 'dm' ? place.slug : place.channelId) ?? '',
),
const draftKey = hostDraftKey(
saved?.hostId ?? '',
place.kind,
(place.kind === 'dm' ? place.slug : place.channelId) ?? '',
);
const [draft, setDraft] = useSourceDraft(draftKey);
const [mentions, setMentions] = useState<SelectedMention[]>([]);
const [caret, setCaret] = useState(0);
const [selection, setSelection] = useState<{ start: number; end: number }>();
Expand Down Expand Up @@ -474,14 +474,23 @@ function ChatConversation({
}
: undefined,
);
const sharedMount = useCallback(
(node: unknown) => {
if (node === null) return;
const shared = takeSharedFiles(draftKey);
if (shared.length > 0) setFiles((current) => [...current, ...shared].slice(0, MAX_PICKED));
},
[draftKey],
);
const mountRef = dictation.mountRef;
const screenRef = useCallback(
(node: unknown) => {
mountRef(node);
readMount(node);
recentMount(node);
sharedMount(node);
},
[mountRef, readMount, recentMount],
[mountRef, readMount, recentMount, sharedMount],
);

const asking = snapshot.messages.findLast(
Expand Down
45 changes: 45 additions & 0 deletions apps/mobile/src/ui/share/incoming-share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ResolvedSharePayload } from 'expo-sharing';

import { fromSharedFile, MAX_PICKED, type PickedFile } from '../../host/pick-files';
import { sourceDrafts } from '../../host/native-source-drafts';

export type IncomingShare = { text: string; files: PickedFile[] };

export function incomingShare(payloads: readonly ResolvedSharePayload[]): IncomingShare {
const text: string[] = [];
const files: PickedFile[] = [];
for (const payload of payloads) {
const uri = payload.contentUri;
const attachment =
uri !== null &&
payload.shareType !== 'text' &&
payload.shareType !== 'url' &&
payload.contentType !== 'website' &&
payload.contentType !== 'text';
if (attachment) {
if (files.length < MAX_PICKED)
files.push(fromSharedFile(uri, payload.originalName, payload.contentMimeType ?? null));
} else if (payload.value.trim() !== '') text.push(payload.value.trim());
}
return { text: text.join('\n'), files };
}

export function appendShared(current: string, text: string): string {
if (text === '') return current;
return current.trim() === '' ? text : `${current.replace(/\s+$/u, '')}\n${text}`;
}

const held = new Map<string, PickedFile[]>();

export function takeSharedFiles(key: string): PickedFile[] {
const files = held.get(key) ?? [];
held.delete(key);
return files;
}

export async function deliverShare(key: string, share: IncomingShare): Promise<void> {
await sourceDrafts.ready;
if (share.text !== '') sourceDrafts.set(key, appendShared(sourceDrafts.get(key), share.text));
if (share.files.length > 0)
held.set(key, [...(held.get(key) ?? []), ...share.files].slice(0, MAX_PICKED));
}
3 changes: 3 additions & 0 deletions apps/mobile/src/ui/share/share-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isSharePath(path: string): boolean {
return /^(?:[a-z][a-z0-9+.-]*:\/\/)?\/?expo-sharing(?:[/?#]|$)/iu.test(path);
}
113 changes: 113 additions & 0 deletions apps/mobile/src/ui/share/share-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useIncomingShare } from 'expo-sharing';
import { useRouter } from 'expo-router';
import { useMemo, useState } from 'react';
import { ActivityIndicator, FlatList, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { useHostSession, useHostSnapshot, useQuietChannels } from '../../host/host-session';
import { hostDraftKey } from '../../host/source-drafts';
import { ConversationRow } from '../chat/conversation-row';
import { conversations, type Conversation } from '../chat/conversations';
import { useDesignTheme } from '../design/theme';
import { radius, space } from '../design/tokens';
import { Button } from '../primitives/button';
import { DrawerHeader } from '../primitives/drawer-header';
import { Text } from '../primitives/text';
import { conversationHref } from '../shell/open-conversation';
import { t } from '../strings';
import { deliverShare, incomingShare } from './incoming-share';

function draftKey(hostId: string, row: Conversation): string {
return row.kind === 'bot'
? hostDraftKey(hostId, 'dm', row.bot.slug)
: hostDraftKey(hostId, 'channel', row.channel.id);
}

export default function ShareScreen() {
const router = useRouter();
const theme = useDesignTheme();
const insets = useSafeAreaInsets();
const { saved, client } = useHostSession();
const snapshot = useHostSnapshot(client);
const quiet = useQuietChannels(saved?.hostId);
const incoming = useIncomingShare();
const share = useMemo(
() => incomingShare(incoming.resolvedSharedPayloads),
[incoming.resolvedSharedPayloads],
);
const rows = useMemo(
() => conversations(snapshot.roster, [], {}, quiet),
[snapshot.roster, quiet],
);
const [sending, setSending] = useState(false);
const empty = !incoming.isResolving && share.text === '' && share.files.length === 0;
const send = async (row: Conversation) => {
if (saved === undefined || saved === null || sending || empty) return;
setSending(true);
await deliverShare(draftKey(saved.hostId, row), share);
incoming.clearSharedPayloads();
router.replace(conversationHref(row, saved.hostId));
};
const close = () => {
incoming.clearSharedPayloads();
if (router.canGoBack()) router.back();
else router.replace('/');
};
return (
<FlatList
style={{ flex: 1, backgroundColor: theme.overlay.fill }}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ paddingBottom: insets.bottom + space.x6 }}
data={saved && !empty ? rows : []}
keyExtractor={(row) => row.key}
ListHeaderComponent={
<View style={{ paddingTop: space.x5, paddingHorizontal: space.x5 }}>
<DrawerHeader title={t.share.title} onClose={close} />
<View
testID="share-preview"
style={{
marginVertical: space.x5,
padding: space.x5,
gap: space.x3,
borderRadius: radius.lg,
backgroundColor: theme.layer.raised.fill,
}}
>
{incoming.isResolving ? (
<ActivityIndicator />
) : empty ? (
<Text tone="muted">{t.share.nothing}</Text>
) : (
<>
{share.text !== '' && <Text numberOfLines={4}>{share.text}</Text>}
{share.files.length > 0 && (
<Text variant="meta" tone="muted">
{t.share.files(share.files.length)}
</Text>
)}
</>
)}
</View>
</View>
}
ListEmptyComponent={
saved === null ? (
<View style={{ paddingHorizontal: space.x5, gap: space.x4 }}>
<Text tone="muted">{t.share.pairFirst}</Text>
<Button
label={t.pairTitle}
tone="primary"
testID="share-pair"
onPress={() => router.replace('/pair')}
/>
</View>
) : !empty && snapshot.roster === undefined ? (
<ActivityIndicator />
) : undefined
}
renderItem={({ item }) => (
<ConversationRow row={item} now={new Date()} onPress={() => void send(item)} />
)}
/>
);
}
12 changes: 12 additions & 0 deletions apps/mobile/src/ui/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,12 @@ const en = {
pullToCompose: 'Pull up to type',
clipboardImage: 'Image on the clipboard',
pasteImage: 'Paste image',
share: {
title: 'Send to',
nothing: 'Nothing was shared.',
pairFirst: 'Pair a computer first to send this to a Bot.',
files: (count: number) => (count === 1 ? '1 attachment' : `${count} attachments`),
},
releaseToCompose: 'Release to type',
unseenLatest: (count: number) =>
`${count} new ${count === 1 ? 'message' : 'messages'} · Jump to latest`,
Expand Down Expand Up @@ -1593,6 +1599,12 @@ const zh: typeof en = {
pullToCompose: '上拉输入消息',
clipboardImage: '剪贴板里有一张图片',
pasteImage: '粘贴图片',
share: {
title: '发送到',
nothing: '没有收到分享的内容。',
pairFirst: '先配对一台电脑,才能发送给 Bot。',
files: (count: number) => `${count} 个附件`,
},
releaseToCompose: '松手输入消息',
unseenLatest: (count: number) => `${count} 条新消息 · 跳到最新`,
beginning: '这是你们私信的开始。',
Expand Down
Loading
Loading