From c37256c54e6d97f3d77affa0db4ba69dcb765924 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 2 May 2026 14:43:45 +0200 Subject: [PATCH] feat: enhance attachment handling in ComposeScreen with file picker and updated UI options --- locales/en/common.json | 2 ++ src/api/__tests__/blob.test.ts | 7 +++++ src/api/blob.ts | 17 ++++++++---- src/lib/email-export.ts | 18 +++++++++--- src/screens/ComposeScreen.tsx | 50 +++++++++++++++++++++++++++++++++- 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/locales/en/common.json b/locales/en/common.json index 026d45fc..4a6860f0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -450,6 +450,8 @@ "send": "Send", "cancel": "Cancel", "attach": "Attach", + "attach_photos": "Photos & Videos", + "attach_files": "Files", "discard": "Discard", "discard_draft_title": "Discard draft?", "discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?", diff --git a/src/api/__tests__/blob.test.ts b/src/api/__tests__/blob.test.ts index 4a225fb1..ac612cc6 100644 --- a/src/api/__tests__/blob.test.ts +++ b/src/api/__tests__/blob.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +vi.mock('expo-file-system', () => ({ + File: class { + constructor(public uri: string) {} + async bytes() { return new Uint8Array(); } + }, +})); + vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', diff --git a/src/api/blob.ts b/src/api/blob.ts index 49dfa0d5..976dd5ab 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -1,3 +1,4 @@ +import { File } from 'expo-file-system'; import { jmapClient } from './jmap-client'; export async function uploadBlob( @@ -7,11 +8,14 @@ export async function uploadBlob( const session = jmapClient.currentSession; if (!session) throw new Error('Not connected'); - const uploadUrl = session.uploadUrl.replace('{accountId}', jmapClient.accountId); + const uploadUrl = session.uploadUrl.replace( + '{accountId}', + encodeURIComponent(jmapClient.accountId), + ); - // Fetch local file as blob - const fileResponse = await fetch(uri); - const blob = await fileResponse.blob(); + // Read the source via the file-system API so this works for both `file://` + // (image picker, iOS document picker) and `content://` (Android SAF) URIs. + const bytes = await new File(uri).bytes(); const response = await fetch(uploadUrl, { method: 'POST', @@ -19,11 +23,12 @@ export async function uploadBlob( 'Content-Type': type, Authorization: (jmapClient as any).authHeader, }, - body: blob, + body: bytes as unknown as BodyInit, }); if (!response.ok) { - throw new Error(`Upload failed: ${response.status}`); + const detail = await response.text().catch(() => ''); + throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`); } return response.json(); diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 795e1965..91c45af4 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -1,3 +1,4 @@ +import { Platform } from 'react-native'; import { File, Paths } from 'expo-file-system'; import * as Sharing from 'expo-sharing'; import { jmapClient } from '../api/jmap-client'; @@ -18,6 +19,15 @@ function safeAttachmentName(name: string | undefined, type: string | undefined): return `attachment.${fallbackExt}`; } +// On Android, sharing a `file://` URI from the cache directory throws a +// FileUriExposedException on modern OS versions; the system requires a +// FileProvider-backed `content://` URI instead. expo-file-system exposes that +// via `file.contentUri`, which is the only field iOS doesn't expose. +function shareableUri(file: { uri: string; contentUri?: string }): string { + if (Platform.OS === 'android' && file.contentUri) return file.contentUri; + return file.uri; +} + export async function shareAttachment( blobId: string, name: string | undefined, @@ -26,15 +36,15 @@ export async function shareAttachment( const filename = safeAttachmentName(name, type); const mimeType = type || 'application/octet-stream'; const dest = new File(Paths.cache, filename); - if (dest.exists) dest.delete(); const url = getDownloadUrl(blobId, filename, mimeType); const downloaded = await File.downloadFileAsync(url, dest, { headers: { Authorization: authHeader() }, + idempotent: true, }); if (!(await Sharing.isAvailableAsync())) { throw new Error('Sharing is not available on this device'); } - await Sharing.shareAsync(downloaded.uri, { + await Sharing.shareAsync(shareableUri(downloaded), { mimeType, dialogTitle: filename, }); @@ -54,15 +64,15 @@ function safeFilename(subject: string | undefined): string { export async function shareEmailEml(blobId: string, subject?: string): Promise { const dest = new File(Paths.cache, safeFilename(subject)); - if (dest.exists) dest.delete(); const url = getDownloadUrl(blobId, dest.name, RFC822); const downloaded = await File.downloadFileAsync(url, dest, { headers: { Authorization: authHeader() }, + idempotent: true, }); if (!(await Sharing.isAvailableAsync())) { throw new Error('Sharing is not available on this device'); } - await Sharing.shareAsync(downloaded.uri, { + await Sharing.shareAsync(shareableUri(downloaded), { mimeType: RFC822, dialogTitle: 'Share email', UTI: 'public.email-message', diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 306f57c9..9c935352 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react-native'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; +import { File as FsFile } from 'expo-file-system'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { Button } from '../components'; @@ -486,7 +487,7 @@ export default function ComposeScreen({ route, navigation }: Props) { } }; - const pickAttachment = async () => { + const pickPhotoAttachments = async () => { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert( @@ -516,6 +517,53 @@ export default function ComposeScreen({ route, navigation }: Props) { } }; + const pickFileAttachment = async () => { + // pickFileAsync's return type is the lower-level FileSystemFile, not the + // public `File` wrapper. Both expose the fields we need (uri/name/size/type). + type PickedFile = { uri: string; name?: string; size?: number; type?: string }; + let picked: PickedFile | undefined; + try { + const result = await FsFile.pickFileAsync(); + picked = (Array.isArray(result) ? result[0] : result) as PickedFile; + } catch (e) { + // The picker rejects with PickerCancelledException when the user backs + // out — treat that as a no-op rather than surfacing an error. + const message = e instanceof Error ? e.message : String(e); + if (/cancel/i.test(message)) return; + Alert.alert(t('email_composer.attach', 'Attach'), message); + return; + } + if (!picked) return; + const type = picked.type || 'application/octet-stream'; + const name = picked.name || `attachment-${Date.now()}`; + const entry = addUploadEntry({ + name, + type, + size: picked.size ?? 0, + uri: picked.uri, + inline: false, + }); + void startUpload(entry); + }; + + const pickAttachment = () => { + Alert.alert( + t('email_composer.attach', 'Attach'), + undefined, + [ + { + text: t('email_composer.attach_photos', 'Photos & Videos'), + onPress: () => { void pickPhotoAttachments(); }, + }, + { + text: t('email_composer.attach_files', 'Files'), + onPress: () => { void pickFileAttachment(); }, + }, + { text: t('email_composer.cancel', 'Cancel'), style: 'cancel' }, + ], + ); + }; + const insertInlineImage = async () => { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) {