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:
@@ -3,6 +3,8 @@ import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
|||||||
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
||||||
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||||
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
@@ -17,5 +19,7 @@ export function TauriDesktopFeatures(): null {
|
|||||||
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
||||||
useTauriSmtc(); // P5-43 system media transport controls
|
useTauriSmtc(); // P5-43 system media transport controls
|
||||||
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
||||||
import { getDataTransferFiles } from '../utils/dom';
|
import { collectDroppedFiles } from '../utils/fileEntries';
|
||||||
|
|
||||||
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const files = getDataTransferFiles(evt.dataTransfer);
|
// `collectDroppedFiles` synchronously captures the entry list from the
|
||||||
|
// DataTransfer before traversing folders asynchronously.
|
||||||
|
collectDroppedFiles(evt.dataTransfer).then((files) => {
|
||||||
if (files) onDrop(files);
|
if (files) onDrop(files);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[onDrop],
|
[onDrop],
|
||||||
);
|
);
|
||||||
@@ -24,8 +27,12 @@ export const useFileDropZone = (
|
|||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setActive(false);
|
setActive(false);
|
||||||
if (!evt.dataTransfer) return;
|
if (!evt.dataTransfer) return;
|
||||||
const files = getDataTransferFiles(evt.dataTransfer);
|
// Capture entries synchronously (inside the event) then traverse any
|
||||||
|
// dropped folders asynchronously — the DataTransferItemList is emptied
|
||||||
|
// once this handler returns.
|
||||||
|
collectDroppedFiles(evt.dataTransfer).then((files) => {
|
||||||
if (files) onDrop(files);
|
if (files) onDrop(files);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
target?.addEventListener('drop', handleDrop);
|
target?.addEventListener('drop', handleDrop);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Detail shape of the `focus-assist-changed` event emitted by the native side. */
|
||||||
|
type FocusAssistChangedDetail = {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (desktop). Subscribes to
|
||||||
|
* the native `focus-assist-changed` event (Windows `SHQueryUserNotificationState`
|
||||||
|
* poll, `{ active }`) and mirrors it into `focusAssistActiveAtom`, which the
|
||||||
|
* notification gate reads to suppress notifications while the shell is in Focus
|
||||||
|
* Assist / Quiet Hours, presenting, gaming full-screen, or busy. Inert in the
|
||||||
|
* browser, since `useTauriEvent` only listens under Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriFocusAssist(): void {
|
||||||
|
const setFocusAssist = useSetAtom(focusAssistActiveAtom);
|
||||||
|
|
||||||
|
useTauriEvent<FocusAssistChangedDetail>('focus-assist-changed', ({ active }) =>
|
||||||
|
setFocusAssist(active),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { MsgType } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Payload of the `lotus-notification-activate` event (a plain body click). */
|
||||||
|
interface ActivateDetail {
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of the `lotus-notification-reply` event (the inline reply box). */
|
||||||
|
interface ReplyDetail {
|
||||||
|
roomId?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-41 / P5-35 — wire the native WinRT toast's click + quick-reply back into the
|
||||||
|
* client. The Rust side (`show_rich_toast`) dispatches DOM CustomEvents via
|
||||||
|
* `emit_to_web`:
|
||||||
|
* - `lotus-notification-activate` → route to the room the toast was for, reusing
|
||||||
|
* the same `useNavigate(path)` mechanism the web `notificationclick` path uses
|
||||||
|
* (see ClientNonUIFeatures).
|
||||||
|
* - `lotus-notification-reply` → send the typed reply straight to the room.
|
||||||
|
* No-op outside Tauri (the events never fire).
|
||||||
|
*/
|
||||||
|
export function useTauriToastActions(): void {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
|
||||||
|
if (path) navigate(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
|
||||||
|
if (!roomId || !text) return;
|
||||||
|
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/lotus.png';
|
import LogoSVG from '../../../../public/res/lotus.png';
|
||||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||||
@@ -110,6 +111,7 @@ function InviteNotifications() {
|
|||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
||||||
@@ -168,7 +170,8 @@ function InviteNotifications() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
||||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
const quietActive =
|
||||||
|
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (!quietActive) {
|
if (!quietActive) {
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
notify(invites.length - perviousInviteLen);
|
notify(invites.length - perviousInviteLen);
|
||||||
@@ -190,6 +193,7 @@ function InviteNotifications() {
|
|||||||
quietHoursEnabled,
|
quietHoursEnabled,
|
||||||
quietHoursStart,
|
quietHoursStart,
|
||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
|
focusAssistActive,
|
||||||
inviteSoundId,
|
inviteSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -213,6 +217,7 @@ function MessageNotifications() {
|
|||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
||||||
@@ -356,7 +361,8 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
const quietActive =
|
||||||
|
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (!quietActive) {
|
if (!quietActive) {
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
const avatarMxc =
|
const avatarMxc =
|
||||||
@@ -395,6 +401,7 @@ function MessageNotifications() {
|
|||||||
quietHoursEnabled,
|
quietHoursEnabled,
|
||||||
quietHoursStart,
|
quietHoursStart,
|
||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
|
focusAssistActive,
|
||||||
messageSoundId,
|
messageSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (live OS state).
|
||||||
|
*
|
||||||
|
* Standalone, non-persisted boolean atom reflecting whether the shell is
|
||||||
|
* currently suppressing notifications (Focus Assist / Quiet Hours, presentation
|
||||||
|
* mode, full-screen D3D, or "busy"). It is driven at runtime by
|
||||||
|
* `useTauriFocusAssist` from the native `focus-assist-changed` event and read by
|
||||||
|
* the notification gate. Because it mirrors transient OS state — not a user
|
||||||
|
* preference — it is a plain in-memory atom that defaults to `false` and is
|
||||||
|
* intentionally NOT written to `localStorage`.
|
||||||
|
*/
|
||||||
|
export const focusAssistActiveAtom = atom(false);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { filesFromEntries } from './fileEntries';
|
||||||
|
|
||||||
|
const fileEntry = (name: string): FileSystemFileEntry =>
|
||||||
|
({
|
||||||
|
isFile: true,
|
||||||
|
isDirectory: false,
|
||||||
|
name,
|
||||||
|
file: (success: (file: File) => void) => {
|
||||||
|
success(new File(['x'], name, { type: 'text/plain' }));
|
||||||
|
},
|
||||||
|
}) as unknown as FileSystemFileEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A directory whose reader yields its children in several batches (mirroring
|
||||||
|
* Chromium's `readEntries`, which caps each call) and finally an empty batch.
|
||||||
|
*/
|
||||||
|
const dirEntry = (name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry => {
|
||||||
|
const batches = [children.slice(0, 1), children.slice(1), [] as FileSystemEntry[]];
|
||||||
|
return {
|
||||||
|
isFile: false,
|
||||||
|
isDirectory: true,
|
||||||
|
name,
|
||||||
|
createReader: () => {
|
||||||
|
let call = 0;
|
||||||
|
return {
|
||||||
|
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
|
||||||
|
const batch = batches[call] ?? [];
|
||||||
|
call += 1;
|
||||||
|
success(batch);
|
||||||
|
},
|
||||||
|
} as unknown as FileSystemDirectoryReader;
|
||||||
|
},
|
||||||
|
} as unknown as FileSystemDirectoryEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('filesFromEntries flattens nested folders and prefixes relative paths', async () => {
|
||||||
|
const entries: FileSystemEntry[] = [
|
||||||
|
fileEntry('top.txt'),
|
||||||
|
dirEntry('photos', [
|
||||||
|
fileEntry('a.jpg'),
|
||||||
|
dirEntry('2024', [fileEntry('b.jpg'), fileEntry('c.jpg')]),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = await filesFromEntries(entries);
|
||||||
|
const names = files.map((f) => f.name).sort();
|
||||||
|
|
||||||
|
assert.deepEqual(names, ['photos/2024/b.jpg', 'photos/2024/c.jpg', 'photos/a.jpg', 'top.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filesFromEntries reads directory entries in batches until empty', async () => {
|
||||||
|
const entries: FileSystemEntry[] = [
|
||||||
|
dirEntry('docs', [fileEntry('one.txt'), fileEntry('two.txt')]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = await filesFromEntries(entries);
|
||||||
|
assert.equal(files.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filesFromEntries respects the maxFiles cap', async () => {
|
||||||
|
const entries: FileSystemEntry[] = [
|
||||||
|
dirEntry('many', [fileEntry('a.txt'), fileEntry('b.txt')]),
|
||||||
|
fileEntry('c.txt'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = await filesFromEntries(entries, 2);
|
||||||
|
assert.equal(files.length, 2);
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user