From 4509a2b6d361b7ba53cd3e4fa2ee8f0450ae759c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 1 Jul 2026 10:01:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Tier=20B=20web=20side=20?= =?UTF-8?q?=E2=80=94=20toast=20actions,=20Focus=20Assist=20gate,=20folder?= =?UTF-8?q?=20DnD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/components/TauriDesktopFeatures.tsx | 4 + src/app/hooks/useFileDrop.ts | 17 ++- src/app/hooks/useTauriFocusAssist.ts | 24 ++++ src/app/hooks/useTauriToastActions.ts | 39 ++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 11 +- src/app/state/focusAssist.ts | 14 +++ src/app/utils/fileEntries.test.ts | 70 +++++++++++ src/app/utils/fileEntries.ts | 122 +++++++++++++++++++ 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 src/app/hooks/useTauriFocusAssist.ts create mode 100644 src/app/hooks/useTauriToastActions.ts create mode 100644 src/app/state/focusAssist.ts create mode 100644 src/app/utils/fileEntries.test.ts create mode 100644 src/app/utils/fileEntries.ts diff --git a/src/app/components/TauriDesktopFeatures.tsx b/src/app/components/TauriDesktopFeatures.tsx index fa644d37f..13e1eb6ef 100644 --- a/src/app/components/TauriDesktopFeatures.tsx +++ b/src/app/components/TauriDesktopFeatures.tsx @@ -3,6 +3,8 @@ import { useTauriJumpList } from '../hooks/useTauriJumpList'; import { useTauriThumbbar } from '../hooks/useTauriThumbbar'; import { useTauriSmtc } from '../hooks/useTauriSmtc'; 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 @@ -17,5 +19,7 @@ export function TauriDesktopFeatures(): null { useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end) useTauriSmtc(); // P5-43 system media transport controls 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; } diff --git a/src/app/hooks/useFileDrop.ts b/src/app/hooks/useFileDrop.ts index d88def72e..2791931fe 100644 --- a/src/app/hooks/useFileDrop.ts +++ b/src/app/hooks/useFileDrop.ts @@ -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); diff --git a/src/app/hooks/useTauriFocusAssist.ts b/src/app/hooks/useTauriFocusAssist.ts new file mode 100644 index 000000000..15ade4ffb --- /dev/null +++ b/src/app/hooks/useTauriFocusAssist.ts @@ -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('focus-assist-changed', ({ active }) => + setFocusAssist(active), + ); +} diff --git a/src/app/hooks/useTauriToastActions.ts b/src/app/hooks/useTauriToastActions.ts new file mode 100644 index 000000000..4b608cb08 --- /dev/null +++ b/src/app/hooks/useTauriToastActions.ts @@ -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('lotus-notification-activate', ({ path }) => { + if (path) navigate(path); + }); + + useTauriEvent('lotus-notification-reply', ({ roomId, text }) => { + if (!roomId || !text) return; + mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined); + }); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 160fbe334..b0a1eba49 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -2,6 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai'; import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; +import { focusAssistActiveAtom } from '../../state/focusAssist'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import LogoSVG from '../../../../public/res/lotus.png'; import LogoUnreadSVG from '../../../../public/res/lotus-unread.png'; @@ -110,6 +111,7 @@ function InviteNotifications() { const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); + const focusAssistActive = useAtomValue(focusAssistActiveAtom); const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId'); @@ -168,7 +170,8 @@ function InviteNotifications() { useEffect(() => { if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') { - const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd); + const quietActive = + focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd)); if (!quietActive) { if (showNotifications && notificationPermission('granted')) { notify(invites.length - perviousInviteLen); @@ -190,6 +193,7 @@ function InviteNotifications() { quietHoursEnabled, quietHoursStart, quietHoursEnd, + focusAssistActive, inviteSoundId, ]); @@ -213,6 +217,7 @@ function MessageNotifications() { const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); + const focusAssistActive = useAtomValue(focusAssistActiveAtom); const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId'); @@ -356,7 +361,8 @@ function MessageNotifications() { return; } - const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd); + const quietActive = + focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd)); if (!quietActive) { if (showNotifications && notificationPermission('granted')) { const avatarMxc = @@ -395,6 +401,7 @@ function MessageNotifications() { quietHoursEnabled, quietHoursStart, quietHoursEnd, + focusAssistActive, messageSoundId, ]); diff --git a/src/app/state/focusAssist.ts b/src/app/state/focusAssist.ts new file mode 100644 index 000000000..c944c13e8 --- /dev/null +++ b/src/app/state/focusAssist.ts @@ -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); diff --git a/src/app/utils/fileEntries.test.ts b/src/app/utils/fileEntries.test.ts new file mode 100644 index 000000000..616759c81 --- /dev/null +++ b/src/app/utils/fileEntries.test.ts @@ -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); +}); diff --git a/src/app/utils/fileEntries.ts b/src/app/utils/fileEntries.ts new file mode 100644 index 000000000..3395e0057 --- /dev/null +++ b/src/app/utils/fileEntries.ts @@ -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 => + 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 => + 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 => { + const files: File[] = []; + + const walk = async (entry: FileSystemEntry, prefix: string): Promise => { + 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 => { + const entries = entriesFromDataTransfer(dataTransfer); + if (entries.length === 0) { + return Promise.resolve(getDataTransferFiles(dataTransfer)); + } + return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined)); +};