fix(desktop): address code-review findings on the desktop wave
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -369,13 +369,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const nodes = JSON.parse(stored);
|
const nodes = JSON.parse(stored);
|
||||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||||
Transforms.insertFragment(editor, nodes);
|
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 {
|
} catch {
|
||||||
// Ignore malformed stored draft
|
// Ignore malformed stored draft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, msgDraft, roomId]);
|
}, [editor, msgDraft, roomId, setMsgDraft]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHan
|
|||||||
(evt) => {
|
(evt) => {
|
||||||
// `collectDroppedFiles` synchronously captures the entry list from the
|
// `collectDroppedFiles` synchronously captures the entry list from the
|
||||||
// DataTransfer before traversing folders asynchronously.
|
// DataTransfer before traversing folders asynchronously.
|
||||||
collectDroppedFiles(evt.dataTransfer).then((files) => {
|
collectDroppedFiles(evt.dataTransfer)
|
||||||
if (files) onDrop(files);
|
.then((files) => {
|
||||||
});
|
if (files) onDrop(files);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
},
|
},
|
||||||
[onDrop],
|
[onDrop],
|
||||||
);
|
);
|
||||||
@@ -30,9 +32,11 @@ export const useFileDropZone = (
|
|||||||
// Capture entries synchronously (inside the event) then traverse any
|
// Capture entries synchronously (inside the event) then traverse any
|
||||||
// dropped folders asynchronously — the DataTransferItemList is emptied
|
// dropped folders asynchronously — the DataTransferItemList is emptied
|
||||||
// once this handler returns.
|
// once this handler returns.
|
||||||
collectDroppedFiles(evt.dataTransfer).then((files) => {
|
collectDroppedFiles(evt.dataTransfer)
|
||||||
if (files) onDrop(files);
|
.then((files) => {
|
||||||
});
|
if (files) onDrop(files);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
target?.addEventListener('drop', handleDrop);
|
target?.addEventListener('drop', handleDrop);
|
||||||
|
|||||||
@@ -31,13 +31,14 @@ export function useTauriThumbbar(): void {
|
|||||||
if (!callEmbed) return;
|
if (!callEmbed) return;
|
||||||
if (action === 'mute') {
|
if (action === 'mute') {
|
||||||
// toggleMicrophone flips the mic; `microphone === false` means muted.
|
// 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') {
|
} else if (action === 'deafen') {
|
||||||
// toggleSound flips local audio; `sound === false` means deafened. It also
|
// toggleSound flips local audio; `sound === false` means deafened. It also
|
||||||
// mutes the mic while deafened, matching the in-app Deafen control.
|
// mutes the mic while deafened, matching the in-app Deafen control.
|
||||||
callEmbed.control.toggleSound();
|
callEmbed.control.toggleSound();
|
||||||
} else if (action === 'end') {
|
} else if (action === 'end') {
|
||||||
callEmbed.hangup();
|
callEmbed.hangup().catch(() => undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -100,11 +100,24 @@ function TauriEffects() {
|
|||||||
function DesktopChrome({ children }: { children: ReactNode }) {
|
function DesktopChrome({ children }: { children: ReactNode }) {
|
||||||
const customChrome = useAtomValue(customWindowChromeAtom);
|
const customChrome = useAtomValue(customWindowChromeAtom);
|
||||||
useTauriWindowChrome();
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
<div
|
||||||
<TitleBar />
|
style={
|
||||||
<div style={{ flexGrow: 1, minHeight: 0 }}>{children}</div>
|
useChrome
|
||||||
|
? { display: 'flex', flexDirection: 'column', height: '100vh' }
|
||||||
|
: { display: 'contents' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{useChrome && <TitleBar />}
|
||||||
|
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,12 +137,22 @@ export const normalizeComposerToolbarOrder = (
|
|||||||
result.push(key);
|
result.push(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Append missing keys in their canonical default position…
|
||||||
DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
|
DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
result.push(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;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -77,16 +77,28 @@ export const filesFromEntries = async (
|
|||||||
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||||||
if (files.length >= maxFiles) return;
|
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) {
|
if (entry.isFile) {
|
||||||
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
|
try {
|
||||||
if (files.length >= maxFiles) return;
|
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
|
||||||
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
|
if (files.length >= maxFiles) return;
|
||||||
|
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
|
||||||
|
} catch {
|
||||||
|
/* skip unreadable file */
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
let childEntries: FileSystemEntry[] = [];
|
||||||
const childEntries = await readAllDirectoryEntries(reader);
|
try {
|
||||||
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
|
childEntries = await readAllDirectoryEntries(reader);
|
||||||
|
} catch {
|
||||||
|
return; /* skip unreadable directory */
|
||||||
|
}
|
||||||
const childPrefix = `${prefix}${entry.name}/`;
|
const childPrefix = `${prefix}${entry.name}/`;
|
||||||
for (const child of childEntries) {
|
for (const child of childEntries) {
|
||||||
if (files.length >= maxFiles) break;
|
if (files.length >= maxFiles) break;
|
||||||
|
|||||||
Reference in New Issue
Block a user