feat(desktop): Tier B web side — toast actions, Focus Assist gate, folder DnD

- P5-41/35 useTauriToastActions: native rich-toast click → navigate(path) (opens
  the room), quick reply → mx.sendMessage(roomId, m.text). The desktop bridge
  routes message notifications (tag=roomId) to show_rich_toast.
- P5-56 useTauriFocusAssist + focusAssistActiveAtom: a native focus-assist-changed
  event drives the atom, OR'd into the existing quiet-hours gate in
  ClientNonUIFeatures so notifications+sounds suppress during Windows Focus Assist.
- P5-48 recursive folder drag-drop: fileEntries.ts (sync webkitGetAsEntry capture
  → async batched readEntries traversal, path-prefixed names, 500-file cap) wired
  into useFileDrop, reusing the existing upload pipeline. +3 unit tests.

Hooks no-op in the browser. Gates: tsc/eslint/prettier clean, build OK, 559 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:01:10 -04:00
co-authored by Claude Opus 4.8
parent 7e38baa7b6
commit 4509a2b6d3
8 changed files with 294 additions and 7 deletions
+122
View File
@@ -0,0 +1,122 @@
import { getDataTransferFiles, renameFile } from './dom';
// Guard against pathological drops (deeply nested / huge trees) that could
// otherwise queue thousands of uploads and freeze the composer.
export const MAX_DROPPED_FILES = 500;
/**
* Synchronously collect the `FileSystemEntry` objects for every item in a
* drop's `DataTransfer`.
*
* This MUST be called synchronously inside the drop event handler: the
* `DataTransferItemList` is emptied once the handler returns, so calling
* `webkitGetAsEntry()` after an `await` yields `null`. Capture the entries
* first, then traverse them asynchronously with {@link filesFromEntries}.
*
* Returns an empty array when `webkitGetAsEntry` is unavailable (non-Chromium
* browsers), signalling the caller to fall back to the flat file list.
*/
export const entriesFromDataTransfer = (dataTransfer: DataTransfer): FileSystemEntry[] => {
const entries: FileSystemEntry[] = [];
const { items } = dataTransfer;
if (!items) return entries;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item && item.kind === 'file' && typeof item.webkitGetAsEntry === 'function') {
const entry = item.webkitGetAsEntry();
if (entry) entries.push(entry);
}
}
return entries;
};
const fileFromFileEntry = (entry: FileSystemFileEntry): Promise<File> =>
new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
/**
* Read every entry from a directory reader.
*
* `readEntries` returns results in BATCHES (Chromium yields at most ~100 per
* call), so it must be called repeatedly until it resolves with an empty array.
*/
const readAllDirectoryEntries = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> =>
new Promise((resolve, reject) => {
const all: FileSystemEntry[] = [];
const readBatch = () => {
reader.readEntries((batch) => {
if (batch.length === 0) {
resolve(all);
return;
}
all.push(...batch);
readBatch();
}, reject);
};
readBatch();
});
/**
* Recursively walk `FileSystemEntry` objects (as produced by
* {@link entriesFromDataTransfer}) and resolve them into a flat `File[]`,
* descending into every nested directory.
*
* Nested files keep their relative folder path as a name prefix (e.g.
* `photos/2024/pic.jpg`) so uploads remain distinguishable. Traversal stops
* once `maxFiles` files have been collected.
*/
export const filesFromEntries = async (
entries: FileSystemEntry[],
maxFiles: number = MAX_DROPPED_FILES,
): Promise<File[]> => {
const files: File[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (files.length >= maxFiles) return;
if (entry.isFile) {
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
if (files.length >= maxFiles) return;
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
return;
}
if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
const childEntries = await readAllDirectoryEntries(reader);
const childPrefix = `${prefix}${entry.name}/`;
for (const child of childEntries) {
if (files.length >= maxFiles) break;
// eslint-disable-next-line no-await-in-loop
await walk(child, childPrefix);
}
}
};
for (const entry of entries) {
if (files.length >= maxFiles) break;
// eslint-disable-next-line no-await-in-loop
await walk(entry, '');
}
return files;
};
/**
* Extract dropped files, descending into any dropped folders.
*
* Captures the `FileSystemEntry` list synchronously (required — see
* {@link entriesFromDataTransfer}) then traverses it asynchronously. Falls back
* to the flat `dataTransfer.files` list when the directory API is unavailable
* (non-Chromium) or when no entries are exposed.
*/
export const collectDroppedFiles = (dataTransfer: DataTransfer): Promise<File[] | undefined> => {
const entries = entriesFromDataTransfer(dataTransfer);
if (entries.length === 0) {
return Promise.resolve(getDataTransferFiles(dataTransfer));
}
return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined));
};