From b1dad2e2543c151b15d46e83e5979f4cfcac7ca7 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Wed, 14 May 2025 13:16:50 +0100
Subject: [PATCH] feat: implement keyboard shortcuts for mail categories and
improve selection modes
---
.../context/command-palette-context.tsx | 4 +
apps/mail/components/mail/mail-display.tsx | 24 ++++++
apps/mail/components/mail/mail-list.tsx | 73 +++++++++++++++----
apps/mail/components/mail/mail.tsx | 10 ++-
apps/mail/config/navigation.ts | 12 +--
apps/mail/config/shortcuts.ts | 56 ++++++++++++--
apps/mail/hooks/use-hot-key.ts | 34 +++++++++
apps/mail/lib/hotkeys/mail-list-hotkeys.tsx | 28 +++++++
apps/mail/lib/hotkeys/navigation-hotkeys.tsx | 2 +-
apps/mail/locales/en.json | 3 +-
10 files changed, 216 insertions(+), 30 deletions(-)
diff --git a/apps/mail/components/context/command-palette-context.tsx b/apps/mail/components/context/command-palette-context.tsx
index 28379e398..58f21d35a 100644
--- a/apps/mail/components/context/command-palette-context.tsx
+++ b/apps/mail/components/context/command-palette-context.tsx
@@ -37,6 +37,7 @@ type CommandItem = {
onClick?: () => unknown;
shortcut?: string;
isBackButton?: boolean;
+ disabled?: boolean;
};
const CommandPaletteContext = React.createContext(null);
@@ -86,6 +87,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
mailCommands.push({
title: 'common.commandPalette.commands.composeMessage',
icon: Pencil2,
+ shortcut: 'c',
onClick: () => {
setIsComposeOpen('true');
},
@@ -96,12 +98,14 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
section?.sections.forEach((group) => {
group.items.forEach((navItem) => {
+ if (navItem.disabled) return;
const item: CommandItem = {
title: navItem.title,
icon: navItem.icon,
url: navItem.url,
shortcut: navItem.shortcut,
isBackButton: navItem.isBackButton,
+ disabled: navItem.disabled,
};
if (sectionKey === 'mail') {
diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx
index 82e9d44fa..4f4f9e63f 100644
--- a/apps/mail/components/mail/mail-display.tsx
+++ b/apps/mail/components/mail/mail-display.tsx
@@ -821,6 +821,14 @@ const MailDisplay = ({ emailData, index, totalEmails, demo }: Props) => {
Reply
+
+ r
+
diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx
index f63ea7278..f28883894 100644
--- a/apps/mail/components/mail/mail-list.tsx
+++ b/apps/mail/components/mail/mail-list.tsx
@@ -611,11 +611,7 @@ const Thread = memo(
{latestMessage.to.map((e) => e.email).join(', ')}
) : (
-
+
{highlightText(latestMessage.subject, searchValue.highlight)}
)}
@@ -773,15 +769,27 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
const isKeyPressed = useKeyState();
const getSelectMode = useCallback((): MailSelectMode => {
+ // Check for Alt key using both 'Alt' and 'AltLeft'/'AltRight' for better browser compatibility
+ const isAltPressed = isKeyPressed('Alt') || isKeyPressed('AltLeft') || isKeyPressed('AltRight');
+
+ // Check for Shift key using both 'Shift' and 'ShiftLeft'/'ShiftRight'
+ const isShiftPressed =
+ isKeyPressed('Shift') || isKeyPressed('ShiftLeft') || isKeyPressed('ShiftRight');
+
if (isKeyPressed('Control') || isKeyPressed('Meta')) {
return 'mass';
}
- if (isKeyPressed('Shift')) {
- return 'range';
- }
- if (isKeyPressed('Alt') && isKeyPressed('Shift')) {
+
+ // Check for Alt+Shift combination first (higher priority)
+ if (isAltPressed && isShiftPressed) {
+ console.log('Select All Below mode activated'); // Debug log
return 'selectAllBelow';
}
+
+ if (isShiftPressed) {
+ return 'range';
+ }
+
return 'single';
}, [isKeyPressed]);
@@ -791,26 +799,63 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
const handleSelectMail = useCallback(
(message: ParsedMessage) => {
const itemId = message.threadId ?? message.id;
- switch (getSelectMode()) {
+ const currentMode = getSelectMode();
+ console.log('Selection mode:', currentMode, 'for item:', itemId);
+
+ switch (currentMode) {
case 'mass': {
const newSelected = mail.bulkSelected.includes(itemId)
? mail.bulkSelected.filter((id) => id !== itemId)
: [...mail.bulkSelected, itemId];
+ console.log('Mass selection mode - selected items:', newSelected.length);
return setMail({ ...mail, bulkSelected: newSelected });
}
+ case 'selectAllBelow': {
+ // Find the index of the clicked item
+ const clickedIndex = items.findIndex((item) => item.id === itemId);
+ console.log(
+ 'SelectAllBelow - clicked index:',
+ clickedIndex,
+ 'total items:',
+ items.length,
+ );
+
+ // Select all items from the clicked one to the end of the list
+ if (clickedIndex !== -1) {
+ const itemsBelow = items.slice(clickedIndex);
+ const idsBelow = itemsBelow.map((item) => item.id);
+ console.log('Selecting all items below - count:', idsBelow.length);
+ return setMail({ ...mail, bulkSelected: idsBelow });
+ }
+ console.log('Item not found in list, selecting just this item');
+ return setMail({ ...mail, bulkSelected: [itemId] });
+ }
+ case 'range': {
+ // For future implementation of range selection
+ console.log('Range selection mode - not fully implemented');
+ return setMail({ ...mail, bulkSelected: [itemId] });
+ }
+ default: {
+ console.log('Single selection mode');
+ return setMail({ ...mail, bulkSelected: [itemId] });
+ }
}
- setMail({ ...mail, bulkSelected: [message.threadId ?? message.id] });
},
- [mail, setMail, getSelectMode],
+ [mail, setMail, getSelectMode, items],
);
const [, setFocusedIndex] = useAtom(focusedIndexAtom);
const handleMailClick = useCallback(
(message: ParsedMessage) => () => {
- if (getSelectMode() !== 'single') {
+ // Log the current selection mode for debugging
+ const mode = getSelectMode();
+ console.log('Mail click with mode:', mode);
+
+ if (mode !== 'single') {
return handleSelectMail(message);
}
+
handleMouseEnter(message.id);
const messageThreadId = message.threadId ?? message.id;
@@ -822,7 +867,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
void setDraftId(null);
void setActiveReplyId(null);
},
- [mail, items, setFocusedIndex],
+ [mail, items, setFocusedIndex, getSelectMode, handleSelectMail],
);
const isFiltering = searchValue.value.trim().length > 0;
diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx
index 819a87985..84d8d4634 100644
--- a/apps/mail/components/mail/mail.tsx
+++ b/apps/mail/components/mail/mail.tsx
@@ -810,7 +810,15 @@ function CategorySelect({ isMultiSelectMode }: { isMultiSelectMode: boolean }) {
{!isSelected && (
- {cat.name}
+ {cat.name}
+
+ {idx ? idx + 1 : ''}
+
)}
diff --git a/apps/mail/config/navigation.ts b/apps/mail/config/navigation.ts
index e6bbf8afc..281bc599b 100644
--- a/apps/mail/config/navigation.ts
+++ b/apps/mail/config/navigation.ts
@@ -58,21 +58,21 @@ export const navigationConfig: Record = {
title: 'navigation.sidebar.inbox',
url: '/mail/inbox',
icon: Inbox,
- shortcut: "g + i",
+ shortcut: 'g + i',
},
{
id: 'drafts',
title: 'navigation.sidebar.drafts',
url: '/mail/draft',
icon: Folder,
- shortcut: "g + d",
+ shortcut: 'g + d',
},
{
id: 'sent',
title: 'navigation.sidebar.sent',
url: '/mail/sent',
icon: Plane2,
- shortcut: "g + t",
+ shortcut: 'g + t',
},
],
},
@@ -84,21 +84,19 @@ export const navigationConfig: Record = {
title: 'navigation.sidebar.archive',
url: '/mail/archive',
icon: Archive,
- shortcut: "g + a",
+ shortcut: 'g + a',
},
{
id: 'spam',
title: 'navigation.sidebar.spam',
url: '/mail/spam',
icon: ExclamationCircle,
- shortcut: "g + s",
},
{
id: 'trash',
title: 'navigation.sidebar.bin',
url: '/mail/bin',
icon: Bin,
- shortcut: "g + b",
},
],
},
@@ -150,6 +148,7 @@ export const navigationConfig: Record = {
title: 'navigation.settings.general',
url: '/settings/general',
icon: SettingsGear,
+ shortcut: 'g + s',
},
{
title: 'navigation.settings.connections',
@@ -181,6 +180,7 @@ export const navigationConfig: Record = {
title: 'navigation.settings.shortcuts',
url: '/settings/shortcuts',
icon: Tabs,
+ shortcut: '?',
},
// {
// title: 'navigation.settings.signatures',
diff --git a/apps/mail/config/shortcuts.ts b/apps/mail/config/shortcuts.ts
index 1bb58590e..5269caec9 100644
--- a/apps/mail/config/shortcuts.ts
+++ b/apps/mail/config/shortcuts.ts
@@ -79,13 +79,13 @@ const navigation: Shortcut[] = [
description: 'Go to sent mail',
scope: 'navigation',
},
- // {
- // keys: ['g', 's'],
- // action: 'goToSpam',
- // type: 'combination',
- // description: 'Go to spam',
- // scope: 'navigation',
- // },
+ {
+ keys: ['g', 's'],
+ action: 'goToSettings',
+ type: 'combination',
+ description: 'Go to general settings',
+ scope: 'navigation',
+ },
{
keys: ['g', 'a'],
action: 'goToArchive',
@@ -228,6 +228,48 @@ const mailListShortcuts: Shortcut[] = [
// description: 'Scroll up',
// scope: 'mail-list',
// },
+ {
+ keys: ['1'],
+ action: 'showImportant',
+ type: 'single',
+ description: 'Show important',
+ scope: 'mail-list',
+ },
+ {
+ keys: ['2'],
+ action: 'showAllMail',
+ type: 'single',
+ description: 'Show all mail',
+ scope: 'mail-list',
+ },
+ {
+ keys: ['3'],
+ action: 'showPersonal',
+ type: 'single',
+ description: 'Show personal',
+ scope: 'mail-list',
+ },
+ {
+ keys: ['4'],
+ action: 'showUpdates',
+ type: 'single',
+ description: 'Show updates',
+ scope: 'mail-list',
+ },
+ {
+ keys: ['5'],
+ action: 'showPromotions',
+ type: 'single',
+ description: 'Show promotions',
+ scope: 'mail-list',
+ },
+ {
+ keys: ['6'],
+ action: 'showUnread',
+ type: 'single',
+ description: 'Show unread',
+ scope: 'mail-list',
+ },
];
const composeShortcuts: Shortcut[] = [
diff --git a/apps/mail/hooks/use-hot-key.ts b/apps/mail/hooks/use-hot-key.ts
index c9f83f48c..94d5d464d 100644
--- a/apps/mail/hooks/use-hot-key.ts
+++ b/apps/mail/hooks/use-hot-key.ts
@@ -7,17 +7,51 @@ let listenersInit = false;
function initKeyListeners() {
if (typeof window !== 'undefined' && !listenersInit) {
window.addEventListener('keydown', (e) => {
+ // Store the key state
keyStates.set(e.key, true);
+
+ // Also store specific states for modifier keys
+ if (e.altKey) {
+ keyStates.set('Alt', true);
+ keyStates.set('AltLeft', true);
+ keyStates.set('AltRight', true);
+ }
+
+ if (e.shiftKey) {
+ keyStates.set('Shift', true);
+ keyStates.set('ShiftLeft', true);
+ keyStates.set('ShiftRight', true);
+ }
+
+ console.log('Key down:', e.key, 'Alt:', e.altKey, 'Shift:', e.shiftKey);
});
window.addEventListener('keyup', (e) => {
+ // Clear the key state
keyStates.set(e.key, false);
+
+ // Also clear specific states for modifier keys
+ if (e.key === 'Alt' || e.key === 'AltLeft' || e.key === 'AltRight') {
+ keyStates.set('Alt', false);
+ keyStates.set('AltLeft', false);
+ keyStates.set('AltRight', false);
+ }
+
+ if (e.key === 'Shift' || e.key === 'ShiftLeft' || e.key === 'ShiftRight') {
+ keyStates.set('Shift', false);
+ keyStates.set('ShiftLeft', false);
+ keyStates.set('ShiftRight', false);
+ }
+
+ console.log('Key up:', e.key);
});
window.addEventListener('blur', () => {
+ // Clear all key states when window loses focus
keyStates.forEach((_, key) => {
keyStates.set(key, false);
});
+ console.log('Window blur - all keys reset');
});
listenersInit = true;
diff --git a/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx b/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx
index dd2b78c3e..c3dae4c75 100644
--- a/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx
+++ b/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx
@@ -7,8 +7,10 @@ import { useMail } from '@/components/mail/use-mail';
import { useTRPC } from '@/providers/query-provider';
import { useShortcuts } from './use-hotkey-utils';
import { useThreads } from '@/hooks/use-threads';
+import { usePathname } from 'next/navigation';
import { useStats } from '@/hooks/use-stats';
import { useTranslations } from 'next-intl';
+import { useQueryState } from 'nuqs';
import { toast } from 'sonner';
export function MailListHotkeys() {
@@ -20,6 +22,8 @@ export function MailListHotkeys() {
const hoveredEmailId = useRef(null);
const trpc = useTRPC();
const queryClient = useQueryClient();
+ const [, setCategory] = useQueryState('category');
+ const pathname = usePathname();
const invalidateCount = () =>
queryClient.invalidateQueries({ queryKey: trpc.mail.count.queryKey() });
const { mutateAsync: bulkArchive } = useMutation(trpc.mail.bulkArchive.mutationOptions());
@@ -175,6 +179,12 @@ export function MailListHotkeys() {
// }
// }, []);
+ const switchMailListCategory = (category: string | null) => {
+ if (pathname?.includes('/mail/inbox')) {
+ setCategory(category);
+ }
+ };
+
const handlers = {
markAsRead,
markAsUnread,
@@ -184,6 +194,24 @@ export function MailListHotkeys() {
// muteThread,
// scrollDown,
// scrollUp,
+ showImportant: () => {
+ switchMailListCategory(null);
+ },
+ showAllMail: () => {
+ switchMailListCategory('All Mail');
+ },
+ showPersonal: () => {
+ switchMailListCategory('Personal');
+ },
+ showUpdates: () => {
+ switchMailListCategory('Updates');
+ },
+ showPromotions: () => {
+ switchMailListCategory('Promotions');
+ },
+ showUnread: () => {
+ switchMailListCategory('Unread');
+ },
};
const mailListShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
diff --git a/apps/mail/lib/hotkeys/navigation-hotkeys.tsx b/apps/mail/lib/hotkeys/navigation-hotkeys.tsx
index e0ae51f14..aa74dbcd9 100644
--- a/apps/mail/lib/hotkeys/navigation-hotkeys.tsx
+++ b/apps/mail/lib/hotkeys/navigation-hotkeys.tsx
@@ -14,7 +14,7 @@ export function NavigationHotkeys() {
sentMail: () => router.push('/mail/sent'),
goToArchive: () => router.push('/mail/archive'),
goToBin: () => router.push('/mail/bin'),
- goToSpam: () => router.push('/mail/spam'),
+ goToSettings: () => router.push('/settings'),
};
const globalShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
diff --git a/apps/mail/locales/en.json b/apps/mail/locales/en.json
index cf738fbf8..9c1102a25 100644
--- a/apps/mail/locales/en.json
+++ b/apps/mail/locales/en.json
@@ -445,7 +445,8 @@
"goToArchive": "Go to Archive",
"goToBin": "Go to Bin",
"scrollDown": "Scroll down in mail list",
- "scrollUp": "Scroll up in mail list"
+ "scrollUp": "Scroll up in mail list",
+ "goToSettings": "Go to Settings"
}
},
"labels": {