feat: implement keyboard shortcuts for mail categories and improve selection modes

This commit is contained in:
Ahmet Kilinc
2025-05-14 13:16:50 +01:00
parent 24e9d8a2e6
commit b1dad2e254
10 changed files with 216 additions and 30 deletions

View File

@@ -37,6 +37,7 @@ type CommandItem = {
onClick?: () => unknown;
shortcut?: string;
isBackButton?: boolean;
disabled?: boolean;
};
const CommandPaletteContext = React.createContext<CommandPaletteContext | null>(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') {

View File

@@ -821,6 +821,14 @@ const MailDisplay = ({ emailData, index, totalEmails, demo }: Props) => {
Reply
</div>
</div>
<kbd
className={cn(
'border-muted-foreground/10 bg-accent h-6 rounded-[6px] border px-1.5 font-mono text-xs leading-6',
'-me-1 ms-auto inline-flex max-h-full items-center',
)}
>
r
</kbd>
</button>
<button
onClick={(e) => {
@@ -836,6 +844,14 @@ const MailDisplay = ({ emailData, index, totalEmails, demo }: Props) => {
Reply All
</div>
</div>
<kbd
className={cn(
'border-muted-foreground/10 bg-accent h-6 rounded-[6px] border px-1.5 font-mono text-xs leading-6',
'-me-1 ms-auto inline-flex max-h-full items-center',
)}
>
a
</kbd>
</button>
<button
onClick={(e) => {
@@ -851,6 +867,14 @@ const MailDisplay = ({ emailData, index, totalEmails, demo }: Props) => {
Forward
</div>
</div>
<kbd
className={cn(
'border-muted-foreground/10 bg-accent h-6 rounded-[6px] border px-1.5 font-mono text-xs leading-6',
'-me-1 ms-auto inline-flex max-h-full items-center',
)}
>
f
</kbd>
</button>
</div>
</div>

View File

@@ -611,11 +611,7 @@ const Thread = memo(
{latestMessage.to.map((e) => e.email).join(', ')}
</p>
) : (
<p
className={cn(
'mt-1 line-clamp-1 w-full text-sm text-[#8C8C8C] min-w-0',
)}
>
<p className={cn('mt-1 line-clamp-1 w-full min-w-0 text-sm text-[#8C8C8C]')}>
{highlightText(latestMessage.subject, searchValue.highlight)}
</p>
)}
@@ -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;

View File

@@ -810,7 +810,15 @@ function CategorySelect({ isMultiSelectMode }: { isMultiSelectMode: boolean }) {
</TooltipTrigger>
{!isSelected && (
<TooltipContent side="top" className={`${idx === 0 ? 'ml-4' : ''}`}>
<span>{cat.name}</span>
<span className="mr-2">{cat.name}</span>
<kbd
className={cn(
'border-muted-foreground/10 bg-accent h-6 rounded-[6px] border px-1.5 font-mono text-xs leading-6',
'-me-1 ms-auto inline-flex max-h-full items-center',
)}
>
{idx ? idx + 1 : ''}
</kbd>
</TooltipContent>
)}
</Tooltip>

View File

@@ -58,21 +58,21 @@ export const navigationConfig: Record<string, NavConfig> = {
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<string, NavConfig> = {
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<string, NavConfig> = {
title: 'navigation.settings.general',
url: '/settings/general',
icon: SettingsGear,
shortcut: 'g + s',
},
{
title: 'navigation.settings.connections',
@@ -181,6 +180,7 @@ export const navigationConfig: Record<string, NavConfig> = {
title: 'navigation.settings.shortcuts',
url: '/settings/shortcuts',
icon: Tabs,
shortcut: '?',
},
// {
// title: 'navigation.settings.signatures',

View File

@@ -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[] = [

View File

@@ -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;

View File

@@ -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<string | null>(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);

View File

@@ -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);

View File

@@ -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": {