Compare commits

..
9 Commits
13 changed files with 891 additions and 104 deletions
+16 -3
View File
@@ -51,8 +51,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
function navigateToNotificationTap(payload: NotificationTapPayload): void {
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
@@ -261,11 +274,11 @@ export default function App() {
void (async () => {
const initial = await getInitialNotificationTap();
if (cancelled || !initial) return;
navigateToNotificationTap(initial);
await navigateToNotificationTap(initial);
})();
const unsubscribe = addNotificationTapListener((payload) => {
navigateToNotificationTap(payload);
void navigateToNotificationTap(payload);
});
return () => {
+1 -1
View File
@@ -1 +1 @@
0.1.25
0.1.29
@@ -65,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val emailId = options.getString("emailId")
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -73,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject,
emailId, threadId, subject, accountId,
)
promise.resolve(null)
}
@@ -94,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
emailId: String?,
threadId: String?,
subject: String?,
accountId: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -101,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
@@ -23,6 +23,11 @@ object NotificationTapStore {
// our notification builder writes — preventing other apps from
// navigating us to a forged email/thread.
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
// accountId is `username@host` (see generateAccountId), so it needs `.`,
// `@`, `+` etc. — cap length and restrict charset to what that helper can
// emit. accountId is optional: notifications created before this field
// existed still navigate (using the active account as a fallback).
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
fun captureFromIntent(intent: Intent?): TapPayload? {
val extras = intent?.extras ?: return null
@@ -34,12 +39,14 @@ object NotificationTapStore {
// cap its length so a hostile launcher can't ship a 1MB string into
// the navigation payload.
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
val payload = TapPayload(emailId, threadId, subject)
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
return payload
}
@@ -47,15 +54,18 @@ object NotificationTapStore {
val emailId: String,
val threadId: String,
val subject: String?,
val accountId: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
}
}
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
}
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.25",
"version": "0.1.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.25",
"version": "0.1.29",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -17,6 +17,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
@@ -5216,6 +5217,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.25",
"version": "0.1.29",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -23,6 +23,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
+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,
+157 -8
View File
@@ -1,19 +1,68 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
// mark a directory; everything else is a MIME type.
const DIRECTORY_TYPES = new Set([
'd',
'application/x-directory',
'text/directory',
'httpd/unix-directory',
'inode/directory',
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
const t = node.type;
if (!t) return false;
return DIRECTORY_TYPES.has(t) || t.includes('directory');
}
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
// e.g. a file inside the `Documents` folder is stored with
// `name = "Documents∕report.pdf"`. The display name shown to the user
// strips the prefix.
export const PATH_SEP = '∕';
// Path is a list of folder display names from root. [] is root,
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
// `sub` folder under Documents.
export function pathToPrefix(path: readonly string[]): string {
if (path.length === 0) return '';
return path.join(PATH_SEP) + PATH_SEP;
}
// True when `node` is a direct child (not a descendant) of `prefix`.
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
if (!node.name) return false;
if (prefix === '') return !node.name.includes(PATH_SEP);
if (!node.name.startsWith(prefix)) return false;
const rest = node.name.slice(prefix.length);
return rest.length > 0 && !rest.includes(PATH_SEP);
}
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
}
// Stalwart's FileNode/get does not support a parentId filter and some builds
// omit `type` from the default property set. We fetch the whole tree and
// filter client-side; properties are listed explicitly so `isDirectory()`
// always has a value to look at.
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = {};
if (parentId !== undefined) filter.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, filter }, '0']],
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
USING,
);
return res.methodResponses[0][1].list;
return (res.methodResponses[0][1].list ?? []) as FileNode[];
}
export async function createFileNode(
@@ -27,13 +76,113 @@ export async function createFileNode(
}, '0']],
USING,
);
return res.methodResponses[0][1].created['new-file'];
const created = res.methodResponses[0][1].created?.['new-file'];
if (!created) {
const err = res.methodResponses[0][1].notCreated?.['new-file'];
throw new Error(err?.description || 'Create failed');
}
return created;
}
export async function createFolder(
displayName: string,
currentPath: readonly string[],
): Promise<FileNode> {
// 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 fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
}
export async function updateFileNode(
id: string,
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
USING,
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
// Rename a node and, if it's a folder, cascade the rename to every descendant
// so the encoded path stays consistent. The webmail does this same dance.
export async function renameFileNode(
node: FileNode,
newDisplayName: string,
currentPath: readonly string[],
allNodes: readonly FileNode[],
): Promise<void> {
const prefix = pathToPrefix(currentPath);
const oldServerName = node.name;
const newServerName = prefix + newDisplayName;
if (oldServerName === newServerName) return;
await updateFileNode(node.id, { name: newServerName });
if (isDirectory(node)) {
const oldFolderPrefix = oldServerName + PATH_SEP;
const newFolderPrefix = newServerName + PATH_SEP;
for (const n of allNodes) {
if (n.id === node.id) continue;
if (n.name.startsWith(oldFolderPrefix)) {
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
}
}
}
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
);
}
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
// references, so we collect descendants by name-prefix and destroy them all
// in one call. Without this, deleting a folder leaves its files orphaned and
// still discoverable at the old encoded path.
export async function deleteWithDescendants(
targets: readonly FileNode[],
allNodes: readonly FileNode[],
): Promise<void> {
const ids = new Set<string>();
for (const node of targets) {
ids.add(node.id);
if (isDirectory(node)) {
const folderPrefix = node.name + PATH_SEP;
for (const n of allNodes) {
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
}
}
}
await deleteFileNodes(Array.from(ids));
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type);
}
export async function uploadFileNode(
uri: string,
displayName: string,
mimeType: string,
currentPath: readonly string[],
): Promise<FileNode> {
const blob = await uploadBlob(uri, mimeType);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({
name: fullName,
type: blob.type || mimeType || 'application/octet-stream',
blobId: blob.blobId,
size: blob.size,
});
}
+2 -1
View File
@@ -429,7 +429,7 @@ export interface FileNode {
name: string;
parentId?: string | null;
type: string;
blobId?: string;
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
@@ -465,4 +465,5 @@ export const CAPABILITIES = {
CALENDARS: 'urn:ietf:params:jmap:calendars',
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
} as const;
+12 -1
View File
@@ -3,8 +3,10 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -12,9 +14,18 @@ import { useColors } from '../theme/colors';
export function OfflineCacheBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const sync = useOfflineCacheStore((s) => s.sync);
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
const resetSync = useOfflineCacheStore((s) => s.resetSync);
// When UpdateBanner is stacked above us it already absorbs the status-bar
// inset, so we only add it when we're the topmost banner.
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
const updateBannerVisible =
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
const topInset = updateBannerVisible ? 0 : insets.top;
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
@@ -71,7 +82,7 @@ export function OfflineCacheBanner(): React.ReactElement | null {
const isError = sync.phase === 'error';
return (
<View style={[styles.banner, isError && styles.bannerError]}>
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
<CloudDownload size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>{title}</Text>
+2
View File
@@ -41,6 +41,7 @@ interface ShowNotificationOptions {
emailId: string;
threadId: string;
subject?: string;
accountId: string;
}
interface BulwarkFcmNative {
@@ -100,6 +101,7 @@ export async function pushBackgroundTask(_data: unknown): Promise<void> {
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
});
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
+4
View File
@@ -327,6 +327,10 @@ export interface NotificationTapPayload {
emailId: string;
threadId: string;
subject?: string;
// Identifies which logged-in account the notification was generated for.
// Optional for back-compat: older notifications already on the system tray
// won't carry this and will fall back to the active account on tap.
accountId?: string;
}
// Returns - and clears - any pending "notification tap" that launched the app
+617 -85
View File
@@ -1,31 +1,60 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import {
ChevronLeft, FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio,
FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
} from 'lucide-react-native';
import { getFileNodes } from '../api/files';
// 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, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
uploadFileNode,
} from '../api/files';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
import type { FileNode } from '../api/types';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
import Dialog from '../components/Dialog';
interface FolderFrame {
id: string | null;
name: string;
// A file row carries the resolved display name alongside the original
// server-side name (the path-encoded `node.name` field).
interface FileRow extends FileNode {
displayName: string;
}
const ROOT_FRAME: FolderFrame = { id: null, name: 'Files' };
function pickFileIcon(name: string) {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage;
@@ -49,10 +78,6 @@ function fileIconColor(name: string, c: ThemePalette): string {
return c.textMuted;
}
function isHidden(node: FileNode): boolean {
return node.name.startsWith('.');
}
function formatFileSize(bytes?: number): string {
if (bytes == null) return '';
if (bytes < 1024) return `${bytes} B`;
@@ -82,12 +107,24 @@ function formatModified(iso?: string): string {
export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [stack, setStack] = useState<FolderFrame[]>([ROOT_FRAME]);
const [files, setFiles] = useState<FileNode[]>([]);
const [path, setPath] = useState<string[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selection, setSelection] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
@@ -96,21 +133,21 @@ export default function FilesScreen() {
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
const setSetting = useSettingsStore((s) => s.updateSetting);
// Local override so the user can flip view from the toolbar without
// changing their persisted default. Falls back to the setting.
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
const current = stack[stack.length - 1];
const selectionMode = selection.size > 0;
const prefix = useMemo(() => pathToPrefix(path), [path]);
const headerName = path.length === 0 ? 'Files' : path[path.length - 1];
const loadFiles = useCallback(
async (parentId: string | null, mode: 'initial' | 'refresh' = 'initial') => {
async (mode: 'initial' | 'refresh' = 'initial') => {
if (mode === 'refresh') setRefreshing(true);
else setLoading(true);
setError(null);
try {
const nodes = await getFileNodes(parentId);
setFiles(nodes);
const nodes = await getAllFileNodes();
setAllNodes(nodes);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load files');
} finally {
@@ -122,18 +159,36 @@ export default function FilesScreen() {
);
useEffect(() => {
void loadFiles(current.id);
}, [loadFiles, current.id]);
void loadFiles();
}, [loadFiles]);
const visibleFiles = useMemo(() => {
let out = files;
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
// Directories always sort to the top regardless of sort key, then secondary
// sort by the requested column. This matches macOS Finder / web file pickers.
// If we navigated into a folder that no longer exists (it was deleted, or
// we never had it), walk back up until we land on one that does. The check
// looks for a directory node whose serverName matches the encoded path.
useEffect(() => {
if (allNodes.length === 0) return;
setPath((prev) => {
let next = prev;
while (next.length > 0) {
const parentPrefix = pathToPrefix(next.slice(0, -1));
const expectedName = parentPrefix + next[next.length - 1];
const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n));
if (exists) break;
next = next.slice(0, -1);
}
return next.length === prev.length ? prev : next;
});
}, [allNodes]);
const visibleFiles = useMemo<FileRow[]>(() => {
const rows = allNodes
.filter((n) => isDirectChildOfPrefix(n, prefix))
.map<FileRow>((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) }))
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'));
const dir = sortDir === 'desc' ? -1 : 1;
return [...out].sort((a, b) => {
const aDir = a.type === 'directory' ? 0 : 1;
const bDir = b.type === 'directory' ? 0 : 1;
return rows.sort((a, b) => {
const aDir = isDirectory(a) ? 0 : 1;
const bDir = isDirectory(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
switch (sortKey) {
case 'size':
@@ -145,22 +200,69 @@ export default function FilesScreen() {
}
case 'name':
default:
return a.name.localeCompare(b.name) * dir;
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [files, showHiddenFiles, sortKey, sortDir]);
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
const openNode = useCallback((node: FileNode) => {
if (node.type === 'directory') {
setStack((s) => [...s, { id: node.id, name: node.name }]);
}
// Files are no-ops for now — opening a blob would need a download flow
// wired to the JMAP download endpoint.
const clearSelection = useCallback(() => setSelection(new Set()), []);
const toggleSelect = useCallback((id: string) => {
setSelection((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const previewFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await shareAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, []);
const downloadFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await downloadAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
setBusyId(null);
}
}, []);
const handleRowPress = useCallback(
(row: FileRow) => {
if (selectionMode) {
toggleSelect(row.id);
return;
}
if (isDirectory(row)) {
setPath((p) => [...p, row.displayName]);
return;
}
void previewFile(row);
},
[selectionMode, toggleSelect, previewFile],
);
const handleRowLongPress = useCallback((row: FileRow) => {
toggleSelect(row.id);
}, [toggleSelect]);
const goBack = useCallback(() => {
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
}, []);
setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
clearSelection();
}, [clearSelection]);
const toggleView = useCallback(() => {
const next: FilesViewMode = viewMode === 'list' ? 'grid' : 'list';
@@ -169,12 +271,130 @@ export default function FilesScreen() {
}, [viewMode, setSetting]);
const onRefresh = useCallback(() => {
void loadFiles(current.id, 'refresh');
}, [loadFiles, current.id]);
void loadFiles('refresh');
}, [loadFiles]);
const submitNewFolder = useCallback(async () => {
const name = newFolderName.trim();
if (!name) return;
if (name.includes(PATH_SEP) || name.includes('/')) {
Alert.alert('Invalid name', 'Folder names cannot contain "/".');
return;
}
setNewFolderOpen(false);
setNewFolderName('');
try {
await createFolder(name, path);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
}
}, [newFolderName, path, loadFiles]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.displayName) {
setRenameTarget(null);
return;
}
if (name.includes(PATH_SEP) || name.includes('/')) {
Alert.alert('Invalid name', 'Names cannot contain "/".');
return;
}
const target = renameTarget;
setRenameTarget(null);
setRenameValue('');
try {
await renameFileNode(target, name, path, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
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 picker.getDocumentAsync({
copyToCacheDirectory: true,
multiple: false,
});
} catch (e) {
Alert.alert('Pick failed', e instanceof Error ? e.message : String(e));
return;
}
if (result.canceled || result.assets.length === 0) return;
const asset = result.assets[0];
setUploading(true);
try {
await uploadFileNode(
asset.uri,
asset.name,
asset.mimeType || 'application/octet-stream',
path,
);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Upload failed', e instanceof Error ? e.message : String(e));
} finally {
setUploading(false);
}
}, [uploading, path, loadFiles]);
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
setConfirmDelete({ rows });
}, []);
const performDelete = useCallback(async () => {
if (!confirmDelete) return;
const targets = confirmDelete.rows;
setConfirmDelete(null);
clearSelection();
try {
await deleteWithDescendants(targets, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
}
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
const renderHeader = () => {
const canBack = stack.length > 1;
const canBack = path.length > 0;
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
if (selectionMode) {
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
return (
<View style={styles.header}>
<View style={styles.headerRow}>
<Pressable onPress={clearSelection} hitSlop={8} style={styles.headerBtn}>
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.title}>{selection.size} selected</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => requestDelete(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Delete selected"
>
<Trash2 size={20} color={c.error} />
</Pressable>
</View>
</View>
</View>
);
}
return (
<View style={styles.header}>
<View style={styles.headerRow}>
@@ -185,49 +405,76 @@ export default function FilesScreen() {
</Pressable>
) : null}
<Text style={styles.title} numberOfLines={1}>
{current.name}
{headerName}
</Text>
</View>
<Pressable
onPress={toggleView}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={viewMode === 'list' ? 'Switch to grid view' : 'Switch to list view'}
>
<ToggleIcon size={20} color={c.text} />
</Pressable>
<View style={styles.headerActions}>
<Pressable
onPress={() => setNewFolderOpen(true)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="New folder"
>
<FolderPlus size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => void startUpload()}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Upload file"
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<Upload size={20} color={c.text} />
)}
</Pressable>
<Pressable
onPress={toggleView}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={viewMode === 'list' ? 'Switch to grid view' : 'Switch to list view'}
>
<ToggleIcon size={20} color={c.text} />
</Pressable>
</View>
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{stack.map((f) => f.name).join(' / ')}
{['Files', ...path].join(' / ')}
</Text>
) : null}
</View>
);
};
const renderRow = (item: FileNode) => {
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
const tint = item.type === 'directory'
const renderRow = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
const tappable = item.type === 'directory';
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const busy = busyId === item.id;
return (
<Pressable
onPress={() => openNode(item)}
disabled={!tappable}
onPress={() => handleRowPress(item)}
onLongPress={() => handleRowLongPress(item)}
delayLongPress={300}
style={({ pressed }) => [
styles.fileRow,
pressed && tappable && { backgroundColor: c.surfaceHover },
selected && { backgroundColor: c.primaryBg },
pressed && !selected && { backgroundColor: c.surfaceHover },
]}
>
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
{item.displayName}
</Text>
<View style={styles.fileMetaRow}>
{item.size != null && item.type !== 'directory' ? (
{item.size != null && !isDir ? (
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
) : null}
{item.updated ? (
@@ -235,23 +482,40 @@ export default function FilesScreen() {
) : null}
</View>
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : !selectionMode ? (
<Pressable
onPress={(e) => {
e.stopPropagation();
setActionsTarget(item);
}}
hitSlop={8}
style={styles.rowMore}
>
<MoreVertical size={18} color={c.textMuted} />
</Pressable>
) : null}
</Pressable>
);
};
const renderGridCell = (item: FileNode) => {
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
const tint = item.type === 'directory'
const renderGridCell = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
const tappable = item.type === 'directory';
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
return (
<Pressable
onPress={() => openNode(item)}
disabled={!tappable}
onPress={() => handleRowPress(item)}
onLongPress={() => handleRowLongPress(item)}
delayLongPress={300}
style={({ pressed }) => [
styles.gridItem,
pressed && tappable && { backgroundColor: c.surfaceHover },
selected && { backgroundColor: c.primaryBg },
pressed && !selected && { backgroundColor: c.surfaceHover },
]}
>
{showIcons ? (
@@ -260,9 +524,9 @@ export default function FilesScreen() {
<View style={{ width: 36, height: 36 }} />
)}
<Text style={styles.gridName} numberOfLines={2}>
{item.name}
{item.displayName}
</Text>
{item.size != null && item.type !== 'directory' ? (
{item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
{formatFileSize(item.size)}
</Text>
@@ -284,7 +548,7 @@ export default function FilesScreen() {
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => void loadFiles(current.id)}
onPress={() => void loadFiles()}
>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
@@ -295,7 +559,7 @@ export default function FilesScreen() {
<View style={styles.center}>
<HardDrive size={48} color={c.textMuted} />
<Text style={styles.emptyText}>
{stack.length > 1 ? 'This folder is empty' : 'No files yet'}
{path.length > 0 ? 'This folder is empty' : 'No files yet'}
</Text>
</View>
);
@@ -310,11 +574,7 @@ export default function FilesScreen() {
contentContainerStyle={styles.gridContent}
renderItem={({ item }) => renderGridCell(item)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.primary}
/>
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
}
/>
);
@@ -326,11 +586,7 @@ export default function FilesScreen() {
keyExtractor={(item) => item.id}
renderItem={({ item }) => renderRow(item)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.primary}
/>
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
}
/>
);
@@ -340,10 +596,181 @@ export default function FilesScreen() {
<View style={styles.container}>
{renderHeader()}
{body}
<PromptModal
visible={newFolderOpen}
title="New folder"
placeholder="Folder name"
confirmText="Create"
value={newFolderName}
onChange={setNewFolderName}
onCancel={() => {
setNewFolderOpen(false);
setNewFolderName('');
}}
onSubmit={submitNewFolder}
/>
<PromptModal
visible={renameTarget != null}
title="Rename"
placeholder="Name"
confirmText="Rename"
value={renameValue}
onChange={setRenameValue}
onCancel={() => {
setRenameTarget(null);
setRenameValue('');
}}
onSubmit={submitRename}
/>
<ActionsSheet
target={actionsTarget}
onClose={() => setActionsTarget(null)}
onPreview={(r) => {
setActionsTarget(null);
void previewFile(r);
}}
onDownload={(r) => {
setActionsTarget(null);
void downloadFile(r);
}}
onRename={(r) => {
setActionsTarget(null);
setRenameTarget(r);
setRenameValue(r.displayName);
}}
onDelete={(r) => {
setActionsTarget(null);
requestDelete([r]);
}}
/>
<Dialog
visible={confirmDelete != null}
title={
confirmDelete && confirmDelete.rows.length > 1
? `Delete ${confirmDelete.rows.length} items?`
: 'Delete this item?'
}
message="Folders are deleted along with everything inside them. This can't be undone."
variant="destructive"
confirmText="Delete"
onConfirm={() => void performDelete()}
onCancel={() => setConfirmDelete(null)}
/>
</View>
);
}
interface PromptModalProps {
visible: boolean;
title: string;
placeholder: string;
confirmText: string;
value: string;
onChange: (v: string) => void;
onCancel: () => void;
onSubmit: () => void;
}
function PromptModal(props: PromptModalProps) {
const c = useColors();
const styles = React.useMemo(() => makePromptStyles(c), [c]);
return (
<Modal
visible={props.visible}
transparent
animationType="fade"
onRequestClose={props.onCancel}
>
<TouchableWithoutFeedback onPress={props.onCancel}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
<TextInput
value={props.value}
onChangeText={props.onChange}
placeholder={props.placeholder}
placeholderTextColor={c.textMuted}
style={styles.input}
autoFocus
returnKeyType="done"
onSubmitEditing={props.onSubmit}
/>
<View style={styles.row}>
<Pressable onPress={props.onCancel} style={styles.btnGhost}>
<Text style={styles.btnGhostText}>Cancel</Text>
</Pressable>
<Pressable
onPress={props.onSubmit}
style={[styles.btnPrimary, !props.value.trim() && { opacity: 0.5 }]}
disabled={!props.value.trim()}
>
<Text style={styles.btnPrimaryText}>{props.confirmText}</Text>
</Pressable>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
interface ActionsSheetProps {
target: FileRow | null;
onClose: () => void;
onPreview: (r: FileRow) => void;
onDownload: (r: FileRow) => void;
onRename: (r: FileRow) => void;
onDelete: (r: FileRow) => void;
}
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!target) return null;
const isDir = isDirectory(target);
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
</Text>
{!isDir ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>
<Share2 size={18} color={c.text} />
<Text style={styles.actionLabel}>Preview / Share</Text>
</Pressable>
) : null}
{!isDir ? (
<Pressable style={styles.action} onPress={() => onDownload(target)}>
<Download size={18} color={c.text} />
<Text style={styles.actionLabel}>Save to device</Text>
</Pressable>
) : null}
<Pressable style={styles.action} onPress={() => onRename(target)}>
<Pencil size={18} color={c.text} />
<Text style={styles.actionLabel}>Rename</Text>
</Pressable>
<Pressable style={styles.action} onPress={() => onDelete(target)}>
<Trash2 size={18} color={c.error} />
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
@@ -369,6 +796,11 @@ function makeStyles(c: ThemePalette) {
alignItems: 'center',
gap: spacing.xs,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerBtn: {
padding: spacing.xs,
borderRadius: radius.sm,
@@ -437,6 +869,9 @@ function makeStyles(c: ThemePalette) {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
rowMore: {
padding: spacing.xs,
},
gridContent: {
padding: spacing.sm,
},
@@ -463,3 +898,100 @@ function makeStyles(c: ThemePalette) {
},
});
}
function makePromptStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
dialog: {
width: '100%',
maxWidth: 400,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.lg,
padding: spacing.lg,
gap: spacing.md,
},
title: {
...typography.h3,
color: c.text,
},
input: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: c.text,
fontSize: typography.body.fontSize,
backgroundColor: c.surface,
},
row: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
btnGhost: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
btnGhostText: {
color: c.text,
fontWeight: '600' as const,
},
btnPrimary: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.primary,
},
btnPrimaryText: {
color: c.primaryForeground,
fontWeight: '600' as const,
},
});
}
function makeSheetStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xxl,
paddingHorizontal: spacing.md,
gap: spacing.xs,
},
sheetTitle: {
...typography.bodyMedium,
color: c.textMuted,
paddingHorizontal: spacing.sm,
paddingBottom: spacing.sm,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
},
actionLabel: {
...typography.body,
color: c.text,
},
});
}