mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
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.
110 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|