Compare commits

..
4 Commits
6 changed files with 99 additions and 10 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.26
0.1.28
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.26",
"version": "0.1.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.26",
"version": "0.1.28",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.26",
"version": "0.1.28",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+51
View File
@@ -61,6 +61,57 @@ export async function uploadBlob(
throw new Error('Upload succeeded but response did not include a blobId');
}
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
// `new File(uri).bytes()`, which is overkill when we already have the bytes
// (e.g. the empty-blob trick used to create directories on Stalwart).
export async function uploadBytes(
bytes: Uint8Array,
type: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
export function getDownloadUrl(
blobId: string,
name?: string,
+17 -4
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
@@ -16,17 +16,24 @@ const DIRECTORY_TYPES = new Set([
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
return DIRECTORY_TYPES.has(node.type) || node.type.includes('directory');
const t = node.type;
if (!t) return false;
return DIRECTORY_TYPES.has(t) || t.includes('directory');
}
// Stalwart's FileNode/get does not support a parentId filter — fetching with
// `ids: null` returns the whole tree, which the caller filters client-side.
// Mirrors the webmail file-store; keeping the API uniform avoids a divergence
// later when we add other JMAP servers.
//
// Properties are listed explicitly: some Stalwart builds omit `type` from the
// default property set, which makes `isDirectory()` impossible to compute.
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null }, '0']],
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
USING,
);
return (res.methodResponses[0][1].list ?? []) as FileNode[];
@@ -55,7 +62,13 @@ export async function createFolder(
name: string,
parentId: string | null,
): Promise<FileNode> {
return createFileNode({ name, parentId, type: 'd', blobId: null, size: 0 });
// Stalwart's FileNode/set rejects directory creates without a blobId
// ("Missing blob id"), so we upload an empty blob first and reference it.
// Mirrors the webmail's createFileDirectory.
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
const props: Partial<FileNode> = { name, type: 'd', blobId: blob.blobId, size: 0 };
if (parentId !== null) props.parentId = parentId;
return createFileNode(props);
}
export async function updateFileNode(
+27 -2
View File
@@ -18,7 +18,24 @@ import {
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
} from 'lucide-react-native';
import * as DocumentPicker from 'expo-document-picker';
// expo-document-picker is loaded lazily on first upload. Its native module
// is registered at app launch via Expo autolinking; on builds that predate
// the dep being added, requiring it at import time crashes the whole bundle
// with "Cannot find native module 'ExpoDocumentPicker'". Deferring the
// require keeps every other Files tab feature working until the user
// rebuilds the native shell.
type DocumentPickerModule = typeof import('expo-document-picker');
let documentPickerModule: DocumentPickerModule | null = null;
function loadDocumentPicker(): DocumentPickerModule | null {
if (documentPickerModule) return documentPickerModule;
try {
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
return documentPickerModule;
} catch {
return null;
}
}
import {
createFolder, deleteFileNodes, getAllFileNodes, isDirectory, updateFileNode,
uploadFileNode,
@@ -287,9 +304,17 @@ export default function FilesScreen() {
const startUpload = useCallback(async () => {
if (uploading) return;
const picker = loadDocumentPicker();
if (!picker) {
Alert.alert(
'Upload unavailable',
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable uploads.',
);
return;
}
let result;
try {
result = await DocumentPicker.getDocumentAsync({
result = await picker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
});