From 258e3ec6208bb04833fb6f801b13e2cfa4abcba8 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 1 Jul 2026 10:40:31 -0400 Subject: [PATCH] fix(desktop): address code-review findings on the desktop wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fileEntries: a single unreadable file/dir in a dropped folder no longer aborts the whole traversal (try/catch per entry, skip failures) — was discarding ALL dropped files (incl. the flat-file path) + an unhandled rejection; also add .catch in both useFileDrop consumers. - RoomInput: mirror a localStorage-restored draft into the draft atom so the P5-57 indicator reflects a persisted draft after a page reload, not only on same-session room re-entry. - useTauriThumbbar: swallow toggleMicrophone()/hangup() rejections (parity with SMTC) — avoids an unhandled rejection when clicked mid-teardown. - App/DesktopChrome: keep wrapper element types stable across the chrome toggle (display:contents when off) so flipping it no longer remounts RouterProvider. - settings: normalizeComposerToolbarOrder also appends missing keys from the canonical key set (safety net if a new button is absent from the default order). Gates: tsc/eslint/prettier clean, build OK, 559 tests. Co-Authored-By: Claude Opus 4.8 --- src/app/features/room/RoomInput.tsx | 6 +++++- src/app/hooks/useFileDrop.ts | 16 ++++++++++------ src/app/hooks/useTauriThumbbar.ts | 5 +++-- src/app/pages/App.tsx | 21 +++++++++++++++++---- src/app/state/settings.ts | 10 ++++++++++ src/app/utils/fileEntries.ts | 22 +++++++++++++++++----- 6 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 9d2699116..d5007b233 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -369,13 +369,17 @@ export const RoomInput = forwardRef( const nodes = JSON.parse(stored); if (Array.isArray(nodes) && nodes.length > 0) { Transforms.insertFragment(editor, nodes); + // Mirror the restored draft into the atom so the draft indicator + // (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft + // after a page reload — not only on same-session room re-entry. + setMsgDraft(nodes); } } } catch { // Ignore malformed stored draft } } - }, [editor, msgDraft, roomId]); + }, [editor, msgDraft, roomId, setMsgDraft]); useEffect( () => () => { diff --git a/src/app/hooks/useFileDrop.ts b/src/app/hooks/useFileDrop.ts index 2791931fe..f5c880589 100644 --- a/src/app/hooks/useFileDrop.ts +++ b/src/app/hooks/useFileDrop.ts @@ -6,9 +6,11 @@ export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHan (evt) => { // `collectDroppedFiles` synchronously captures the entry list from the // DataTransfer before traversing folders asynchronously. - collectDroppedFiles(evt.dataTransfer).then((files) => { - if (files) onDrop(files); - }); + collectDroppedFiles(evt.dataTransfer) + .then((files) => { + if (files) onDrop(files); + }) + .catch(() => undefined); }, [onDrop], ); @@ -30,9 +32,11 @@ export const useFileDropZone = ( // 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); - }); + collectDroppedFiles(evt.dataTransfer) + .then((files) => { + if (files) onDrop(files); + }) + .catch(() => undefined); }; target?.addEventListener('drop', handleDrop); diff --git a/src/app/hooks/useTauriThumbbar.ts b/src/app/hooks/useTauriThumbbar.ts index cc4e65f06..6ee6a2183 100644 --- a/src/app/hooks/useTauriThumbbar.ts +++ b/src/app/hooks/useTauriThumbbar.ts @@ -31,13 +31,14 @@ export function useTauriThumbbar(): void { if (!callEmbed) return; if (action === 'mute') { // toggleMicrophone flips the mic; `microphone === false` means muted. - callEmbed.control.toggleMicrophone(); + // Async transport send — swallow rejection (widget mid-teardown), as SMTC does. + callEmbed.control.toggleMicrophone().catch(() => undefined); } else if (action === 'deafen') { // toggleSound flips local audio; `sound === false` means deafened. It also // mutes the mic while deafened, matching the in-app Deafen control. callEmbed.control.toggleSound(); } else if (action === 'end') { - callEmbed.hangup(); + callEmbed.hangup().catch(() => undefined); } }); } diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index a1a5f5cb2..1ca919eb0 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -100,11 +100,24 @@ function TauriEffects() { function DesktopChrome({ children }: { children: ReactNode }) { const customChrome = useAtomValue(customWindowChromeAtom); useTauriWindowChrome(); - if (!(isTauri() && customChrome)) return <>{children}; + const useChrome = isTauri() && customChrome; + // Keep the wrapper element structure STABLE across the toggle so flipping the + // setting never changes the element type in `children`'s ancestry — otherwise + // React would unmount/remount the whole RouterProvider subtree (losing scroll, + // menus, unsaved composer state). When off, both wrappers use `display:contents` + // so they generate no box → zero layout impact (also the browser default path). return ( -
- -
{children}
+
+ {useChrome && } +
+ {children} +
); } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 7cf16b89f..43c3db323 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -137,12 +137,22 @@ export const normalizeComposerToolbarOrder = ( result.push(key); } }); + // Append missing keys in their canonical default position… DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => { if (!seen.has(key)) { seen.add(key); result.push(key); } }); + // …then any known key not covered by the default order (safety net so a new + // button added to COMPOSER_TOOLBAR_BUTTON_KEYS but forgotten in the default + // order can still render/reorder rather than being permanently dropped). + COMPOSER_TOOLBAR_BUTTON_KEYS.forEach((key) => { + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + }); return result; }; diff --git a/src/app/utils/fileEntries.ts b/src/app/utils/fileEntries.ts index 3395e0057..194e24d87 100644 --- a/src/app/utils/fileEntries.ts +++ b/src/app/utils/fileEntries.ts @@ -77,16 +77,28 @@ export const filesFromEntries = async ( const walk = async (entry: FileSystemEntry, prefix: string): Promise => { if (files.length >= maxFiles) return; + // A single unreadable file/directory (moved between drop and read, a + // permissions/lock error, an OS special file) must NOT abort the whole + // traversal — skip it and keep collecting the rest. if (entry.isFile) { - const file = await fileFromFileEntry(entry as FileSystemFileEntry); - if (files.length >= maxFiles) return; - files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file); + try { + const file = await fileFromFileEntry(entry as FileSystemFileEntry); + if (files.length >= maxFiles) return; + files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file); + } catch { + /* skip unreadable file */ + } return; } if (entry.isDirectory) { - const reader = (entry as FileSystemDirectoryEntry).createReader(); - const childEntries = await readAllDirectoryEntries(reader); + let childEntries: FileSystemEntry[] = []; + try { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + childEntries = await readAllDirectoryEntries(reader); + } catch { + return; /* skip unreadable directory */ + } const childPrefix = `${prefix}${entry.name}/`; for (const child of childEntries) { if (files.length >= maxFiles) break;