diff --git a/.github/pr-screenshots/2401/chat-2-photos-d.png b/.github/pr-screenshots/2401/chat-2-photos-d.png new file mode 100644 index 0000000000..044df2f0e1 Binary files /dev/null and b/.github/pr-screenshots/2401/chat-2-photos-d.png differ diff --git a/.github/pr-screenshots/2401/chat-2-photos-l.png b/.github/pr-screenshots/2401/chat-2-photos-l.png new file mode 100644 index 0000000000..591b255d39 Binary files /dev/null and b/.github/pr-screenshots/2401/chat-2-photos-l.png differ diff --git a/.github/pr-screenshots/2401/chat-link-d.png b/.github/pr-screenshots/2401/chat-link-d.png new file mode 100644 index 0000000000..5f2c9611d1 Binary files /dev/null and b/.github/pr-screenshots/2401/chat-link-d.png differ diff --git a/.github/pr-screenshots/2401/chat-link-l.png b/.github/pr-screenshots/2401/chat-link-l.png new file mode 100644 index 0000000000..7af624f6cf Binary files /dev/null and b/.github/pr-screenshots/2401/chat-link-l.png differ diff --git a/.github/pr-screenshots/2401/cold-chat-draft-l.png b/.github/pr-screenshots/2401/cold-chat-draft-l.png new file mode 100644 index 0000000000..39a73bce45 Binary files /dev/null and b/.github/pr-screenshots/2401/cold-chat-draft-l.png differ diff --git a/.github/pr-screenshots/2401/cold-close-chats-l.png b/.github/pr-screenshots/2401/cold-close-chats-l.png new file mode 100644 index 0000000000..ead0f5d099 Binary files /dev/null and b/.github/pr-screenshots/2401/cold-close-chats-l.png differ diff --git a/.github/pr-screenshots/2401/cold-send-to-l.png b/.github/pr-screenshots/2401/cold-send-to-l.png new file mode 100644 index 0000000000..19a08ecc98 Binary files /dev/null and b/.github/pr-screenshots/2401/cold-send-to-l.png differ diff --git a/.github/pr-screenshots/2401/sheet-2-photos-d.png b/.github/pr-screenshots/2401/sheet-2-photos-d.png new file mode 100644 index 0000000000..362428fc8a Binary files /dev/null and b/.github/pr-screenshots/2401/sheet-2-photos-d.png differ diff --git a/.github/pr-screenshots/2401/sheet-2-photos-l.png b/.github/pr-screenshots/2401/sheet-2-photos-l.png new file mode 100644 index 0000000000..d56fb37547 Binary files /dev/null and b/.github/pr-screenshots/2401/sheet-2-photos-l.png differ diff --git a/.github/pr-screenshots/2401/sheet-2-photos-zh-d.png b/.github/pr-screenshots/2401/sheet-2-photos-zh-d.png new file mode 100644 index 0000000000..84dac1aee7 Binary files /dev/null and b/.github/pr-screenshots/2401/sheet-2-photos-zh-d.png differ diff --git a/.github/pr-screenshots/2401/sheet-2-photos-zh-l.png b/.github/pr-screenshots/2401/sheet-2-photos-zh-l.png new file mode 100644 index 0000000000..8e3cbba880 Binary files /dev/null and b/.github/pr-screenshots/2401/sheet-2-photos-zh-l.png differ diff --git a/.github/pr-screenshots/2401/sheet-link-d.png b/.github/pr-screenshots/2401/sheet-link-d.png new file mode 100644 index 0000000000..ca14cfa1bf Binary files /dev/null and b/.github/pr-screenshots/2401/sheet-link-d.png differ diff --git a/.github/pr-screenshots/2401/sheet-link-l.png b/.github/pr-screenshots/2401/sheet-link-l.png new file mode 100644 index 0000000000..7b30269598 Binary files /dev/null and b/.github/pr-screenshots/2401/sheet-link-l.png differ diff --git a/.github/pr-screenshots/2401/unpaired-d.png b/.github/pr-screenshots/2401/unpaired-d.png new file mode 100644 index 0000000000..8baa39a1ec Binary files /dev/null and b/.github/pr-screenshots/2401/unpaired-d.png differ diff --git a/.github/pr-screenshots/2401/unpaired-l.png b/.github/pr-screenshots/2401/unpaired-l.png new file mode 100644 index 0000000000..9ea4f59b14 Binary files /dev/null and b/.github/pr-screenshots/2401/unpaired-l.png differ diff --git a/.github/pr-screenshots/2401/unpaired-pair-screen-l.png b/.github/pr-screenshots/2401/unpaired-pair-screen-l.png new file mode 100644 index 0000000000..9a5cf63c58 Binary files /dev/null and b/.github/pr-screenshots/2401/unpaired-pair-screen-l.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 0fa5e9898a..5b4b577b9f 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -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** diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index 55ec4f8833..051cd39e23 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -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 { @@ -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 }, @@ -40,5 +64,5 @@ module.exports = ({ config }) => { ...(firebase ? { googleServicesFile: firebase } : {}), }, ...(store ? { name: 'DeepSeekBot', scheme: 'deepseekbot' } : {}), - }; + }); }; diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 09884b32e5..560beb21cd 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -76,7 +76,6 @@ ], "expo-font", "expo-image", - "expo-sharing", "expo-status-bar", "expo-web-browser" ], diff --git a/apps/mobile/app/+native-intent.tsx b/apps/mobile/app/+native-intent.tsx index dc4377278f..ebf6121124 100644 --- a/apps/mobile/app/+native-intent.tsx +++ b/apps/mobile/app/+native-intent.tsx @@ -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=')) { diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index cb7d4b47f9..7b853b3108 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -65,6 +65,10 @@ export default function RootLayout() { name="pair" options={{ title: t.pairTitle, ...sheetScreenOptions(design, true) }} /> + { if (limit <= 0) return []; if (source === 'file') { diff --git a/apps/mobile/src/ui/chat/conversation-screen.tsx b/apps/mobile/src/ui/chat/conversation-screen.tsx index 69dfed5d8d..67c4a0c4da 100644 --- a/apps/mobile/src/ui/chat/conversation-screen.tsx +++ b/apps/mobile/src/ui/chat/conversation-screen.tsx @@ -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 { @@ -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([]); const [caret, setCaret] = useState(0); const [selection, setSelection] = useState<{ start: number; end: number }>(); @@ -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( diff --git a/apps/mobile/src/ui/share/incoming-share.ts b/apps/mobile/src/ui/share/incoming-share.ts new file mode 100644 index 0000000000..e8232e4068 --- /dev/null +++ b/apps/mobile/src/ui/share/incoming-share.ts @@ -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(); + +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 { + 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)); +} diff --git a/apps/mobile/src/ui/share/share-path.ts b/apps/mobile/src/ui/share/share-path.ts new file mode 100644 index 0000000000..cb68933c25 --- /dev/null +++ b/apps/mobile/src/ui/share/share-path.ts @@ -0,0 +1,3 @@ +export function isSharePath(path: string): boolean { + return /^(?:[a-z][a-z0-9+.-]*:\/\/)?\/?expo-sharing(?:[/?#]|$)/iu.test(path); +} diff --git a/apps/mobile/src/ui/share/share-screen.tsx b/apps/mobile/src/ui/share/share-screen.tsx new file mode 100644 index 0000000000..7bbdfc53e9 --- /dev/null +++ b/apps/mobile/src/ui/share/share-screen.tsx @@ -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 ( + row.key} + ListHeaderComponent={ + + + + {incoming.isResolving ? ( + + ) : empty ? ( + {t.share.nothing} + ) : ( + <> + {share.text !== '' && {share.text}} + {share.files.length > 0 && ( + + {t.share.files(share.files.length)} + + )} + + )} + + + } + ListEmptyComponent={ + saved === null ? ( + + {t.share.pairFirst} +