diff --git a/web/src/lib/__tests__/keyboardSequence.test.ts b/web/src/lib/__tests__/keyboardSequence.test.ts new file mode 100644 index 0000000..dbb1dc0 --- /dev/null +++ b/web/src/lib/__tests__/keyboardSequence.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { keyboard } from "@/lib/keyboard"; + +/* + * Two-key sequences against the single keys they start with. + * + * "Go to folder" is `g o` while `o` on its own opens a conversation (#233), so + * the whole feature rests on a pending prefix being tried before a bare key. + * That was true when it was written and nothing said so out loud, which is the + * kind of thing a later refactor quietly reverses. + */ + +const press = (key: string) => { + const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + window.dispatchEvent(e); + return e; +}; + +let pop: (() => void) | null = null; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + pop?.(); + pop = null; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("a sequence sharing its second key with a single binding", () => { + it("runs the sequence, not the single key", () => { + const seq = vi.fn(); + const single = vi.fn(); + pop = keyboard.pushScope("t", [ + { keys: "g o", description: "Go to folder", group: "Navigation", handler: seq }, + { keys: "o", description: "Open", group: "Mail", handler: single }, + ]); + press("g"); + press("o"); + expect(seq).toHaveBeenCalledOnce(); + expect(single).not.toHaveBeenCalled(); + }); + + it("runs the single key when no prefix is pending", () => { + const seq = vi.fn(); + const single = vi.fn(); + pop = keyboard.pushScope("t", [ + { keys: "g o", description: "Go to folder", group: "Navigation", handler: seq }, + { keys: "o", description: "Open", group: "Mail", handler: single }, + ]); + press("o"); + expect(single).toHaveBeenCalledOnce(); + expect(seq).not.toHaveBeenCalled(); + }); + + it("forgets the prefix after a pause, so a later key means itself again", () => { + const seq = vi.fn(); + const single = vi.fn(); + pop = keyboard.pushScope("t", [ + { keys: "g o", description: "Go to folder", group: "Navigation", handler: seq }, + { keys: "o", description: "Open", group: "Mail", handler: single }, + ]); + press("g"); + vi.advanceTimersByTime(2000); + press("o"); + expect(seq).not.toHaveBeenCalled(); + expect(single).toHaveBeenCalledOnce(); + }); + + it("swallows the prefix rather than letting it act on its own", () => { + // `g` is not a binding by itself; pressing it must not fall through to + // anything, or holding it would type into the page. + const seq = vi.fn(); + pop = keyboard.pushScope("t", [ + { keys: "g o", description: "Go to folder", group: "Navigation", handler: seq }, + ]); + const e = press("g"); + expect(e.defaultPrevented).toBe(true); + expect(seq).not.toHaveBeenCalled(); + }); + + it("lets a key that completes no sequence still act as itself", () => { + /* + * `g` then `z`, where `g z` is nothing. The prefix is dropped and `z` runs + * on that same press rather than being eaten — so a mistyped prefix costs + * the prefix and not the keystroke after it. + */ + const seq = vi.fn(); + const single = vi.fn(); + pop = keyboard.pushScope("t", [ + { keys: "g o", description: "Go to folder", group: "Navigation", handler: seq }, + { keys: "z", description: "Zed", group: "Mail", handler: single }, + ]); + press("g"); + press("z"); + expect(seq).not.toHaveBeenCalled(); + expect(single).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 8fe687b..0c9bc74 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -55,6 +55,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "Zu Ordner springen…", "Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.", "Export iCAL file": "iCAL-Datei exportieren", "Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index d97c7cf..732c5b2 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -47,6 +47,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "Ir a la carpeta…", "Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.", "Export iCAL file": "Exportar archivo iCAL", "Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 179ee17..7aedf83 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -52,6 +52,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "Aller au dossier…", "Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.", "Export iCAL file": "Exporter un fichier iCAL", "Could not export this calendar: {error}": "Impossible d’exporter ce calendrier : {error}", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index dee7385..0a103fa 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -46,6 +46,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "フォルダーへ移動…", "Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。", "Export iCAL file": "iCAL ファイルをエクスポート", "Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 2d45772..7db3907 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "Ga naar map…", "Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.", "Export iCAL file": "iCAL-bestand exporteren", "Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index db29a4b..ccdb01d 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -50,6 +50,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "Ir para a pasta…", "Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.", "Export iCAL file": "Exportar arquivo iCAL", "Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index ee4a535..28a284d 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -49,6 +49,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "Перейти к папке…", "Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.", "Export iCAL file": "Экспортировать файл iCAL", "Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 2a1244c..d9cabd7 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -43,6 +43,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "Перейти до теки…", "Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.", "Export iCAL file": "Експортувати файл iCAL", "Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 3549075..540c1aa 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -45,6 +45,7 @@ import type { Catalog } from "@/lib/i18n"; */ export const catalog: Catalog = { strings: { + "Go to folder…": "转到文件夹…", "Set for everyone here. You cannot change this.": "已为此处所有人设定,您无法更改。", "Export iCAL file": "导出 iCAL 文件", "Could not export this calendar: {error}": "无法导出此日历:{error}", diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index a0c5cc7..ee809b0 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -15,6 +15,7 @@ import { FilesTree } from "./files/FilesTree"; import { ContactsSidebar } from "./contacts/ContactsSidebar"; import { CalendarSidebar } from "./calendar/CalendarSidebar"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; +import { MailboxPicker } from "./mail/MailboxPicker"; import { formatSize } from "@/lib/format"; import { TranslateBoundary } from "@/ui/TranslateBoundary"; import { t } from "@/lib/i18n"; @@ -37,9 +38,15 @@ export function AppShell({ children }: { children: ReactNode }) { const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); const acctMenu = useMenu(); + /* + * "Go to folder" (#233), hosted here rather than in the mail view because + * the `g` shortcuts are global: pressing it from the calendar should still + * take you to a folder, and the mail view is not mounted to hear about it. + */ + const [goFolder, setGoFolder] = useState(false); const section = location.split("/")[1] || "mail"; - useGlobalShortcuts({ onHelp: () => setHelpOpen(true) }); + useGlobalShortcuts({ onHelp: () => setHelpOpen(true), onGoToFolder: () => setGoFolder(true) }); useEffect(() => setDrawer(false), [location]); // Escape closes it too, for the tablet with a keyboard attached. @@ -220,6 +227,16 @@ export function AppShell({ children }: { children: ReactNode }) { )} setHelpOpen(false)} /> + {goFolder && ( + setGoFolder(false)} + onPick={(id) => { setGoFolder(false); navigate(`/mail/${id}`); }} + /> + )} ); } diff --git a/web/src/views/Shortcuts.tsx b/web/src/views/Shortcuts.tsx index 87d9729..22bcd34 100644 --- a/web/src/views/Shortcuts.tsx +++ b/web/src/views/Shortcuts.tsx @@ -7,7 +7,7 @@ import { Dialog } from "@/ui/dialog"; import { Kbd } from "@/ui/misc"; import { t } from "@/lib/i18n"; -export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) { +export function useGlobalShortcuts({ onHelp, onGoToFolder }: { onHelp: () => void; onGoToFolder: () => void }) { const [, navigate] = useLocation(); useEffect(() => { const go = (role: string) => () => { @@ -18,6 +18,15 @@ export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) { { keys: "c", description: "Compose new message", group: "Mail", handler: () => void useCompose.getState().open() }, { keys: "?", description: "Show keyboard shortcuts", group: "Navigation", handler: onHelp }, { keys: "g i", description: "Go to Inbox", group: "Navigation", handler: go("inbox") }, + /* + * A folder by name, for a tree the other `g` shortcuts cannot reach. The + * ones below are the handful of folders every account has; this is for + * the dozens that Sieve fills and that have no letter of their own (#233). + * + * `o` on its own opens a conversation, which is not a clash: the manager + * completes a pending sequence before it tries a single key. + */ + { keys: "g o", description: "Go to folder…", group: "Navigation", handler: onGoToFolder }, { keys: "g s", description: "Go to Starred", group: "Navigation", handler: () => navigate("/search?q=is:starred") }, { keys: "g t", description: "Go to Sent", group: "Navigation", handler: go("sent") }, { keys: "g d", description: "Go to Drafts", group: "Navigation", handler: go("drafts") }, @@ -27,7 +36,7 @@ export function useGlobalShortcuts({ onHelp }: { onHelp: () => void }) { { keys: "g f", description: "Go to Files", group: "Navigation", handler: () => navigate("/files") }, { keys: "g k", description: "Go to Settings", group: "Navigation", handler: () => navigate("/settings") }, ]); - }, [navigate, onHelp]); + }, [navigate, onHelp, onGoToFolder]); } export function ShortcutsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { diff --git a/web/src/views/mail/MailboxPicker.tsx b/web/src/views/mail/MailboxPicker.tsx index 8bd9630..2e7385c 100644 --- a/web/src/views/mail/MailboxPicker.tsx +++ b/web/src/views/mail/MailboxPicker.tsx @@ -6,14 +6,22 @@ import type { Id, Mailbox } from "@/jmap/types"; import { t } from "@/lib/i18n"; import { mailboxDisplayPath } from "@/lib/mailboxName"; -export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) { +/** + * @param need which right a folder has to grant to be worth offering. + * `mayAddItems` for a move — a folder you cannot file into is not a + * destination — and `mayReadItems` for going somewhere, since a shared + * folder you may read but not write to is still somewhere you can go. The + * distinction only shows up on shared mail, which is exactly where getting + * it wrong would be invisible to whoever wrote the code. + */ +export function MailboxPicker({ title, onClose, onPick, exclude, need = "mayAddItems" }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[]; need?: "mayAddItems" | "mayReadItems" }) { const mailboxes = useMail((s) => s.mailboxes); const mailboxPath = useMail((s) => s.mailboxPath); const [q, setQ] = useState(""); const [active, setActive] = useState(0); const list = useMemo(() => { const all = Object.values(mailboxes) - .filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems) + .filter((m) => !exclude?.includes(m.id) && m.myRights[need]) .map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes) })) .sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path))); const ql = q.trim().toLowerCase();