feat: jump to the next unread message with n #514

This commit is contained in:
Linus Rath
2026-10-17 19:18:20 +02:00
parent b611d9c028
commit 03031fcb13
32 changed files with 207 additions and 28 deletions
+12
View File
@@ -53,6 +53,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
import { findNextUnreadIndex } from "@/lib/next-unread-email";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
@@ -849,6 +850,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (mvShell) setCursorOnly(true);
}
},
// `n`: open the next unread message below the current one, in the order
// the list has loaded. Only the loaded rows count; say so when none is
// left instead of wrapping to the top. (#514)
onNextUnread: () => {
const nextIndex = findNextUnreadIndex(activeEmails, selectedEmail?.id);
if (nextIndex === -1) {
toast.info(t('shortcuts.navigation.no_more_unread'));
return;
}
void handleEmailSelect(activeEmails[nextIndex]);
},
onOpenEmail: () => {
// Outside Mountain View the email is already open by virtue of being
// selected; under it this is the step that promotes cursor to open.
+41 -1
View File
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useKeyboardShortcuts } from '../use-keyboard-shortcuts';
import { useKeyboardShortcuts, KEYBOARD_SHORTCUTS } from '../use-keyboard-shortcuts';
// #683: the help modal advertises `x` for thread expansion and the hook binds
// it; the handler must actually be invoked when a message is selected.
@@ -90,3 +90,43 @@ describe('useKeyboardShortcuts: Shift + arrow extends the selection', () => {
expect(onExtendSelection).not.toHaveBeenCalled();
});
});
// #514: `n` jumps to the next unread message, with or without one open.
describe('useKeyboardShortcuts: n opens the next unread message', () => {
function pressN(shiftKey = false) {
document.dispatchEvent(new KeyboardEvent('keydown', { key: shiftKey ? 'N' : 'n', code: 'KeyN', shiftKey, bubbles: true }));
}
it('calls onNextUnread with a message open', () => {
const onNextUnread = vi.fn();
renderHook(() =>
useKeyboardShortcuts({ enabled: true, emails: [], selectedEmailId: 'e1', handlers: { onNextUnread } }),
);
pressN();
expect(onNextUnread).toHaveBeenCalledTimes(1);
});
it('calls onNextUnread with nothing open', () => {
const onNextUnread = vi.fn();
renderHook(() => useKeyboardShortcuts({ enabled: true, emails: [], handlers: { onNextUnread } }));
pressN();
expect(onNextUnread).toHaveBeenCalledTimes(1);
});
it('ignores Shift + N', () => {
const onNextUnread = vi.fn();
renderHook(() => useKeyboardShortcuts({ enabled: true, emails: [], handlers: { onNextUnread } }));
pressN(true);
expect(onNextUnread).not.toHaveBeenCalled();
});
it('is listed in the help modal under navigation', () => {
expect(KEYBOARD_SHORTCUTS.navigation).toContainEqual({ key: 'n', description: 'shortcuts.navigation.next_unread' });
});
});
+11
View File
@@ -12,6 +12,8 @@ export interface KeyboardShortcutHandlers {
onPreviousEmail?: () => void;
onOpenEmail?: () => void;
onCloseEmail?: () => void;
/** `n`: open the next unread message below the current one (#514). */
onNextUnread?: () => void;
// Email actions
onReply?: () => void;
@@ -319,6 +321,14 @@ export function useKeyboardShortcuts({
run("toggle-thread-expansion", h.onToggleThreadExpansion);
}
break;
// Navigation: next unread, as in Thunderbird (#514)
case "n":
if (!event.shiftKey) {
event.preventDefault();
run("next-unread", h.onNextUnread);
}
break;
}
},
[selectedEmailId, selectionCount, mountainViewKeys]
@@ -387,6 +397,7 @@ export const KEYBOARD_SHORTCUTS = {
{ 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: [
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { findNextUnreadIndex } from '../next-unread-email';
// #514: `n` opens the next unread message below the current one.
const mail = (id: string, seen: boolean): { id: string; keywords: Record<string, boolean> } =>
({ id, keywords: seen ? { $seen: true } : {} });
describe('findNextUnreadIndex', () => {
const list = [mail('a', false), mail('b', true), mail('c', true), mail('d', false), mail('e', true)];
it('finds the first unread message after the current one', () => {
expect(findNextUnreadIndex(list, 'b')).toBe(3);
});
it('skips the current message even when it is unread', () => {
expect(findNextUnreadIndex(list, 'a')).toBe(3);
});
it('starts at the top when nothing is open', () => {
expect(findNextUnreadIndex(list, undefined)).toBe(0);
expect(findNextUnreadIndex(list, null)).toBe(0);
});
it('starts at the top when the open message is not in the list', () => {
expect(findNextUnreadIndex(list, 'gone')).toBe(0);
});
it('does not wrap around to unread mail above', () => {
expect(findNextUnreadIndex(list, 'd')).toBe(-1);
expect(findNextUnreadIndex(list, 'e')).toBe(-1);
});
it('treats a message without keywords as unread', () => {
const rows = [mail('a', true), { id: 'b' } as { id: string; keywords: Record<string, boolean> }];
expect(findNextUnreadIndex(rows, 'a')).toBe(1);
});
it('returns -1 for an empty or all-read list', () => {
expect(findNextUnreadIndex([], undefined)).toBe(-1);
expect(findNextUnreadIndex([mail('a', true), mail('b', true)], undefined)).toBe(-1);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { Email } from "@/lib/jmap/types";
/**
* Index of the first unread message after `currentId` in the loaded list, or
* -1 when there is none below it. With nothing open (or the open message no
* longer in the list) the search starts at the top. It does not wrap around:
* unread mail above the current one is left for the user to scroll back to.
* (#514)
*/
export function findNextUnreadIndex(
emails: readonly Pick<Email, "id" | "keywords">[],
currentId: string | null | undefined,
): number {
const currentIndex = currentId ? emails.findIndex((e) => e.id === currentId) : -1;
for (let i = currentIndex + 1; i < emails.length; i++) {
if (!emails[i].keywords?.$seen) return i;
}
return -1;
}
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "الرسالة السابقة",
"open_email": "فتح الرسالة",
"close_email": "إغلاق / إلغاء التحديد",
"go_to_folder": "الانتقال إلى المجلد"
"go_to_folder": "الانتقال إلى المجلد",
"next_unread": "الرسالة غير المقروءة التالية",
"no_more_unread": "لا توجد رسائل غير مقروءة أسفل القائمة"
},
"actions": {
"reply": "رد",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Correu anterior",
"open_email": "Obre el correu",
"close_email": "Tanca / Desselecciona",
"go_to_folder": "Vés a la carpeta"
"go_to_folder": "Vés a la carpeta",
"next_unread": "Següent correu no llegit",
"no_more_unread": "No hi ha més correus no llegits més avall a la llista"
},
"actions": {
"reply": "Respon",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Předchozí zpráva",
"open_email": "Otevřít zprávu",
"close_email": "Zavřít / Zrušit výběr",
"go_to_folder": "Přejít do složky"
"go_to_folder": "Přejít do složky",
"next_unread": "Další nepřečtený e-mail",
"no_more_unread": "Níže v seznamu už nejsou žádné nepřečtené e-maily"
},
"actions": {
"reply": "Odpovědět",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Forrige e-mail",
"open_email": "Åbn e-mail",
"close_email": "Luk / Fravaælg",
"go_to_folder": "Gå til mappe"
"go_to_folder": "Gå til mappe",
"next_unread": "Næste ulæste e-mail",
"no_more_unread": "Der er ikke flere ulæste e-mails længere nede på listen"
},
"actions": {
"reply": "Svar",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Vorherige E-Mail",
"open_email": "E-Mail öffnen",
"close_email": "Schließen / Auswahl aufheben",
"go_to_folder": "Zum Ordner springen"
"go_to_folder": "Zum Ordner springen",
"next_unread": "Nächste ungelesene E-Mail",
"no_more_unread": "Weiter unten in der Liste gibt es keine ungelesenen E-Mails"
},
"actions": {
"reply": "Antworten",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Previous email",
"open_email": "Open email",
"close_email": "Close / Deselect",
"go_to_folder": "Go to folder"
"go_to_folder": "Go to folder",
"next_unread": "Next unread email",
"no_more_unread": "No unread email further down the list"
},
"actions": {
"reply": "Reply",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Correo anterior",
"open_email": "Abrir correo",
"close_email": "Cerrar / Deseleccionar",
"go_to_folder": "Ir a la carpeta"
"go_to_folder": "Ir a la carpeta",
"next_unread": "Siguiente correo no leído",
"no_more_unread": "No hay más correos no leídos más abajo en la lista"
},
"actions": {
"reply": "Responder",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "ایمیل قبلی",
"open_email": "باز کردن ایمیل",
"close_email": "بستن / لغو انتخاب",
"go_to_folder": "رفتن به پوشه"
"go_to_folder": "رفتن به پوشه",
"next_unread": "ایمیل خوانده‌نشده بعدی",
"no_more_unread": "ایمیل خوانده‌نشده‌ای پایین‌تر در فهرست نیست"
},
"actions": {
"reply": "پاسخ",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Email précédent",
"open_email": "Ouvrir l'email",
"close_email": "Fermer / Désélectionner",
"go_to_folder": "Aller au dossier"
"go_to_folder": "Aller au dossier",
"next_unread": "E-mail non lu suivant",
"no_more_unread": "Aucun e-mail non lu plus bas dans la liste"
},
"actions": {
"reply": "Répondre",
+3 -1
View File
@@ -2533,7 +2533,9 @@
"previous_email": "אימייל קודם",
"open_email": "פתח אימייל",
"close_email": "סגור / בטל בחירה",
"go_to_folder": "מעבר לתיקייה"
"go_to_folder": "מעבר לתיקייה",
"next_unread": "ההודעה הבאה שלא נקראה",
"no_more_unread": "אין הודעות שלא נקראו בהמשך הרשימה"
},
"actions": {
"reply": "השב",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Előző e-mail",
"open_email": "E-mail megnyitása",
"close_email": "Bezárás / Kijelölés megszüntetése",
"go_to_folder": "Ugrás mappára"
"go_to_folder": "Ugrás mappára",
"next_unread": "Következő olvasatlan e-mail",
"no_more_unread": "Lejjebb a listában nincs több olvasatlan e-mail"
},
"actions": {
"reply": "Válasz",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Messaggio precedente",
"open_email": "Apri messaggio",
"close_email": "Chiudi / Deseleziona",
"go_to_folder": "Vai alla cartella"
"go_to_folder": "Vai alla cartella",
"next_unread": "Prossima email non letta",
"no_more_unread": "Nessuna email non letta più in basso nell'elenco"
},
"actions": {
"reply": "Rispondi",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "前のメール",
"open_email": "メールを開く",
"close_email": "閉じる / 選択解除",
"go_to_folder": "フォルダーに移動"
"go_to_folder": "フォルダーに移動",
"next_unread": "次の未読メール",
"no_more_unread": "リストのこれより下に未読メールはありません"
},
"actions": {
"reply": "返信",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "이전 메일",
"open_email": "메일 열기",
"close_email": "닫기 / 선택 해제",
"go_to_folder": "폴더로 이동"
"go_to_folder": "폴더로 이동",
"next_unread": "다음 읽지 않은 메일",
"no_more_unread": "목록 아래쪽에 읽지 않은 메일이 더 없습니다"
},
"actions": {
"reply": "답장",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Iepriekšējā vēstule",
"open_email": "Atvērt vēstuli",
"close_email": "Aizvērt / Noņemt atlasi",
"go_to_folder": "Pāriet uz mapi"
"go_to_folder": "Pāriet uz mapi",
"next_unread": "Nākamais nelasītais e-pasts",
"no_more_unread": "Sarakstā zemāk vairs nav nelasītu e-pastu"
},
"actions": {
"reply": "Atbildēt",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Өмнөх имэйл",
"open_email": "Имэйл нээх",
"close_email": "Хаах / Сонголтыг цуцлах",
"go_to_folder": "Хавтас руу очих"
"go_to_folder": "Хавтас руу очих",
"next_unread": "Дараагийн уншаагүй имэйл",
"no_more_unread": "Жагсаалтын доод хэсэгт уншаагүй имэйл алга"
},
"actions": {
"reply": "Хариулах",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Forrige e-post",
"open_email": "Åpne e-post",
"close_email": "Lukk / fjern valget",
"go_to_folder": "Gå til mappe"
"go_to_folder": "Gå til mappe",
"next_unread": "Neste uleste e-post",
"no_more_unread": "Det er ingen flere uleste e-poster lenger ned i listen"
},
"actions": {
"reply": "Svar",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Vorige e-mail",
"open_email": "E-mail openen",
"close_email": "Sluiten / Deselecteren",
"go_to_folder": "Ga naar map"
"go_to_folder": "Ga naar map",
"next_unread": "Volgende ongelezen e-mail",
"no_more_unread": "Verderop in de lijst staan geen ongelezen e-mails meer"
},
"actions": {
"reply": "Beantwoorden",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Poprzednia wiadomość",
"open_email": "Otwórz wiadomość",
"close_email": "Zamknij / Odznacz",
"go_to_folder": "Przejdź do folderu"
"go_to_folder": "Przejdź do folderu",
"next_unread": "Następna nieprzeczytana wiadomość",
"no_more_unread": "Niżej na liście nie ma już nieprzeczytanych wiadomości"
},
"actions": {
"reply": "Odpowiedz",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "E-mail anterior",
"open_email": "Abrir e-mail",
"close_email": "Fechar / Desselecionar",
"go_to_folder": "Ir para a pasta"
"go_to_folder": "Ir para a pasta",
"next_unread": "Próximo e-mail não lido",
"no_more_unread": "Não há mais e-mails não lidos mais abaixo na lista"
},
"actions": {
"reply": "Responder",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "E-mailul anterior",
"open_email": "Deschide e-mailul",
"close_email": "Închide / Deselectează",
"go_to_folder": "Mergi la dosar"
"go_to_folder": "Mergi la dosar",
"next_unread": "Următorul e-mail necitit",
"no_more_unread": "Nu mai sunt e-mailuri necitite mai jos în listă"
},
"actions": {
"reply": "Răspunde",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Предыдущее письмо",
"open_email": "Открыть письмо",
"close_email": "Закрыть / Отменить выбор",
"go_to_folder": "Перейти к папке"
"go_to_folder": "Перейти к папке",
"next_unread": "Следующее непрочитанное письмо",
"no_more_unread": "Ниже в списке больше нет непрочитанных писем"
},
"actions": {
"reply": "Ответить",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Predchádzajúci e-mail",
"open_email": "Otvoriť e-mail",
"close_email": "Zavrieť / Zrušiť výber",
"go_to_folder": "Prejsť do priečinka"
"go_to_folder": "Prejsť do priečinka",
"next_unread": "Ďalší neprečítaný e-mail",
"no_more_unread": "Nižšie v zozname už nie sú žiadne neprečítané e-maily"
},
"actions": {
"reply": "Odpovedať",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Önceki e-posta",
"open_email": "E-postayı aç",
"close_email": "Kapat / Seçimi kaldır",
"go_to_folder": "Klasöre git"
"go_to_folder": "Klasöre git",
"next_unread": "Sonraki okunmamış e-posta",
"no_more_unread": "Listenin aşağısında okunmamış e-posta yok"
},
"actions": {
"reply": "Yanıtla",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "Попередній електронний лист",
"open_email": "Відкрийте електронну пошту",
"close_email": "Закрити / Скасувати вибір",
"go_to_folder": "Перейти до теки"
"go_to_folder": "Перейти до теки",
"next_unread": "Наступний непрочитаний лист",
"no_more_unread": "Нижче в списку більше немає непрочитаних листів"
},
"actions": {
"reply": "Відповісти",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "上一封郵件",
"open_email": "開啟郵件",
"close_email": "關閉/取消選取",
"go_to_folder": "跳至資料夾"
"go_to_folder": "跳至資料夾",
"next_unread": "下一封未讀郵件",
"no_more_unread": "清單下方沒有更多未讀郵件"
},
"actions": {
"reply": "回覆",
+3 -1
View File
@@ -2615,7 +2615,9 @@
"previous_email": "上一封邮件",
"open_email": "打开邮件",
"close_email": "关闭/取消选择",
"go_to_folder": "跳转到文件夹"
"go_to_folder": "跳转到文件夹",
"next_unread": "下一封未读邮件",
"no_more_unread": "列表下方没有更多未读邮件"
},
"actions": {
"reply": "回复",