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
parent 7e38baa7b6
commit 4509a2b6d3
8 changed files with 294 additions and 7 deletions
+12 -5
View File
@@ -1,11 +1,14 @@
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 =>
useCallback(
(evt) => {
const files = getDataTransferFiles(evt.dataTransfer);
if (files) onDrop(files);
// `collectDroppedFiles` synchronously captures the entry list from the
// DataTransfer before traversing folders asynchronously.
collectDroppedFiles(evt.dataTransfer).then((files) => {
if (files) onDrop(files);
});
},
[onDrop],
);
@@ -24,8 +27,12 @@ export const useFileDropZone = (
dragCounterRef.current = 0;
setActive(false);
if (!evt.dataTransfer) return;
const files = getDataTransferFiles(evt.dataTransfer);
if (files) onDrop(files);
// 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);
});
};
target?.addEventListener('drop', handleDrop);
+24
View File
@@ -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),
);
}
+39
View File
@@ -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);
});
}