feat: enhance notification handling with accountId support and improve file management functions

This commit is contained in:
Linus Rath
2026-05-14 12:57:03 +02:00
parent 427d163dc0
commit 1858d08dd0
7 changed files with 226 additions and 122 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 () => {
@@ -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"
}
+89 -16
View File
@@ -21,13 +21,39 @@ export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
return DIRECTORY_TYPES.has(t) || t.includes('directory');
}
// Stalwart's FileNode/get does not support a parentId filter — fetching with
// `ids: null` returns the whole tree, which the caller filters client-side.
// Mirrors the webmail file-store; keeping the API uniform avoids a divergence
// later when we add other JMAP servers.
//
// Properties are listed explicitly: some Stalwart builds omit `type` from the
// default property set, which makes `isDirectory()` impossible to compute.
// 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[]> {
@@ -59,16 +85,15 @@ export async function createFileNode(
}
export async function createFolder(
name: string,
parentId: string | null,
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 props: Partial<FileNode> = { name, type: 'd', blobId: blob.blobId, size: 0 };
if (parentId !== null) props.parentId = parentId;
return createFileNode(props);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
}
export async function updateFileNode(
@@ -84,6 +109,33 @@ export async function updateFileNode(
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;
@@ -93,6 +145,27 @@ export async function deleteFileNodes(ids: string[]): Promise<void> {
);
}
// 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);
@@ -100,14 +173,14 @@ export function getFileNodeDownloadUrl(node: FileNode): string {
export async function uploadFileNode(
uri: string,
name: string,
displayName: string,
mimeType: string,
parentId: string | null,
currentPath: readonly string[],
): Promise<FileNode> {
const blob = await uploadBlob(uri, mimeType);
const fullName = pathToPrefix(currentPath) + displayName;
return createFileNode({
name,
parentId,
name: fullName,
type: blob.type || mimeType || 'application/octet-stream',
blobId: blob.blobId,
size: blob.size,
+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
+100 -101
View File
@@ -18,6 +18,7 @@ import {
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
} from 'lucide-react-native';
// 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
@@ -37,7 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null {
}
import {
createFolder, deleteFileNodes, getAllFileNodes, isDirectory, updateFileNode,
createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
uploadFileNode,
} from '../api/files';
import { downloadAttachment, shareAttachment } from '../lib/email-export';
@@ -47,13 +49,12 @@ 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;
@@ -77,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`;
@@ -111,7 +108,7 @@ export default function FilesScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [stack, setStack] = useState<FolderFrame[]>([ROOT_FRAME]);
const [path, setPath] = useState<string[]>([]);
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -121,12 +118,12 @@ export default function FilesScreen() {
const [busyId, setBusyId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<{ ids: string[] } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameTarget, setRenameTarget] = useState<FileNode | null>(null);
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileNode | null>(null);
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
@@ -139,8 +136,9 @@ export default function FilesScreen() {
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 (mode: 'initial' | 'refresh' = 'initial') => {
@@ -164,26 +162,31 @@ export default function FilesScreen() {
void loadFiles();
}, [loadFiles]);
// If we navigated into a folder that has since been deleted, walk back up
// until we land on a still-existing one (or root).
// 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;
setStack((prev) => {
const ids = new Set(allNodes.map((n) => n.id));
let cut = prev.length;
for (let i = prev.length - 1; i > 0; i--) {
const id = prev[i].id;
if (id != null && !ids.has(id)) cut = i;
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 cut === prev.length ? prev : prev.slice(0, cut);
return next.length === prev.length ? prev : next;
});
}, [allNodes]);
const visibleFiles = useMemo(() => {
let out = allNodes.filter((n) => (n.parentId ?? null) === current.id);
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
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) => {
return rows.sort((a, b) => {
const aDir = isDirectory(a) ? 0 : 1;
const bDir = isDirectory(b) ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
@@ -197,10 +200,10 @@ export default function FilesScreen() {
}
case 'name':
default:
return a.name.localeCompare(b.name) * dir;
return a.displayName.localeCompare(b.displayName) * dir;
}
});
}, [allNodes, current.id, showHiddenFiles, sortKey, sortDir]);
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
const clearSelection = useCallback(() => setSelection(new Set()), []);
@@ -213,11 +216,11 @@ export default function FilesScreen() {
});
}, []);
const previewFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
const previewFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await shareAttachment(node.blobId, node.name, node.type);
await shareAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -225,11 +228,11 @@ export default function FilesScreen() {
}
}, []);
const downloadFile = useCallback(async (node: FileNode) => {
if (!node.blobId) return;
setBusyId(node.id);
const downloadFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
await downloadAttachment(node.blobId, node.name, node.type);
await downloadAttachment(row.blobId, row.displayName, row.type);
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
@@ -238,26 +241,26 @@ export default function FilesScreen() {
}, []);
const handleRowPress = useCallback(
(node: FileNode) => {
(row: FileRow) => {
if (selectionMode) {
toggleSelect(node.id);
toggleSelect(row.id);
return;
}
if (isDirectory(node)) {
setStack((s) => [...s, { id: node.id, name: node.name }]);
if (isDirectory(row)) {
setPath((p) => [...p, row.displayName]);
return;
}
void previewFile(node);
void previewFile(row);
},
[selectionMode, toggleSelect, previewFile],
);
const handleRowLongPress = useCallback((node: FileNode) => {
toggleSelect(node.id);
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]);
@@ -274,33 +277,41 @@ export default function FilesScreen() {
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, current.id);
await createFolder(name, path);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
}
}, [newFolderName, current.id, loadFiles]);
}, [newFolderName, path, loadFiles]);
const submitRename = useCallback(async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.name) {
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 updateFileNode(target.id, { name });
await renameFileNode(target, name, path, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
}
}, [renameTarget, renameValue, loadFiles]);
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
const startUpload = useCallback(async () => {
if (uploading) return;
@@ -330,7 +341,7 @@ export default function FilesScreen() {
asset.uri,
asset.name,
asset.mimeType || 'application/octet-stream',
current.id,
path,
);
await loadFiles('refresh');
} catch (e) {
@@ -338,33 +349,20 @@ export default function FilesScreen() {
} finally {
setUploading(false);
}
}, [uploading, current.id, loadFiles]);
}, [uploading, path, loadFiles]);
const requestDelete = useCallback((ids: string[]) => {
if (ids.length === 0) return;
setConfirmDelete({ ids });
const requestDelete = useCallback((rows: FileRow[]) => {
if (rows.length === 0) return;
setConfirmDelete({ rows });
}, []);
const performDelete = useCallback(async () => {
if (!confirmDelete) return;
// Cascade: if the user deletes a folder, also delete every descendant
// we know about. Stalwart deletes folders with content silently otherwise.
const targetIds = new Set(confirmDelete.ids);
let frontier = [...confirmDelete.ids];
while (frontier.length > 0) {
const next: string[] = [];
for (const node of allNodes) {
if (node.parentId && frontier.includes(node.parentId) && !targetIds.has(node.id)) {
targetIds.add(node.id);
next.push(node.id);
}
}
frontier = next;
}
const targets = confirmDelete.rows;
setConfirmDelete(null);
clearSelection();
try {
await deleteFileNodes(Array.from(targetIds));
await deleteWithDescendants(targets, allNodes);
await loadFiles('refresh');
} catch (e) {
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
@@ -372,9 +370,10 @@ export default function FilesScreen() {
}, [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}>
@@ -384,7 +383,7 @@ export default function FilesScreen() {
<Text style={styles.title}>{selection.size} selected</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => requestDelete(Array.from(selection))}
onPress={() => requestDelete(selectedRows)}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel="Delete selected"
@@ -406,7 +405,7 @@ export default function FilesScreen() {
</Pressable>
) : null}
<Text style={styles.title} numberOfLines={1}>
{current.name}
{headerName}
</Text>
</View>
<View style={styles.headerActions}>
@@ -443,19 +442,19 @@ export default function FilesScreen() {
</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 renderRow = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
const busy = busyId === item.id;
return (
@@ -472,7 +471,7 @@ export default function FilesScreen() {
{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 && !isDir ? (
@@ -501,12 +500,12 @@ export default function FilesScreen() {
);
};
const renderGridCell = (item: FileNode) => {
const renderGridCell = (item: FileRow) => {
const isDir = isDirectory(item);
const Icon = isDir ? Folder : pickFileIcon(item.name);
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
const tint = isDir
? (coloredIcons ? c.calendar.blue : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
const selected = selection.has(item.id);
return (
<Pressable
@@ -525,7 +524,7 @@ export default function FilesScreen() {
<View style={{ width: 36, height: 36 }} />
)}
<Text style={styles.gridName} numberOfLines={2}>
{item.name}
{item.displayName}
</Text>
{item.size != null && !isDir ? (
<Text style={styles.gridMeta} numberOfLines={1}>
@@ -560,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>
);
@@ -629,30 +628,30 @@ export default function FilesScreen() {
<ActionsSheet
target={actionsTarget}
onClose={() => setActionsTarget(null)}
onPreview={(n) => {
onPreview={(r) => {
setActionsTarget(null);
void previewFile(n);
void previewFile(r);
}}
onDownload={(n) => {
onDownload={(r) => {
setActionsTarget(null);
void downloadFile(n);
void downloadFile(r);
}}
onRename={(n) => {
onRename={(r) => {
setActionsTarget(null);
setRenameTarget(n);
setRenameValue(n.name);
setRenameTarget(r);
setRenameValue(r.displayName);
}}
onDelete={(n) => {
onDelete={(r) => {
setActionsTarget(null);
requestDelete([n.id]);
requestDelete([r]);
}}
/>
<Dialog
visible={confirmDelete != null}
title={
confirmDelete && confirmDelete.ids.length > 1
? `Delete ${confirmDelete.ids.length} items?`
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."
@@ -722,12 +721,12 @@ function PromptModal(props: PromptModalProps) {
}
interface ActionsSheetProps {
target: FileNode | null;
target: FileRow | null;
onClose: () => void;
onPreview: (n: FileNode) => void;
onDownload: (n: FileNode) => void;
onRename: (n: FileNode) => void;
onDelete: (n: FileNode) => 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) {
@@ -742,7 +741,7 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.name}
{target.displayName}
</Text>
{!isDir ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>