Files
webmail/components/settings/desktop-quick-compose-setting.tsx
T
Linus Rath 71aaadef50 fix: run plugins' compose check in quick compose, keep a composer with one owner, and bring it back after a crash
The first version of quick compose skipped `onBeforeCompose`, a composer
moved back could end up open in both windows, a reply moved back under
Pro flagged the selected message, and a crashed pop-out came back blank.

- A new message in a pop-out runs `onBeforeCompose` with the same result
  as the main window: a plugin that stops it closes the window.
- The way back has two steps, so a pop-out that gave up can't leave a
  second owner. A handoff behind a plugin's gate makes it say busy.
- A Pro tab made from a composer moved back answers its own message.
- Each save writes `draft`, `account` and `owner` into the pop-out's
  address, which a reload after a crash starts from. What was typed since
  the last save, up to the 2 s autosave delay, is still lost.
- The recorder refuses keys that type a character on this keyboard, and
  what the shell refuses from a page, and says why.
2026-11-04 01:31:43 +01:00

110 lines
3.7 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { describeAccelerator, QUICK_COMPOSE_DEFAULT, readShortcutPress } from "@/lib/desktop/shortcut-keys";
import type { DesktopQuickComposeStatus } from "@/lib/desktop/types";
import { SettingItem, ToggleSwitch } from "./settings-section";
interface KeyboardMap {
get: (code: string) => string | undefined;
}
/** What each key gives alone on this keyboard. Not every browser has it, so it can be null. */
async function readKeyboardMap(): Promise<KeyboardMap | null> {
try {
const keyboard = (navigator as unknown as { keyboard?: { getLayoutMap?: () => Promise<KeyboardMap> } }).keyboard;
return (await keyboard?.getLayoutMap?.()) ?? null;
} catch {
return null;
}
}
/**
* The shortcut for a small composer window, on the desktop settings. What holds it is the
* shell's business: this shows what it says, including that another app has the keys.
*/
export function DesktopQuickComposeSetting({ status, platform, onChange }: {
status: DesktopQuickComposeStatus;
platform: string | undefined;
onChange: (shortcut: string | null) => void;
}) {
const t = useTranslations("desktopQuickCompose");
const [recording, setRecording] = useState(false);
const [refused, setRefused] = useState<"types-character" | "reserved" | null>(null);
const keyboardMap = useRef<KeyboardMap | null>(null);
const { shortcut, active, locked } = status;
const keys = shortcut ? describeAccelerator(shortcut, platform) : "";
useEffect(() => {
if (!recording) return;
let stale = false;
void readKeyboardMap().then((map) => {
if (!stale) keyboardMap.current = map;
});
return () => {
stale = true;
};
}, [recording]);
const stopRecording = () => {
setRecording(false);
setRefused(null);
};
const description = refused === "types-character" ? t("types_character")
: refused === "reserved" ? t("reserved")
: recording ? t("recording_hint")
: !shortcut ? t("description_off")
: active ? t("description", { keys })
: t("taken", { keys });
return (
<SettingItem label={t("label")} description={description} locked={locked}>
<div className="flex items-center gap-2">
{recording ? (
<Button
variant="outline"
size="sm"
autoFocus
className="text-xs h-7"
data-testid="quick-compose-recording"
onBlur={stopRecording}
onKeyDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
stopRecording();
return;
}
const result = readShortcutPress(event, platform, keyboardMap.current?.get(event.code));
if ("accelerator" in result) {
stopRecording();
onChange(result.accelerator);
} else if (result.refused !== "incomplete") {
setRefused(result.refused);
}
}}
>
{t("recording")}
</Button>
) : shortcut && (
<Button
variant="outline"
size="sm"
className="text-xs h-7"
aria-label={t("change", { keys })}
title={t("change", { keys })}
data-testid="quick-compose-keys"
onClick={() => setRecording(true)}
>
{keys}
</Button>
)}
<ToggleSwitch checked={!!shortcut} onChange={(on) => onChange(on ? (status.builtIn ?? QUICK_COMPOSE_DEFAULT) : null)} />
</div>
</SettingItem>
);
}