mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: enhance notification handling with accountId support and improve file management functions
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+100
-101
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user