mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-29 07:16:19 +00:00
feat: implement keyboard shortcuts for mail categories and improve selection modes
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user