mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bdca0133e | ||
|
|
1858d08dd0 | ||
|
|
427d163dc0 | ||
|
|
7e34ba42ef | ||
|
|
886a64171d | ||
|
|
760bf01f4d | ||
|
|
b6d87093b9 | ||
|
|
2a3b1f90dc | ||
|
|
2ed9c29bd5 |
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+12
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user