mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
feat: enhance attachment handling in ComposeScreen with file picker and updated UI options
This commit is contained in:
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user