mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: open the keyboard shortcuts with ? in every app, on that app's keys
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const nav = vi.hoisted(() => ({ pathname: '/' }));
|
||||
vi.mock('@/i18n/navigation', () => ({ usePathname: () => nav.pathname }));
|
||||
vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => false }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
|
||||
vi.mock('@/lib/keyboard-shortcuts/calendar', () => ({
|
||||
CALENDAR_SECTIONS: [{ id: 'views', title: 'calendar.views', entries: [{ key: 'd', description: 'calendar.day_view' }] }],
|
||||
}));
|
||||
|
||||
const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
|
||||
|
||||
describe('keyboard shortcuts help per app', () => {
|
||||
it("opens on the current app's tab and lists the general keys there too", () => {
|
||||
nav.pathname = '/calendar';
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
// Contacts, Files and Settings have no keys yet: no tabs.
|
||||
expect(tabs.map((tab) => tab.textContent)).toEqual(['sidebar.mail', 'sidebar.calendar']);
|
||||
expect(screen.getByRole('tab', { name: 'sidebar.calendar' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('calendar.day_view')).toBeInTheDocument();
|
||||
expect(screen.getByText('shortcuts.global.help')).toBeInTheDocument();
|
||||
expect(screen.queryByText('shortcuts.actions.reply')).toBeNull();
|
||||
|
||||
act(() => { fireEvent.click(screen.getByRole('tab', { name: 'sidebar.mail' })); });
|
||||
expect(screen.getByText('shortcuts.actions.reply')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('shortcuts.global.help')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('translates "or" between alternatives and uses Ctrl off Apple devices', () => {
|
||||
nav.pathname = '/';
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getAllByText('or').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Ctrl + K').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -123,3 +123,20 @@ describe('global commands', () => {
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ? key', () => {
|
||||
it('opens the keyboard shortcuts in any app, but not while typing or with a modifier', () => {
|
||||
render(<><input aria-label="field" /><CommandPaletteHost /></>);
|
||||
act(() => { fireEvent.keyDown(screen.getByLabelText('field'), { key: '?', code: 'Slash', shiftKey: true }); });
|
||||
act(() => { fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true, ctrlKey: true }); });
|
||||
expect(screen.queryByText('shortcuts help')).toBeNull();
|
||||
|
||||
const appHandler = vi.fn();
|
||||
document.addEventListener('keydown', appHandler);
|
||||
act(() => { fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true }); });
|
||||
document.removeEventListener('keydown', appHandler);
|
||||
expect(screen.getByText('shortcuts help')).toBeInTheDocument();
|
||||
// Mail's own handler doesn't open a second copy.
|
||||
expect(appHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,9 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { GlobalSearchPalette } from "@/components/global-search/global-search-palette";
|
||||
import { isContextMenuOpen } from "@/components/ui/context-menu";
|
||||
import { runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { COMMAND_PALETTE_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
|
||||
import { COMMAND_PALETTE_OPEN_EVENT, KEYBOARD_SHORTCUTS_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
|
||||
import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-registry";
|
||||
import { shortcutAlternatives } from "@/lib/shortcut-keys";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
@@ -94,6 +95,30 @@ export function CommandPaletteHost() {
|
||||
if (!enabled) setOpen(false);
|
||||
}, [enabled]);
|
||||
|
||||
// `?` opens the keyboard shortcuts in every app (R8), on the app's own
|
||||
// section. Captured before the apps' own key handlers, so Mail's doesn't
|
||||
// open a second copy.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
const question = event.key === "?" || (event.code === "Slash" && event.shiftKey);
|
||||
if (!question || isEditableEventTarget(event) || isContextMenuOpen()) return;
|
||||
// Another dialog is open: the key belongs to it.
|
||||
if (document.querySelector('[aria-modal="true"]')) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setShowShortcuts(true);
|
||||
};
|
||||
const onOpen = () => setShowShortcuts(true);
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
window.addEventListener(KEYBOARD_SHORTCUTS_OPEN_EVENT, onOpen);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
window.removeEventListener(KEYBOARD_SHORTCUTS_OPEN_EVENT, onOpen);
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <CommandPalette onClose={() => setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Keyboard } from "@/components/icons";
|
||||
import { KEYBOARD_SHORTCUTS, type ShortcutEntry } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { Keyboard } from "@/components/icons";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import {
|
||||
SHORTCUT_APPS,
|
||||
sectionsWithGeneral,
|
||||
shortcutAppForPath,
|
||||
type ShortcutAppId,
|
||||
type ShortcutEntry,
|
||||
type ShortcutSection,
|
||||
} from "@/lib/keyboard-shortcuts";
|
||||
import { shortcutAlternatives } from "@/lib/shortcut-keys";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
@@ -14,16 +24,29 @@ import { usePolicyStore } from "@/stores/policy-store";
|
||||
interface KeyboardShortcutsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** The app to open on. Default: the app of the current page. */
|
||||
app?: ShortcutAppId;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
||||
/**
|
||||
* The keyboard shortcuts help (`?`): one tab per app that has keys, opened
|
||||
* on the current app's, each with the keys that work everywhere under
|
||||
* "Global".
|
||||
*/
|
||||
export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcutsModalProps) {
|
||||
const t = useTranslations();
|
||||
const { startTour } = useTour();
|
||||
const pathname = usePathname();
|
||||
const tabsId = useId();
|
||||
// The focus starts on the list itself: on the first control it would
|
||||
// scroll to the tour link at the bottom when there are no tabs.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
const shell = useProInterfaceActive() ? "pro" : "standard";
|
||||
const features = usePolicyStore((s) => s.policy.features);
|
||||
// Plugin buttons in the composer that have a shortcut (composer-toolbar-actions).
|
||||
const pluginComposerShortcuts = usePluginActions("composer-toolbar-actions").filter((a) => a.shortcut);
|
||||
|
||||
// Rows that belong to one interface layout (or one shell) are listed only under it,
|
||||
// and a row of a gated feature only while the admin has it on.
|
||||
const shown = (entries: readonly ShortcutEntry[]) =>
|
||||
@@ -31,155 +54,114 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
&& (!entry.shell || entry.shell === shell)
|
||||
&& (!entry.feature || (features[entry.feature] ?? true)));
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
const apps = SHORTCUT_APPS.filter((a) => a.sections.some((s) => shown(s.entries).length > 0));
|
||||
const currentApp = app ?? shortcutAppForPath(pathname ?? "/");
|
||||
const initial = apps.some((a) => a.id === currentApp) ? currentApp : apps[0]?.id ?? "mail";
|
||||
const [selected, setSelected] = useState<ShortcutAppId>(initial);
|
||||
// Each time it opens, it opens on the current app.
|
||||
useEffect(() => {
|
||||
if (isOpen) setSelected(initial);
|
||||
}, [isOpen, initial]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const selectedApp = SHORTCUT_APPS.find((a) => a.id === selected);
|
||||
const sections = sectionsWithGeneral(selectedApp?.sections ?? []);
|
||||
|
||||
const extraRows = (section: ShortcutSection) =>
|
||||
selected === "mail" && section.id === "composer"
|
||||
? pluginComposerShortcuts.map((action) => (
|
||||
<ShortcutRow
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
shortcutKey={action.shortcut!}
|
||||
description={`${action.label} (${action.pluginName})`}
|
||||
/>
|
||||
))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<Keyboard className="w-5 h-5 text-muted-foreground" />
|
||||
{t("shortcuts.title")}
|
||||
</span>
|
||||
}
|
||||
initialFocus={contentRef}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-dialog-title"
|
||||
className={cn(
|
||||
"bg-background border border-border rounded-lg shadow-xl",
|
||||
"w-full max-w-2xl max-h-[90vh] overflow-hidden",
|
||||
"animate-in zoom-in-95 duration-200"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Keyboard className="w-5 h-5 text-muted-foreground" />
|
||||
<h2 id="shortcuts-dialog-title" className="text-lg font-semibold text-foreground">
|
||||
{t("shortcuts.title")}
|
||||
</h2>
|
||||
<div ref={contentRef} tabIndex={-1} className="outline-none">
|
||||
{apps.length > 1 && (
|
||||
<div role="tablist" aria-label={t("shortcuts.title")} className="mt-4 flex flex-wrap gap-1 border-b border-border">
|
||||
{apps.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${tabsId}-${a.id}`}
|
||||
aria-selected={a.id === selected}
|
||||
aria-controls={`${tabsId}-panel`}
|
||||
onClick={() => setSelected(a.id)}
|
||||
className={cn(
|
||||
"-mb-px px-3 py-2 text-sm border-b-2 transition-colors",
|
||||
a.id === selected
|
||||
? "border-primary text-foreground font-medium"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t(a.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
id={`${tabsId}-panel`}
|
||||
role={apps.length > 1 ? "tabpanel" : undefined}
|
||||
aria-labelledby={apps.length > 1 ? `${tabsId}-${selected}` : undefined}
|
||||
className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-8"
|
||||
>
|
||||
{sections.map((section) => {
|
||||
const rows = shown(section.entries);
|
||||
const extra = extraRows(section);
|
||||
if (rows.length === 0 && !extra?.length) return null;
|
||||
return (
|
||||
<section key={section.id} className={cn(section.wide && "md:col-span-2")}>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t(section.title)}
|
||||
</h3>
|
||||
<div className={cn(section.wide ? "grid grid-cols-1 md:grid-cols-2 gap-2" : "space-y-2")}>
|
||||
{rows.map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={`${shortcut.key}:${shortcut.description}`}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
{extra}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 md:p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Navigation Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.navigation")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{shown(KEYBOARD_SHORTCUTS.navigation).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.actions")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{shown(KEYBOARD_SHORTCUTS.actions).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Global Section */}
|
||||
<section className="md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.global")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{shown(KEYBOARD_SHORTCUTS.global).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Threads Section */}
|
||||
<section className="md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.threads")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{shown(KEYBOARD_SHORTCUTS.threads).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Composer Section */}
|
||||
<section className="md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.composer")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{shown(KEYBOARD_SHORTCUTS.composer).map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
{pluginComposerShortcuts.map((action) => (
|
||||
<ShortcutRow
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
shortcutKey={action.shortcut!}
|
||||
description={`${action.label} (${action.pluginName})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer tip */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t("shortcuts.tip")}
|
||||
</p>
|
||||
<p className="text-sm text-center mt-2">
|
||||
<button
|
||||
onClick={() => { onClose(); startTour(); }}
|
||||
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
{t("tour.take_a_tour")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{/* Footer tip */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t("shortcuts.tip")}
|
||||
</p>
|
||||
<p className="text-sm text-center mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClose(); startTour(); }}
|
||||
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
{t("tour.take_a_tour")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,8 +172,8 @@ function ShortcutRow({
|
||||
shortcutKey: string;
|
||||
description: string;
|
||||
}) {
|
||||
// Split keys by " / " to render multiple key badges
|
||||
const keys = shortcutKey.split(" / ");
|
||||
const t = useTranslations("shortcuts");
|
||||
const keys = shortcutAlternatives(shortcutKey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
@@ -199,7 +181,7 @@ function ShortcutRow({
|
||||
<div className="flex items-center gap-1.5 ms-4">
|
||||
{keys.map((key, index) => (
|
||||
<span key={index}>
|
||||
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
|
||||
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">{t("or")}</span>}
|
||||
<kbd
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useEffect, useCallback, useRef } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { keyboardHooks } from "@/lib/plugin-hooks";
|
||||
import type { InterfaceLayout } from "@/stores/settings-store";
|
||||
import type { FeatureGates } from "@/lib/admin/types";
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
// Navigation
|
||||
@@ -379,58 +377,7 @@ export function useKeyboardShortcuts({
|
||||
return { getAdjacentEmailIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the help modal. `layout` limits a row to one interface layout:
|
||||
* Mountain View adds the `g` sequences and gives `x` to selection.
|
||||
*/
|
||||
export interface ShortcutEntry {
|
||||
readonly key: string;
|
||||
readonly description: string;
|
||||
readonly layout?: InterfaceLayout;
|
||||
/** Listed only in the Pro shell, or only outside it. */
|
||||
readonly shell?: "pro" | "standard";
|
||||
/** Listed only while this admin policy feature gate is on. */
|
||||
readonly feature?: keyof FeatureGates;
|
||||
}
|
||||
|
||||
// Shortcut definitions for the help modal
|
||||
export const KEYBOARD_SHORTCUTS = {
|
||||
navigation: [
|
||||
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
|
||||
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
|
||||
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
|
||||
{ key: "Esc", description: "shortcuts.navigation.close_email" },
|
||||
{ key: "n", description: "shortcuts.navigation.next_unread" },
|
||||
{ key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" },
|
||||
],
|
||||
actions: [
|
||||
{ key: "r", description: "shortcuts.actions.reply" },
|
||||
{ key: "R / a", description: "shortcuts.actions.reply_all" },
|
||||
{ key: "f", description: "shortcuts.actions.forward" },
|
||||
{ key: "s", description: "shortcuts.actions.star" },
|
||||
{ key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
|
||||
{ key: "# / Del", description: "shortcuts.actions.delete" },
|
||||
{ key: "u", description: "shortcuts.actions.mark_unread" },
|
||||
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
|
||||
{ key: "!", description: "shortcuts.actions.toggle_spam" },
|
||||
],
|
||||
global: [
|
||||
{ key: "c", description: "shortcuts.global.compose" },
|
||||
{ key: "/", description: "shortcuts.global.search" },
|
||||
{ key: "Ctrl/⌘ + K", description: "shortcuts.global.global_search", shell: "pro" },
|
||||
{ key: "Ctrl/⌘ + K", description: "command_palette.open", shell: "standard" },
|
||||
{ key: "?", description: "shortcuts.global.help" },
|
||||
{ key: "Shift + G", description: "shortcuts.global.refresh" },
|
||||
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
|
||||
{ key: "Shift + ↓ / ↑", description: "shortcuts.global.extend_selection" },
|
||||
],
|
||||
threads: [
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
|
||||
{ key: "x", description: "shortcuts.threads.select_mountain_view", layout: "mountain-view" },
|
||||
],
|
||||
composer: [
|
||||
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
|
||||
{ key: "Ctrl/Cmd + Shift + Enter", description: "shortcuts.composer.schedule_send" },
|
||||
{ key: "t", description: "shortcuts.composer.template_picker" },
|
||||
],
|
||||
} as const;
|
||||
// The help modal's rows live per app in lib/keyboard-shortcuts; Mail's are
|
||||
// re-exported here under their old names.
|
||||
export type { ShortcutEntry } from "@/lib/keyboard-shortcuts";
|
||||
export { MAIL_SHORTCUTS as KEYBOARD_SHORTCUTS } from "@/lib/keyboard-shortcuts";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatShortcutCombo, shortcutAlternatives } from '../shortcut-keys';
|
||||
import { GENERAL_SHORTCUTS, SHORTCUT_APPS, sectionsWithGeneral, shortcutAppForPath } from '../keyboard-shortcuts';
|
||||
|
||||
describe('shortcut notation', () => {
|
||||
it('writes Mod as ⌘ on Apple devices and Ctrl elsewhere, Alt as ⌥', () => {
|
||||
expect(formatShortcutCombo('Mod + K', true)).toBe('⌘ + K');
|
||||
expect(formatShortcutCombo('Mod + K', false)).toBe('Ctrl + K');
|
||||
expect(formatShortcutCombo('Ctrl/⌘ + K', false)).toBe('Ctrl + K');
|
||||
expect(formatShortcutCombo('Ctrl/Cmd + Enter', true)).toBe('⌘ + Enter');
|
||||
expect(formatShortcutCombo('Alt + ↑', true)).toBe('⌥ + ↑');
|
||||
expect(formatShortcutCombo('Ctrl+Shift+Y', false)).toBe('Ctrl+Shift+Y');
|
||||
});
|
||||
|
||||
it('splits alternatives', () => {
|
||||
expect(shortcutAlternatives('j / ↓', false)).toEqual(['j', '↓']);
|
||||
expect(shortcutAlternatives('Mod + A', true)).toEqual(['⌘ + A']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcut sections', () => {
|
||||
it('knows the app of a path', () => {
|
||||
expect(shortcutAppForPath('/')).toBe('mail');
|
||||
expect(shortcutAppForPath('/mail/folder/inbox')).toBe('mail');
|
||||
expect(shortcutAppForPath('/calendar')).toBe('calendar');
|
||||
expect(shortcutAppForPath('/files/a/b')).toBe('files');
|
||||
expect(shortcutAppForPath('/de/contacts')).toBe('contacts');
|
||||
expect(shortcutAppForPath('/settings/appearance')).toBe('settings');
|
||||
});
|
||||
|
||||
it('adds the general keys to every app, once', () => {
|
||||
const mail = sectionsWithGeneral(SHORTCUT_APPS.find((a) => a.id === 'mail')!.sections);
|
||||
const global = mail.filter((s) => s.id === 'global');
|
||||
expect(global).toHaveLength(1);
|
||||
expect(global[0].entries).toEqual(expect.arrayContaining([...GENERAL_SHORTCUTS]));
|
||||
const empty = sectionsWithGeneral([]);
|
||||
expect(empty).toEqual([expect.objectContaining({ id: 'global', entries: GENERAL_SHORTCUTS })]);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,13 @@ export function openCommandPalette(): void {
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event(COMMAND_PALETTE_OPEN_EVENT));
|
||||
}
|
||||
|
||||
export const KEYBOARD_SHORTCUTS_OPEN_EVENT = 'bulwark:keyboard-shortcuts';
|
||||
|
||||
/** Open the keyboard shortcuts help, on the current app's section, like `?` does. */
|
||||
export function openKeyboardShortcuts(): void {
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event(KEYBOARD_SHORTCUTS_OPEN_EVENT));
|
||||
}
|
||||
|
||||
const EVENT = 'bulwark:app-command';
|
||||
let pending: AppCommand | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ShortcutSection } from "./types";
|
||||
|
||||
// calendar's keys for the help modal: one or more sections like MAIL_SECTIONS
|
||||
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
|
||||
// gets no tab.
|
||||
export const CALENDAR_SECTIONS: readonly ShortcutSection[] = [];
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ShortcutSection } from "./types";
|
||||
|
||||
// contacts's keys for the help modal: one or more sections like MAIL_SECTIONS
|
||||
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
|
||||
// gets no tab.
|
||||
export const CONTACTS_SECTIONS: readonly ShortcutSection[] = [];
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ShortcutSection } from "./types";
|
||||
|
||||
// files's keys for the help modal: one or more sections like MAIL_SECTIONS
|
||||
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
|
||||
// gets no tab.
|
||||
export const FILES_SECTIONS: readonly ShortcutSection[] = [];
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CALENDAR_SECTIONS } from "./calendar";
|
||||
import { CONTACTS_SECTIONS } from "./contacts";
|
||||
import { FILES_SECTIONS } from "./files";
|
||||
import { MAIL_SECTIONS } from "./mail";
|
||||
import { SETTINGS_SECTIONS } from "./settings";
|
||||
import type { ShortcutAppId, ShortcutEntry, ShortcutSection } from "./types";
|
||||
|
||||
export type { ShortcutAppId, ShortcutEntry, ShortcutSection } from "./types";
|
||||
export { MAIL_SHORTCUTS } from "./mail";
|
||||
|
||||
export interface ShortcutApp {
|
||||
readonly id: ShortcutAppId;
|
||||
/** Translation key of the tab. */
|
||||
readonly title: string;
|
||||
readonly sections: readonly ShortcutSection[];
|
||||
}
|
||||
|
||||
/** Each app's keys, in rail order. Each app fills its own file next to this one. */
|
||||
export const SHORTCUT_APPS: readonly ShortcutApp[] = [
|
||||
{ id: "mail", title: "sidebar.mail", sections: MAIL_SECTIONS },
|
||||
{ id: "calendar", title: "sidebar.calendar", sections: CALENDAR_SECTIONS },
|
||||
{ id: "contacts", title: "sidebar.contacts", sections: CONTACTS_SECTIONS },
|
||||
{ id: "files", title: "sidebar.files", sections: FILES_SECTIONS },
|
||||
{ id: "settings", title: "sidebar.settings", sections: SETTINGS_SECTIONS },
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that work in every app (the shell handles them). Listed under each
|
||||
* app's "global" section, or in a "Global" section of their own when the
|
||||
* app has none.
|
||||
*/
|
||||
export const GENERAL_SHORTCUTS: readonly ShortcutEntry[] = [
|
||||
{ key: "Mod + K", description: "shortcuts.global.global_search", shell: "pro" },
|
||||
{ key: "Mod + K", description: "command_palette.open", shell: "standard" },
|
||||
{ key: "?", description: "shortcuts.global.help" },
|
||||
];
|
||||
|
||||
/** An app's sections with the general keys added (see GENERAL_SHORTCUTS). */
|
||||
export function sectionsWithGeneral(sections: readonly ShortcutSection[]): ShortcutSection[] {
|
||||
if (sections.some((s) => s.id === "global")) {
|
||||
return sections.map((s) => (s.id === "global" ? { ...s, entries: [...s.entries, ...GENERAL_SHORTCUTS] } : s));
|
||||
}
|
||||
return [...sections, { id: "global", title: "shortcuts.sections.global", entries: GENERAL_SHORTCUTS, wide: true }];
|
||||
}
|
||||
|
||||
/** The app a path belongs to; everything that isn't another app is Mail. */
|
||||
export function shortcutAppForPath(pathname: string): ShortcutAppId {
|
||||
const path = pathname.replace(/^\/[a-z]{2}(-[A-Z]{2})?(?=\/|$)/, "");
|
||||
for (const id of ["calendar", "contacts", "files", "settings"] as const) {
|
||||
if (path === `/${id}` || path.startsWith(`/${id}/`)) return id;
|
||||
}
|
||||
return "mail";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ShortcutSection } from "./types";
|
||||
|
||||
// Mail's keys (hooks/use-keyboard-shortcuts.ts handles them).
|
||||
export const MAIL_SHORTCUTS = {
|
||||
navigation: [
|
||||
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
|
||||
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
|
||||
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
|
||||
{ key: "Esc", description: "shortcuts.navigation.close_email" },
|
||||
{ key: "n", description: "shortcuts.navigation.next_unread" },
|
||||
{ key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" },
|
||||
],
|
||||
actions: [
|
||||
{ key: "r", description: "shortcuts.actions.reply" },
|
||||
{ key: "R / a", description: "shortcuts.actions.reply_all" },
|
||||
{ key: "f", description: "shortcuts.actions.forward" },
|
||||
{ key: "s", description: "shortcuts.actions.star" },
|
||||
{ key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
|
||||
{ key: "# / Del", description: "shortcuts.actions.delete" },
|
||||
{ key: "u", description: "shortcuts.actions.mark_unread" },
|
||||
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
|
||||
{ key: "!", description: "shortcuts.actions.toggle_spam" },
|
||||
],
|
||||
// Shown under "Global" together with GENERAL_SHORTCUTS (./index.ts).
|
||||
global: [
|
||||
{ key: "c", description: "shortcuts.global.compose" },
|
||||
{ key: "/", description: "shortcuts.global.search" },
|
||||
{ key: "Shift + G", description: "shortcuts.global.refresh" },
|
||||
{ key: "Mod + A", description: "shortcuts.global.select_all" },
|
||||
{ key: "Shift + ↓ / ↑", description: "shortcuts.global.extend_selection" },
|
||||
],
|
||||
threads: [
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
|
||||
{ key: "x", description: "shortcuts.threads.select_mountain_view", layout: "mountain-view" },
|
||||
],
|
||||
composer: [
|
||||
{ key: "Mod + Enter", description: "shortcuts.composer.send" },
|
||||
{ key: "Mod + Shift + Enter", description: "shortcuts.composer.schedule_send" },
|
||||
{ key: "t", description: "shortcuts.composer.template_picker" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const MAIL_SECTIONS: readonly ShortcutSection[] = [
|
||||
{ id: "navigation", title: "shortcuts.sections.navigation", entries: MAIL_SHORTCUTS.navigation },
|
||||
{ id: "actions", title: "shortcuts.sections.actions", entries: MAIL_SHORTCUTS.actions },
|
||||
{ id: "global", title: "shortcuts.sections.global", entries: MAIL_SHORTCUTS.global, wide: true },
|
||||
{ id: "threads", title: "shortcuts.sections.threads", entries: MAIL_SHORTCUTS.threads, wide: true },
|
||||
{ id: "composer", title: "shortcuts.sections.composer", entries: MAIL_SHORTCUTS.composer, wide: true },
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ShortcutSection } from "./types";
|
||||
|
||||
// settings's keys for the help modal: one or more sections like MAIL_SECTIONS
|
||||
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
|
||||
// gets no tab.
|
||||
export const SETTINGS_SECTIONS: readonly ShortcutSection[] = [];
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { InterfaceLayout } from "@/stores/settings-store";
|
||||
import type { FeatureGates } from "@/lib/admin/types";
|
||||
|
||||
/**
|
||||
* One row of the help modal. `key` is written in the shortcut notation of
|
||||
* lib/shortcut-keys ("Mod + K", alternatives "j / ↓"); `description` is a
|
||||
* translation key. `layout` limits a row to one interface layout: Mountain
|
||||
* View adds the `g` sequences and gives `x` to selection.
|
||||
*/
|
||||
export interface ShortcutEntry {
|
||||
readonly key: string;
|
||||
readonly description: string;
|
||||
readonly layout?: InterfaceLayout;
|
||||
/** Listed only in the Pro shell, or only outside it. */
|
||||
readonly shell?: "pro" | "standard";
|
||||
/** Listed only while this admin policy feature gate is on. */
|
||||
readonly feature?: keyof FeatureGates;
|
||||
}
|
||||
|
||||
/** A titled group of rows. A section with no rows (after filtering) is not drawn. */
|
||||
export interface ShortcutSection {
|
||||
readonly id: string;
|
||||
/** Translation key of the heading. */
|
||||
readonly title: string;
|
||||
readonly entries: readonly ShortcutEntry[];
|
||||
/** Spans both columns of the modal. */
|
||||
readonly wide?: boolean;
|
||||
}
|
||||
|
||||
export type ShortcutAppId = "mail" | "calendar" | "contacts" | "files" | "settings";
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "اختصارات لوحة المفاتيح",
|
||||
"tip": "اضغط ؟ في أي وقت لإظهار هذه المساعدة",
|
||||
"or": "أو",
|
||||
"sections": {
|
||||
"navigation": "التنقل",
|
||||
"actions": "إجراءات البريد",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Dreceres de teclat",
|
||||
"tip": "Premeu ? en qualsevol moment per mostrar aquesta ajuda",
|
||||
"or": "o",
|
||||
"sections": {
|
||||
"navigation": "Navegació",
|
||||
"actions": "Accions de correu",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Klávesové zkratky",
|
||||
"tip": "Kdykoli stiskněte ? pro zobrazení této nápovědy",
|
||||
"or": "nebo",
|
||||
"sections": {
|
||||
"navigation": "Navigace",
|
||||
"actions": "Akce zprávy",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Tastaturgenveje",
|
||||
"tip": "Tryk ? når som helst for at se denne hjælp",
|
||||
"or": "eller",
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "E-mail-handlinger",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Tastaturkürzel",
|
||||
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
|
||||
"or": "oder",
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "E-Mail-Aktionen",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"tip": "Press ? anytime to show this help",
|
||||
"or": "or",
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Email Actions",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Atajos de Teclado",
|
||||
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
|
||||
"or": "o",
|
||||
"sections": {
|
||||
"navigation": "Navegación",
|
||||
"actions": "Acciones de Correo",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Kiirklahvid",
|
||||
"tip": "Selle abi nägemiseks vajuta igal ajal ?",
|
||||
"or": "või",
|
||||
"sections": {
|
||||
"navigation": "Navigeerimine",
|
||||
"actions": "E-kirja toimingud",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "میانبرهای صفحه کلید",
|
||||
"tip": "هر زمان کلید ? را فشار دهید",
|
||||
"or": "یا",
|
||||
"sections": {
|
||||
"navigation": "ناوبری",
|
||||
"actions": "عملیات ایمیل",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Raccourcis clavier",
|
||||
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
|
||||
"or": "ou",
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions email",
|
||||
|
||||
@@ -2621,6 +2621,7 @@
|
||||
"shortcuts": {
|
||||
"title": "קיצורי מקלדת",
|
||||
"tip": "לחץ על? בכל עת כדי להראות את העזרה הזו",
|
||||
"or": "או",
|
||||
"sections": {
|
||||
"navigation": "ניווט",
|
||||
"actions": "פעולות אימייל",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Billentyűparancsok",
|
||||
"tip": "Nyomd meg a ? gombot bármikor a súgó megjelenítéséhez",
|
||||
"or": "vagy",
|
||||
"sections": {
|
||||
"navigation": "Navigáció",
|
||||
"actions": "E-mail műveletek",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Scorciatoie da tastiera",
|
||||
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
|
||||
"or": "o",
|
||||
"sections": {
|
||||
"navigation": "Navigazione",
|
||||
"actions": "Azioni sui messaggi",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "キーボードショートカット",
|
||||
"tip": "? キーを押すといつでもこのヘルプを表示できます",
|
||||
"or": "または",
|
||||
"sections": {
|
||||
"navigation": "ナビゲーション",
|
||||
"actions": "メール操作",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "단축키",
|
||||
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
|
||||
"or": "또는",
|
||||
"sections": {
|
||||
"navigation": "이동",
|
||||
"actions": "메일 작업",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Īsinājumtaustiņi",
|
||||
"tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību",
|
||||
"or": "vai",
|
||||
"sections": {
|
||||
"navigation": "Navigācija",
|
||||
"actions": "Vēstules darbības",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Гарын товчлол",
|
||||
"tip": "Энэ тусламжийг хүссэн үедээ ? дарж нээж болно",
|
||||
"or": "эсвэл",
|
||||
"sections": {
|
||||
"navigation": "Навигаци",
|
||||
"actions": "Имэйлийн үйлдлүүд",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Tastatursnarveier",
|
||||
"tip": "Trykk på ? når som helst for å vise denne hjelpen",
|
||||
"or": "eller",
|
||||
"sections": {
|
||||
"navigation": "Navigasjon",
|
||||
"actions": "E-posthandlinger",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Sneltoetsen",
|
||||
"tip": "Druk op ? om deze hulp te tonen",
|
||||
"or": "of",
|
||||
"sections": {
|
||||
"navigation": "Navigatie",
|
||||
"actions": "E-mailacties",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Skróty klawiszowe",
|
||||
"tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc",
|
||||
"or": "lub",
|
||||
"sections": {
|
||||
"navigation": "Nawigacja",
|
||||
"actions": "Akcje wiadomości",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Atalhos de Teclado",
|
||||
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
|
||||
"or": "ou",
|
||||
"sections": {
|
||||
"navigation": "Navegação",
|
||||
"actions": "Ações de E-mail",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Comenzi rapide de la tastatură",
|
||||
"tip": "Apăsați ? oricând pentru a afișa acest ajutor",
|
||||
"or": "sau",
|
||||
"sections": {
|
||||
"navigation": "Navigare",
|
||||
"actions": "Acțiuni de e-mail",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Сочетания клавиш",
|
||||
"tip": "Нажмите ? в любое время для отображения справки",
|
||||
"or": "или",
|
||||
"sections": {
|
||||
"navigation": "Навигация",
|
||||
"actions": "Действия с письмом",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Klávesové skratky",
|
||||
"tip": "Kedykoľvek stlačte ? na zobrazenie tejto nápovedy",
|
||||
"or": "alebo",
|
||||
"sections": {
|
||||
"navigation": "Navigácia",
|
||||
"actions": "Akcie správy",
|
||||
|
||||
@@ -590,6 +590,7 @@
|
||||
"shortcuts": {
|
||||
"title": "แป้นพิมพ์ลัด",
|
||||
"tip": "กด ? ได้ทุกเมื่อเพื่อแสดงความช่วยเหลือนี้",
|
||||
"or": "หรือ",
|
||||
"sections": {
|
||||
"navigation": "การนำทาง",
|
||||
"actions": "การจัดการอีเมล",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Klavye Kısayolları",
|
||||
"tip": "Bu yardımı görmek için istediğiniz zaman ? tuşuna basın",
|
||||
"or": "veya",
|
||||
"sections": {
|
||||
"navigation": "Gezinme",
|
||||
"actions": "E-posta İşlemleri",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "Комбінації клавіш",
|
||||
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
|
||||
"or": "або",
|
||||
"sections": {
|
||||
"navigation": "Навігація",
|
||||
"actions": "Дії електронної пошти",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "鍵盤快速鍵",
|
||||
"tip": "隨時按下 ? 即可顯示此說明",
|
||||
"or": "或",
|
||||
"sections": {
|
||||
"navigation": "導覽",
|
||||
"actions": "郵件動作",
|
||||
|
||||
@@ -2703,6 +2703,7 @@
|
||||
"shortcuts": {
|
||||
"title": "键盘快捷键",
|
||||
"tip": "按?随时显示此帮助",
|
||||
"or": "或",
|
||||
"sections": {
|
||||
"navigation": "导航",
|
||||
"actions": "邮件操作",
|
||||
|
||||
Reference in New Issue
Block a user