mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: name the account sign out leaves in the command palette, add star, hide dead key hints
This commit is contained in:
@@ -119,6 +119,41 @@ describe('command palette', () => {
|
||||
expect(hint('search')).toBe('');
|
||||
});
|
||||
|
||||
it('shows no single-key hints while single-key shortcuts are off, since those keys do nothing then', async () => {
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const hint = (label: string) => {
|
||||
const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!;
|
||||
return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' ');
|
||||
};
|
||||
useSettingsStore.setState({ singleKeyShortcuts: false });
|
||||
try {
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
expect(hint('compose')).toBe('');
|
||||
expect(hint('shortcuts')).toBe('');
|
||||
} finally {
|
||||
useSettingsStore.setState({ singleKeyShortcuts: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('names the account Sign out leaves, as the account menu does', async () => {
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
const before = useAccountStore.getState().accounts;
|
||||
useAccountStore.setState({ accounts: [
|
||||
{ id: 'a1', email: 'one@example.org', username: 'one@example.org' },
|
||||
{ id: 'a2', email: 'two@example.org', username: 'two@example.org' },
|
||||
] } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'a2' } as never);
|
||||
try {
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
expect(screen.getByText('sign_out_of(two@example.org)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('sign_out')).toBeNull();
|
||||
} finally {
|
||||
useAccountStore.setState({ accounts: before } as never);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows plugin entries with the plugin name and runs them with Enter', async () => {
|
||||
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
|
||||
render(<CommandPaletteHost />);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Mail registers its palette commands in mail-app.tsx, which is too big to render
|
||||
// here. Each message action with a key in the shortcuts help is in the palette too,
|
||||
// under the key the help names.
|
||||
const source = readFileSync(path.join(process.cwd(), 'components/mail/mail-app.tsx'), 'utf8');
|
||||
const block = source.slice(source.indexOf("useRegisterCommands(t('sidebar.mail')"));
|
||||
|
||||
describe('Mail commands in the palette', () => {
|
||||
it.each([
|
||||
['reply', 'r'],
|
||||
['reply-all', 'a'],
|
||||
['forward', 'f'],
|
||||
['star', 's'],
|
||||
['archive', 'e'],
|
||||
['delete', '#'],
|
||||
['mark-unread', 'u'],
|
||||
['mark-read', 'Shift + I'],
|
||||
['spam', '!'],
|
||||
])('has %s, shown with %s', (id, key) => {
|
||||
const at = block.indexOf(`id: '${id}'`);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const entry = block.slice(at, block.indexOf('}', at));
|
||||
expect(entry).toContain(`shortcut: '${key}'`);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { useGlobalSearchStore } from "@/stores/global-search-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
@@ -38,7 +39,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist
|
||||
import { shortcutAlternatives } from "@/lib/shortcut-keys";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { isSingleKeyHint, isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
@@ -162,6 +163,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
// A touch screen has no keyboard to use the shortcuts with (R8).
|
||||
const touch = useMediaQuery("(pointer: coarse)");
|
||||
// With single-key shortcuts off, "r" or "#" does nothing: no hint for it.
|
||||
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts !== false);
|
||||
const hintKeys = (shortcut: string) => shortcutAlternatives(shortcut).filter((key) => singleKeys || !isSingleKeyHint(key));
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current });
|
||||
@@ -255,7 +259,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
|
||||
run: () => { void useAuthStore.getState().switchAccount(entry.id); },
|
||||
});
|
||||
}
|
||||
list.push({ id: "sign-out", group: account, icon: LogOut, label: tSidebar("sign_out"), run: () => { void useAuthStore.getState().logout(); } });
|
||||
// It signs out of the open account only, as the account menu's entry says.
|
||||
const current = accounts.find((entry) => entry.id === activeAccountId);
|
||||
const currentName = current ? toUnicodeEmail(current.email || current.username) : "";
|
||||
list.push({
|
||||
id: "sign-out",
|
||||
group: account,
|
||||
icon: LogOut,
|
||||
label: currentName ? tSidebar("sign_out_of", { account: currentName }) : tSidebar("sign_out"),
|
||||
run: () => { void useAuthStore.getState().logout(); },
|
||||
});
|
||||
for (const action of pluginActions) {
|
||||
list.push({
|
||||
id: `plugin:${action.pluginId}:${action.id}`,
|
||||
@@ -394,10 +407,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
|
||||
{command.source && (
|
||||
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
|
||||
)}
|
||||
{command.shortcut && (
|
||||
{command.shortcut && hintKeys(command.shortcut).length > 0 && (
|
||||
// Key hints only where there is a keyboard (R8).
|
||||
<span className="flex-shrink-0 flex items-center gap-1 pointer-coarse:hidden" aria-hidden="true">
|
||||
{shortcutAlternatives(command.shortcut).map((key) => (
|
||||
{hintKeys(command.shortcut).map((key) => (
|
||||
<kbd key={key} className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground">{key}</kbd>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -136,7 +136,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { openMailImport } from "@/stores/mail-transfer-store";
|
||||
import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
|
||||
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Star } from "@/components/icons";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { MoveToDialog } from "@/components/email/move-to-dialog";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
@@ -4813,6 +4813,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
when: () => !!paletteEmail && !paletteEmail.keywords?.$seen,
|
||||
run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); },
|
||||
},
|
||||
{
|
||||
id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's',
|
||||
when: hasPaletteEmail, run: () => void handleToggleStar(),
|
||||
},
|
||||
{
|
||||
id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'),
|
||||
icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!',
|
||||
|
||||
@@ -10,3 +10,12 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
export function isSingleKeyShortcutOff(event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">): boolean {
|
||||
return isSingleCharacterKey(event) && useSettingsStore.getState().singleKeyShortcuts === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A key hint as the help and menus write it ("r", "Shift + I", "#") that's a single-key
|
||||
* shortcut, so it does nothing while those are off. Arrows aren't characters.
|
||||
*/
|
||||
export function isSingleKeyHint(combo: string): boolean {
|
||||
const key = combo.replace(/^(shift\s*\+\s*)+/i, "").trim();
|
||||
return [...key].length === 1 && !/[←→↑↓]/.test(key);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user