feat: enhance attachment handling in ComposeScreen with file picker and updated UI options

This commit is contained in:
Linus Rath
2026-05-02 14:43:45 +02:00
parent e1d318ebc1
commit c37256c54e
5 changed files with 83 additions and 11 deletions
+2
View File
@@ -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?",
+7
View File
@@ -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',
+11 -6
View File
@@ -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();
+14 -4
View File
@@ -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<void> {
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',
+49 -1
View File
@@ -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) {