mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-28 14:56:48 +00:00
dvorak for rene (#1738)
# Keyboard Layout Detection and Mapping for Shortcuts ## Description Added keyboard layout detection and mapping to ensure keyboard shortcuts work correctly across different keyboard layouts. The implementation supports QWERTY, Dvorak, Colemak, AZERTY, and QWERTZ layouts, with automatic detection based on browser APIs and fallback methods. Also added a keyboard layout indicator component to show the current detected layout in the UI. ## Type of Change - ✨ New feature (non-breaking change which adds functionality) - 🎨 UI/UX improvement - ⚡ Performance improvement ## Areas Affected - [x] User Interface/Experience ## Testing Done - [x] Manual testing performed - [x] Cross-browser testing (if UI changes) ## Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in complex areas - [x] My changes generate no new warnings ## Additional Notes The implementation uses the KeyboardLayoutMap API when available, with fallbacks to language detection and predefined mappings. The keyboard layout indicator only appears when a non-QWERTY layout is detected. Also includes some code quality improvements: - Enhanced error handling in Gmail API interactions - Simplified optimistic action refresh logic - Improved rate limit handling on the server side ## Screenshots/Recordings N/A --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added dynamic keyboard layout detection and mapping, supporting QWERTY, Dvorak, Colemak, Azerty, and QWERTZ. * Introduced a keyboard layout indicator to display the current layout in the mail app. * Enhanced keyboard shortcuts to display and map keys according to the detected keyboard layout. * Added a utility to convert key names to keyboard event codes for consistent shortcut handling. * **Refactor** * Updated keyboard shortcut handling throughout the mail app to use layout-aware shortcuts. * Simplified optimistic action refresh logic and removed unused parameters. * Refactored internal retry logic for server-side Gmail API calls. * Replaced promise handling with Effect abstractions in server thread label modifications. * **Style** * Cleaned up whitespace and reordered import statements in several components. * **Chores** * Added additional logging for debugging and removed unnecessary log statements. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
60
apps/mail/components/keyboard-layout-indicator.tsx
Normal file
60
apps/mail/components/keyboard-layout-indicator.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Keyboard Layout Indicator Component
|
||||
* Shows the current detected keyboard layout and confidence
|
||||
*/
|
||||
|
||||
import { keyboardLayoutMapper, type LayoutDetectionResult } from '@/utils/keyboard-layout-map';
|
||||
import { KeyboardIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function KeyboardLayoutIndicator() {
|
||||
const layoutInfo = useKeyboardLayout();
|
||||
|
||||
if (!layoutInfo || layoutInfo.layout === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getLayoutDisplayName = (layout: string) => {
|
||||
const names = {
|
||||
qwerty: 'QWERTY',
|
||||
dvorak: 'Dvorak',
|
||||
colemak: 'Colemak',
|
||||
azerty: 'AZERTY',
|
||||
qwertz: 'QWERTZ',
|
||||
};
|
||||
return names[layout as keyof typeof names] || layout.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
|
||||
<KeyboardIcon />
|
||||
<span>{getLayoutDisplayName(layoutInfo.layout)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useKeyboardLayout() {
|
||||
const [layoutInfo, setLayoutInfo] = useState<LayoutDetectionResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateLayoutInfo = () => {
|
||||
const info = keyboardLayoutMapper.getDetectedLayout();
|
||||
setLayoutInfo(info);
|
||||
console.log('Detected keyboard layout:', info);
|
||||
};
|
||||
|
||||
updateLayoutInfo();
|
||||
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => {
|
||||
updateLayoutInfo();
|
||||
console.log('Window focused, updated keyboard layout');
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => window.removeEventListener('focus', handleFocus);
|
||||
}, []);
|
||||
|
||||
return layoutInfo;
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { Bell, Lightning, Mail, ScanEye, Tag, Trash, User, X, Search } from '../icons/icons';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Bell, Lightning, Mail, ScanEye, Tag, Trash, User, X, Search } from '../icons/icons';
|
||||
import { useCategorySettings, useDefaultCategoryId } from '@/hooks/use-categories';
|
||||
import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { usePartySocket } from 'partysocket/react';
|
||||
|
||||
|
||||
// 10 seconds is appropriate for real-time notifications
|
||||
// 10 seconds is appropriate for real-time notifications
|
||||
|
||||
export enum IncomingMessageType {
|
||||
UseChatRequest = 'cf_agent_use_chat_request',
|
||||
@@ -32,9 +31,6 @@ export const NotificationProvider = () => {
|
||||
const [searchValue] = useSearchValue();
|
||||
const { labels } = useSearchLabels();
|
||||
|
||||
|
||||
|
||||
|
||||
usePartySocket({
|
||||
party: 'zero-agent',
|
||||
room: activeConnection?.id ? String(activeConnection.id) : 'general',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { Mic, MicOff, Loader2, WavesIcon } from 'lucide-react';
|
||||
import { useVoice } from '@/providers/voice-provider';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { keyboardLayoutMapper } from '../utils/keyboard-layout-map';
|
||||
import { getKeyCodeFromKey } from '../utils/keyboard-utils';
|
||||
|
||||
export const shortcutSchema = z.object({
|
||||
keys: z.array(z.string()),
|
||||
@@ -13,6 +15,70 @@ export const shortcutSchema = z.object({
|
||||
export type Shortcut = z.infer<typeof shortcutSchema>;
|
||||
export type ShortcutType = Shortcut['type'];
|
||||
|
||||
/**
|
||||
* Enhanced shortcut type with keyboard layout mapping support
|
||||
*/
|
||||
export interface EnhancedShortcut extends Shortcut {
|
||||
mappedKeys?: string[];
|
||||
displayKeys?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert key codes to user-friendly display keys using keyboard layout mapping
|
||||
*/
|
||||
export function getDisplayKeysForShortcut(shortcut: Shortcut): string[] {
|
||||
const detectedLayout = keyboardLayoutMapper.getDetectedLayout();
|
||||
|
||||
return shortcut.keys.map(key => {
|
||||
// Handle special modifiers first
|
||||
switch (key.toLowerCase()) {
|
||||
case 'mod':
|
||||
return navigator.platform.includes('Mac') ? '⌘' : 'Ctrl';
|
||||
case 'meta':
|
||||
return '⌘';
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
return 'Ctrl';
|
||||
case 'alt':
|
||||
return navigator.platform.includes('Mac') ? '⌥' : 'Alt';
|
||||
case 'shift':
|
||||
return '⇧';
|
||||
case 'escape':
|
||||
return 'Esc';
|
||||
case 'backspace':
|
||||
return '⌫';
|
||||
case 'enter':
|
||||
return '↵';
|
||||
case 'space':
|
||||
return 'Space';
|
||||
default:
|
||||
// Use enhanced keyboard layout mapping
|
||||
if (detectedLayout?.layout && detectedLayout.layout !== 'qwerty') {
|
||||
const keyCode = getKeyCodeFromKey(key);
|
||||
const mappedKey = keyboardLayoutMapper.getKeyForCode(keyCode);
|
||||
return mappedKey.length === 1 ? mappedKey.toUpperCase() : mappedKey;
|
||||
}
|
||||
return key.length === 1 ? key.toUpperCase() : key;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a key string to its corresponding KeyCode
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Enhance shortcuts with keyboard layout mapping
|
||||
*/
|
||||
export function enhanceShortcutsWithMapping(shortcuts: Shortcut[]): EnhancedShortcut[] {
|
||||
return shortcuts.map(shortcut => ({
|
||||
...shortcut,
|
||||
displayKeys: getDisplayKeysForShortcut(shortcut),
|
||||
mappedKeys: keyboardLayoutMapper.mapKeys(shortcut.keys.map(getKeyCodeFromKey)),
|
||||
}));
|
||||
}
|
||||
|
||||
const threadDisplayShortcuts: Shortcut[] = [
|
||||
// {
|
||||
// keys: ['i'],
|
||||
@@ -363,3 +429,8 @@ export const keyboardShortcuts: Shortcut[] = [
|
||||
...mailListShortcuts,
|
||||
...composeShortcuts,
|
||||
];
|
||||
|
||||
/**
|
||||
* Enhanced keyboard shortcuts with layout mapping
|
||||
*/
|
||||
export const enhancedKeyboardShortcuts: EnhancedShortcut[] = enhanceShortcutsWithMapping(keyboardShortcuts);
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useTRPC } from '@/providers/query-provider';
|
||||
import { useMail } from '@/components/mail/use-mail';
|
||||
import { moveThreadsTo } from '@/lib/thread-actions';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useCallback } from 'react';
|
||||
import posthog from 'posthog-js';
|
||||
import { useAtom } from 'jotai';
|
||||
import { toast } from 'sonner';
|
||||
@@ -52,25 +52,12 @@ export function useOptimisticActions() {
|
||||
const generatePendingActionId = () =>
|
||||
`pending_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const refreshData = useCallback(
|
||||
async (threadIds: string[], folders?: string[]) => {
|
||||
return await Promise.all([
|
||||
queryClient.refetchQueries({ queryKey: trpc.mail.count.queryKey() }),
|
||||
...(folders?.map((folder) =>
|
||||
queryClient.refetchQueries({
|
||||
queryKey: trpc.mail.listThreads.infiniteQueryKey({ folder }),
|
||||
}),
|
||||
) ?? []),
|
||||
...threadIds.map((id) =>
|
||||
queryClient.refetchQueries({
|
||||
queryKey: trpc.mail.get.queryKey({ id }),
|
||||
}),
|
||||
),
|
||||
queryClient.refetchQueries({ queryKey: trpc.labels.list.queryKey() }),
|
||||
]);
|
||||
},
|
||||
[queryClient, trpc.mail.get, trpc.labels.list],
|
||||
);
|
||||
const refreshData = useCallback(async () => {
|
||||
return await Promise.all([
|
||||
queryClient.refetchQueries({ queryKey: trpc.mail.count.queryKey() }),
|
||||
queryClient.refetchQueries({ queryKey: trpc.labels.list.queryKey() }),
|
||||
]);
|
||||
}, [queryClient]);
|
||||
|
||||
function createPendingAction({
|
||||
type,
|
||||
@@ -80,7 +67,6 @@ export function useOptimisticActions() {
|
||||
execute,
|
||||
undo,
|
||||
toastMessage,
|
||||
folders,
|
||||
}: {
|
||||
type: keyof typeof ActionType;
|
||||
threadIds: string[];
|
||||
@@ -141,7 +127,7 @@ export function useOptimisticActions() {
|
||||
optimisticActionsManager.pendingActions.delete(pendingActionId);
|
||||
optimisticActionsManager.pendingActionsByType.get(type)?.delete(pendingActionId);
|
||||
if (typeActions?.size === 1) {
|
||||
await refreshData(threadIds, folders);
|
||||
await refreshData();
|
||||
removeOptimisticAction(optimisticId);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -149,12 +135,10 @@ export function useOptimisticActions() {
|
||||
removeOptimisticAction(optimisticId);
|
||||
optimisticActionsManager.pendingActions.delete(pendingActionId);
|
||||
optimisticActionsManager.pendingActionsByType.get(type)?.delete(pendingActionId);
|
||||
showToast.error('Action failed');
|
||||
toast.error('Action failed');
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = toast;
|
||||
|
||||
if (toastMessage.trim().length) {
|
||||
toast(bulkActionMessage, {
|
||||
onAutoClose: () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { enhancedKeyboardShortcuts } from '@/config/shortcuts';
|
||||
import { useShortcuts } from './use-hotkey-utils';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ComposeHotkeys() {
|
||||
},
|
||||
};
|
||||
|
||||
const composeShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
const composeShortcuts = enhancedKeyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
|
||||
useShortcuts(composeShortcuts, handlers, { scope });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCommandPalette } from '@/components/context/command-palette-context';
|
||||
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
|
||||
import { keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { enhancedKeyboardShortcuts } from '@/config/shortcuts';
|
||||
import { useShortcuts } from './use-hotkey-utils';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
@@ -20,7 +20,7 @@ export function GlobalHotkeys() {
|
||||
},
|
||||
};
|
||||
|
||||
const globalShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
const globalShortcuts = enhancedKeyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
|
||||
useShortcuts(globalShortcuts, handlers, { scope });
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { enhancedKeyboardShortcuts } from '@/config/shortcuts';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { useLocation, useParams } from 'react-router';
|
||||
import { useMail } from '@/components/mail/use-mail';
|
||||
import { Categories } from '@/components/mail/mail';
|
||||
@@ -239,7 +239,9 @@ export function MailListHotkeys() {
|
||||
],
|
||||
);
|
||||
|
||||
const mailListShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
const mailListShortcuts = enhancedKeyboardShortcuts.filter(
|
||||
(shortcut) => shortcut.scope === scope,
|
||||
);
|
||||
|
||||
useShortcuts(mailListShortcuts, handlers, { scope });
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { enhancedKeyboardShortcuts } from '@/config/shortcuts';
|
||||
import { useShortcuts } from './use-hotkey-utils';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -16,7 +16,7 @@ export function NavigationHotkeys() {
|
||||
helpWithShortcuts: () => navigate('/settings/shortcuts'),
|
||||
};
|
||||
|
||||
const globalShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
const globalShortcuts = enhancedKeyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
|
||||
useShortcuts(globalShortcuts, handlers, { scope });
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mailNavigationCommandAtom } from '@/hooks/use-mail-navigation';
|
||||
import { useThread, } from '@/hooks/use-threads';
|
||||
import { keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { enhancedKeyboardShortcuts } from '@/config/shortcuts';
|
||||
import useMoveTo from '@/hooks/driver/use-move-to';
|
||||
import useDelete from '@/hooks/driver/use-delete';
|
||||
import { useShortcuts } from './use-hotkey-utils';
|
||||
import { useThread } from '@/hooks/use-threads';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useSetAtom } from 'jotai';
|
||||
@@ -55,7 +55,9 @@ export function ThreadDisplayHotkeys() {
|
||||
},
|
||||
};
|
||||
|
||||
const threadDisplayShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
|
||||
const threadDisplayShortcuts = enhancedKeyboardShortcuts.filter(
|
||||
(shortcut) => shortcut.scope === scope,
|
||||
);
|
||||
|
||||
useShortcuts(threadDisplayShortcuts, handlers, { scope });
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// TODO: Implement shortcuts syncing and caching
|
||||
import { type Shortcut, keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { type Shortcut, keyboardShortcuts, enhancedKeyboardShortcuts } from '@/config/shortcuts';
|
||||
import { keyboardLayoutMapper, type KeyboardLayout } from '@/utils/keyboard-layout-map';
|
||||
import { getKeyCodeFromKey } from '@/utils/keyboard-utils';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
@@ -52,12 +54,6 @@ export const isMac =
|
||||
(/macintosh|mac os x/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1));
|
||||
|
||||
const isDvorak =
|
||||
typeof window !== 'undefined' &&
|
||||
(navigator.language === 'en-DV' ||
|
||||
navigator.languages?.includes('en-DV') ||
|
||||
document.documentElement.lang === 'en-DV');
|
||||
|
||||
const dvorakToQwerty: Record<string, string> = {
|
||||
a: 'a',
|
||||
b: 'x',
|
||||
@@ -109,7 +105,19 @@ export const formatKeys = (keys: string[] | undefined): string => {
|
||||
|
||||
const mapKey = (key: string) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
const mappedKey = isDvorak ? qwertyToDvorak[lowerKey] || key : key;
|
||||
|
||||
// Use enhanced keyboard layout mapping
|
||||
const detectedLayout = keyboardLayoutMapper.getDetectedLayout();
|
||||
let mappedKey = key;
|
||||
|
||||
if (detectedLayout?.layout === 'dvorak') {
|
||||
// Use the existing Dvorak mapping for backward compatibility
|
||||
mappedKey = qwertyToDvorak[lowerKey] || key;
|
||||
} else if (detectedLayout?.layout && detectedLayout.layout !== 'qwerty') {
|
||||
// Use the KeyboardLayoutMap API for other layouts
|
||||
const keyCode = getKeyCodeFromKey(key);
|
||||
mappedKey = keyboardLayoutMapper.getKeyForCode(keyCode);
|
||||
}
|
||||
|
||||
switch (mappedKey) {
|
||||
case 'mod':
|
||||
@@ -134,10 +142,26 @@ export const formatKeys = (keys: string[] | undefined): string => {
|
||||
return mapKey(firstKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a key string to its corresponding KeyCode for the keyboard layout mapper
|
||||
*/
|
||||
|
||||
export const formatDisplayKeys = (keys: string[]): string[] => {
|
||||
return keys.map((key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
const mappedKey = isDvorak ? qwertyToDvorak[lowerKey] || key : key;
|
||||
|
||||
// Use enhanced keyboard layout mapping
|
||||
const detectedLayout = keyboardLayoutMapper.getDetectedLayout();
|
||||
let mappedKey = key;
|
||||
|
||||
if (detectedLayout?.layout === 'dvorak') {
|
||||
// Use the existing Dvorak mapping for backward compatibility
|
||||
mappedKey = qwertyToDvorak[lowerKey] || key;
|
||||
} else if (detectedLayout?.layout && detectedLayout.layout !== 'qwerty') {
|
||||
// Use the KeyboardLayoutMap API for other layouts
|
||||
const keyCode = getKeyCodeFromKey(key);
|
||||
mappedKey = keyboardLayoutMapper.getKeyForCode(keyCode);
|
||||
}
|
||||
|
||||
switch (mappedKey) {
|
||||
case 'mod':
|
||||
@@ -168,6 +192,36 @@ export const formatDisplayKeys = (keys: string[]): string[] => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced shortcut utilities with layout mapping support, here incase needed
|
||||
*/
|
||||
export const useEnhancedShortcuts = () => {
|
||||
const layoutInfo = keyboardLayoutMapper.getDetectedLayout();
|
||||
|
||||
const getShortcutsForLayout = useCallback((layout: KeyboardLayout) => {
|
||||
return enhancedKeyboardShortcuts.map((shortcut) => ({
|
||||
...shortcut,
|
||||
mappedKeys: shortcut.keys.map((key) =>
|
||||
keyboardLayoutMapper.convertKey(key, 'qwerty', layout),
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
layoutInfo,
|
||||
formatKeysWithLayout: (keys: string[], targetLayout?: KeyboardLayout) => {
|
||||
if (!targetLayout || !layoutInfo) return formatKeys(keys);
|
||||
|
||||
return keys
|
||||
.map((key) => {
|
||||
return keyboardLayoutMapper.convertKey(key, layoutInfo.layout, targetLayout);
|
||||
})
|
||||
.join('+');
|
||||
},
|
||||
getShortcutsForLayout,
|
||||
};
|
||||
};
|
||||
|
||||
export type HotkeyOptions = {
|
||||
scope: string;
|
||||
preventDefault?: boolean;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useKeyboardLayout } from '@/components/keyboard-layout-indicator';
|
||||
import { LoadingProvider } from '@/components/context/loading-context';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PostHogProvider } from '@/lib/posthog-provider';
|
||||
import { LoadingProvider } from '@/components/context/loading-context';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
@@ -10,6 +11,7 @@ import { ThemeProvider } from 'next-themes';
|
||||
|
||||
export function ClientProviders({ children }: PropsWithChildren) {
|
||||
const { data } = useSettings();
|
||||
useKeyboardLayout();
|
||||
|
||||
const theme = data?.settings.colorTheme || 'system';
|
||||
|
||||
|
||||
596
apps/mail/utils/keyboard-layout-map.ts
Normal file
596
apps/mail/utils/keyboard-layout-map.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* Keyboard Layout Map utility for mapping physical key codes to layout-specific key values
|
||||
* Uses the KeyboardLayoutMap API: https://wicg.github.io/keyboard-map/
|
||||
* Enhanced with comprehensive layout detection and Dvorak support
|
||||
*/
|
||||
|
||||
export interface KeyboardLayoutMapAPI {
|
||||
get(keyCode: string): string | undefined;
|
||||
has(keyCode: string): boolean;
|
||||
keys(): IterableIterator<string>;
|
||||
values(): IterableIterator<string>;
|
||||
entries(): IterableIterator<[string, string]>;
|
||||
forEach(callback: (value: string, key: string) => void): void;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
keyboard?: {
|
||||
getLayoutMap(): Promise<KeyboardLayoutMapAPI>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard layout types and detection
|
||||
*/
|
||||
export type KeyboardLayout = 'qwerty' | 'dvorak' | 'colemak' | 'azerty' | 'qwertz' | 'unknown';
|
||||
|
||||
export interface LayoutDetectionResult {
|
||||
layout: KeyboardLayout;
|
||||
confidence: number;
|
||||
method: 'api' | 'language' | 'fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive layout mapping tables
|
||||
*/
|
||||
export const layoutMappings = {
|
||||
dvorak: {
|
||||
// Dvorak to QWERTY mapping
|
||||
toQwerty: {
|
||||
a: 'a',
|
||||
b: 'x',
|
||||
c: 'j',
|
||||
d: 'e',
|
||||
e: '.',
|
||||
f: 'u',
|
||||
g: 'i',
|
||||
h: 'd',
|
||||
i: 'c',
|
||||
j: 'h',
|
||||
k: 't',
|
||||
l: 'n',
|
||||
m: 'm',
|
||||
n: 'b',
|
||||
o: 'r',
|
||||
p: 'l',
|
||||
q: "'",
|
||||
r: 'p',
|
||||
s: 'o',
|
||||
t: 'k',
|
||||
u: 'g',
|
||||
v: 'q',
|
||||
w: ',',
|
||||
x: 'z',
|
||||
y: 'f',
|
||||
z: ';',
|
||||
';': 's',
|
||||
"'": '-',
|
||||
',': 'w',
|
||||
'.': 'v',
|
||||
'/': 'z',
|
||||
'-': '[',
|
||||
'[': '/',
|
||||
']': '=',
|
||||
'=': ']',
|
||||
},
|
||||
// QWERTY to Dvorak mapping (reversed)
|
||||
fromQwerty: {} as Record<string, string>,
|
||||
// Physical key codes to Dvorak keys
|
||||
physicalMap: {
|
||||
KeyA: 'a',
|
||||
KeyB: 'x',
|
||||
KeyC: 'j',
|
||||
KeyD: 'e',
|
||||
KeyE: '.',
|
||||
KeyF: 'u',
|
||||
KeyG: 'i',
|
||||
KeyH: 'd',
|
||||
KeyI: 'c',
|
||||
KeyJ: 'h',
|
||||
KeyK: 't',
|
||||
KeyL: 'n',
|
||||
KeyM: 'm',
|
||||
KeyN: 'b',
|
||||
KeyO: 'r',
|
||||
KeyP: 'l',
|
||||
KeyQ: "'",
|
||||
KeyR: 'p',
|
||||
KeyS: 'o',
|
||||
KeyT: 'k',
|
||||
KeyU: 'g',
|
||||
KeyV: 'q',
|
||||
KeyW: ',',
|
||||
KeyX: 'z',
|
||||
KeyY: 'f',
|
||||
KeyZ: ';',
|
||||
Semicolon: 's',
|
||||
Quote: '-',
|
||||
Comma: 'w',
|
||||
Period: 'v',
|
||||
Slash: 'z',
|
||||
Minus: '[',
|
||||
BracketLeft: '/',
|
||||
BracketRight: '=',
|
||||
Equal: ']',
|
||||
},
|
||||
},
|
||||
colemak: {
|
||||
physicalMap: {
|
||||
KeyD: 'g',
|
||||
KeyE: 'f',
|
||||
KeyF: 'e',
|
||||
KeyG: 'd',
|
||||
KeyI: 'l',
|
||||
KeyJ: 'u',
|
||||
KeyK: 'y',
|
||||
KeyL: ';',
|
||||
KeyN: 'k',
|
||||
KeyO: ';',
|
||||
KeyP: 'r',
|
||||
KeyR: 's',
|
||||
KeyS: 'r',
|
||||
KeyT: 'g',
|
||||
KeyU: 'l',
|
||||
KeyY: 'j',
|
||||
Semicolon: 'o',
|
||||
},
|
||||
},
|
||||
azerty: {
|
||||
physicalMap: {
|
||||
KeyA: 'q',
|
||||
KeyQ: 'a',
|
||||
KeyW: 'z',
|
||||
KeyZ: 'w',
|
||||
KeyM: ',',
|
||||
Comma: 'm',
|
||||
Period: ';',
|
||||
Semicolon: '.',
|
||||
Digit1: '&',
|
||||
Digit2: 'é',
|
||||
Digit3: '"',
|
||||
Digit4: "'",
|
||||
Digit5: '(',
|
||||
Digit6: '-',
|
||||
Digit7: 'è',
|
||||
Digit8: '_',
|
||||
Digit9: 'ç',
|
||||
Digit0: 'à',
|
||||
},
|
||||
},
|
||||
qwertz: {
|
||||
physicalMap: {
|
||||
KeyY: 'z',
|
||||
KeyZ: 'y',
|
||||
Semicolon: 'ö',
|
||||
Quote: 'ä',
|
||||
BracketLeft: 'ü',
|
||||
BracketRight: '+',
|
||||
Backslash: '#',
|
||||
Minus: 'ß',
|
||||
Equal: '´',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize reverse mappings
|
||||
layoutMappings.dvorak.fromQwerty = Object.entries(layoutMappings.dvorak.toQwerty).reduce(
|
||||
(acc, [dvorak, qwerty]) => {
|
||||
acc[qwerty] = dvorak;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
class KeyboardLayoutMapper {
|
||||
private layoutMap: KeyboardLayoutMapAPI | null = null;
|
||||
private isInitialized = false;
|
||||
private detectedLayout: LayoutDetectionResult | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the keyboard layout map
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
if ('keyboard' in navigator && navigator.keyboard?.getLayoutMap) {
|
||||
this.layoutMap = await navigator.keyboard.getLayoutMap();
|
||||
this.detectedLayout = await this.detectLayout();
|
||||
this.isInitialized = true;
|
||||
} else {
|
||||
console.warn('KeyboardLayoutMap API is not supported in this browser');
|
||||
this.detectedLayout = this.detectLayoutFallback();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize KeyboardLayoutMap:', error);
|
||||
this.detectedLayout = this.detectLayoutFallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect keyboard layout using multiple methods
|
||||
*/
|
||||
private async detectLayout(): Promise<LayoutDetectionResult> {
|
||||
// Method 1: Use KeyboardLayoutMap API
|
||||
if (this.layoutMap) {
|
||||
const layoutResult = this.analyzeLayoutFromAPI();
|
||||
if (layoutResult.confidence > 0.8) {
|
||||
return layoutResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Language-based detection
|
||||
const languageResult = this.detectLayoutFromLanguage();
|
||||
if (languageResult.confidence > 0.6) {
|
||||
return languageResult;
|
||||
}
|
||||
|
||||
// Method 3: Fallback detection
|
||||
return this.detectLayoutFallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze layout from KeyboardLayoutMap API
|
||||
*/
|
||||
private analyzeLayoutFromAPI(): LayoutDetectionResult {
|
||||
if (!this.layoutMap) {
|
||||
return { layout: 'unknown', confidence: 0, method: 'api' };
|
||||
}
|
||||
|
||||
// Check key signatures for different layouts
|
||||
const keySignatures = {
|
||||
dvorak: new Set([
|
||||
{ code: 'KeyQ', expected: "'" },
|
||||
{ code: 'KeyW', expected: ',' },
|
||||
{ code: 'KeyE', expected: '.' },
|
||||
{ code: 'KeyR', expected: 'p' },
|
||||
{ code: 'KeyT', expected: 'y' },
|
||||
]),
|
||||
colemak: new Set([
|
||||
{ code: 'KeyE', expected: 'f' },
|
||||
{ code: 'KeyR', expected: 'p' },
|
||||
{ code: 'KeyT', expected: 'g' },
|
||||
{ code: 'KeyY', expected: 'j' },
|
||||
]),
|
||||
azerty: new Set([
|
||||
{ code: 'KeyQ', expected: 'a' },
|
||||
{ code: 'KeyA', expected: 'q' },
|
||||
{ code: 'KeyW', expected: 'z' },
|
||||
{ code: 'KeyZ', expected: 'w' },
|
||||
]),
|
||||
qwertz: new Set([
|
||||
{ code: 'KeyY', expected: 'z' },
|
||||
{ code: 'KeyZ', expected: 'y' },
|
||||
]),
|
||||
};
|
||||
|
||||
for (const [layout, signatures] of Object.entries(keySignatures)) {
|
||||
let matches = 0;
|
||||
for (const { code, expected } of signatures) {
|
||||
if (this.layoutMap!.get(code) === expected) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
|
||||
const confidence = matches / signatures.size;
|
||||
if (confidence > 0.8) {
|
||||
return { layout: layout as KeyboardLayout, confidence, method: 'api' };
|
||||
}
|
||||
}
|
||||
|
||||
// Default to QWERTY if no specific layout detected
|
||||
return { layout: 'qwerty', confidence: 0.5, method: 'api' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect layout from language/locale settings
|
||||
*/
|
||||
private detectLayoutFromLanguage(): LayoutDetectionResult {
|
||||
const language = navigator.language || navigator.languages?.[0] || '';
|
||||
const languages = navigator.languages || [language];
|
||||
|
||||
// Check for explicit Dvorak indicators
|
||||
const dvorakIndicators = new Set(['DV', 'dvorak']);
|
||||
if (
|
||||
languages.some(
|
||||
(lang) => dvorakIndicators.has(lang) || lang.includes('DV') || lang.includes('dvorak'),
|
||||
)
|
||||
) {
|
||||
return { layout: 'dvorak', confidence: 0.9, method: 'language' };
|
||||
}
|
||||
|
||||
// Check document language
|
||||
const docLang = document.documentElement.lang;
|
||||
if (
|
||||
(docLang && dvorakIndicators.has(docLang)) ||
|
||||
docLang?.includes('DV') ||
|
||||
docLang?.includes('dvorak')
|
||||
) {
|
||||
return { layout: 'dvorak', confidence: 0.8, method: 'language' };
|
||||
}
|
||||
|
||||
// Regional layout detection using Set for faster lookups
|
||||
const layoutByRegion = new Map<string, KeyboardLayout>([
|
||||
['fr', 'azerty'],
|
||||
['de', 'qwertz'],
|
||||
['at', 'qwertz'],
|
||||
['ch', 'qwertz'],
|
||||
]);
|
||||
|
||||
const region = language.split('-')[0];
|
||||
if (layoutByRegion.has(region)) {
|
||||
return { layout: layoutByRegion.get(region)!, confidence: 0.7, method: 'language' };
|
||||
}
|
||||
|
||||
return { layout: 'qwerty', confidence: 0.3, method: 'language' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback layout detection
|
||||
*/
|
||||
private detectLayoutFallback(): LayoutDetectionResult {
|
||||
// Check for common Dvorak indicators in existing code
|
||||
if (typeof window !== 'undefined') {
|
||||
const dvorakLocales = new Set(['en-DV']);
|
||||
const languageSet = new Set(navigator.languages || []);
|
||||
|
||||
const isDvorakFromExisting =
|
||||
dvorakLocales.has(navigator.language) ||
|
||||
languageSet.has('en-DV') ||
|
||||
dvorakLocales.has(document.documentElement.lang);
|
||||
|
||||
if (isDvorakFromExisting) {
|
||||
return { layout: 'dvorak', confidence: 0.6, method: 'fallback' };
|
||||
}
|
||||
}
|
||||
|
||||
return { layout: 'qwerty', confidence: 0.4, method: 'fallback' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detected layout information
|
||||
*/
|
||||
getDetectedLayout(): LayoutDetectionResult | null {
|
||||
return this.detectedLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mapped key value for a given key code
|
||||
* @param keyCode - The physical key code (e.g., 'KeyA', 'Space', 'Enter')
|
||||
* @returns The mapped key value or the original key code if mapping fails
|
||||
*/
|
||||
getKeyForCode(keyCode: string): string {
|
||||
// Try KeyboardLayoutMap API first
|
||||
if (this.layoutMap) {
|
||||
const mappedKey = this.layoutMap.get(keyCode);
|
||||
if (mappedKey) return mappedKey;
|
||||
}
|
||||
|
||||
// Fallback to detected layout mapping
|
||||
if (this.detectedLayout) {
|
||||
const layoutMapping = this.getLayoutMapping(this.detectedLayout.layout);
|
||||
if (layoutMapping && layoutMapping[keyCode]) {
|
||||
return layoutMapping[keyCode];
|
||||
}
|
||||
}
|
||||
|
||||
return keyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layout-specific mapping table
|
||||
*/
|
||||
private getLayoutMapping(layout: KeyboardLayout): Record<string, string> | null {
|
||||
switch (layout) {
|
||||
case 'dvorak':
|
||||
return layoutMappings.dvorak.physicalMap;
|
||||
case 'colemak':
|
||||
return layoutMappings.colemak.physicalMap;
|
||||
case 'azerty':
|
||||
return layoutMappings.azerty.physicalMap;
|
||||
case 'qwertz':
|
||||
return layoutMappings.qwertz.physicalMap;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a key from one layout to another
|
||||
*/
|
||||
convertKey(key: string, fromLayout: KeyboardLayout, toLayout: KeyboardLayout): string {
|
||||
if (fromLayout === toLayout) return key;
|
||||
|
||||
// Special case for Dvorak <-> QWERTY conversion
|
||||
if (fromLayout === 'dvorak' && toLayout === 'qwerty') {
|
||||
return (
|
||||
layoutMappings.dvorak.toQwerty[
|
||||
key.toLowerCase() as keyof typeof layoutMappings.dvorak.toQwerty
|
||||
] || key
|
||||
);
|
||||
}
|
||||
if (fromLayout === 'qwerty' && toLayout === 'dvorak') {
|
||||
return (
|
||||
layoutMappings.dvorak.fromQwerty[
|
||||
key.toLowerCase() as keyof typeof layoutMappings.dvorak.fromQwerty
|
||||
] || key
|
||||
);
|
||||
}
|
||||
|
||||
// For other layouts, use physical mapping
|
||||
const fromMapping = this.getLayoutMapping(fromLayout);
|
||||
const toMapping = this.getLayoutMapping(toLayout);
|
||||
|
||||
if (fromMapping && toMapping) {
|
||||
// Find the physical key code for the source key
|
||||
const physicalKey = Object.entries(fromMapping).find(
|
||||
([_, mappedKey]) => mappedKey.toLowerCase() === key.toLowerCase(),
|
||||
)?.[0];
|
||||
|
||||
if (physicalKey && toMapping[physicalKey]) {
|
||||
return toMapping[physicalKey];
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an array of key codes to their layout-specific values
|
||||
* @param keyCodes - Array of key codes to map
|
||||
* @returns Array of mapped key values
|
||||
*/
|
||||
mapKeys(keyCodes: string[]): string[] {
|
||||
return keyCodes.map((keyCode) => this.getKeyForCode(keyCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the keyboard layout map is available and initialized
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.layoutMap !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available key mappings
|
||||
*/
|
||||
getAllMappings(): Record<string, string> {
|
||||
if (!this.layoutMap) return {};
|
||||
|
||||
const mappings: Record<string, string> = {};
|
||||
this.layoutMap.forEach((value, key) => {
|
||||
mappings[key] = value;
|
||||
});
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all key codes as a Set for efficient lookups
|
||||
*/
|
||||
getKeyCodesSet(): Set<string> {
|
||||
if (!this.layoutMap) return new Set();
|
||||
return new Set(this.layoutMap.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all key values as a Set for efficient lookups
|
||||
*/
|
||||
getKeyValuesSet(): Set<string> {
|
||||
if (!this.layoutMap) return new Set();
|
||||
return new Set(this.layoutMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key code exists in the current layout
|
||||
*/
|
||||
hasKeyCode(keyCode: string): boolean {
|
||||
return this.layoutMap?.has(keyCode) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key value exists in the current layout
|
||||
*/
|
||||
hasKeyValue(keyValue: string): boolean {
|
||||
if (!this.layoutMap) return false;
|
||||
for (const value of this.layoutMap.values()) {
|
||||
if (value === keyValue) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get intersection of two key sets
|
||||
*/
|
||||
getKeySetIntersection(setA: Set<string>, setB: Set<string>): Set<string> {
|
||||
return new Set([...setA].filter((key) => setB.has(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get union of two key sets
|
||||
*/
|
||||
getKeySetUnion(setA: Set<string>, setB: Set<string>): Set<string> {
|
||||
return new Set([...setA, ...setB]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get difference between two key sets
|
||||
*/
|
||||
getKeySetDifference(setA: Set<string>, setB: Set<string>): Set<string> {
|
||||
return new Set([...setA].filter((key) => !setB.has(key)));
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const keyboardLayoutMapper = new KeyboardLayoutMapper();
|
||||
|
||||
/**
|
||||
* Common key code mappings for reference
|
||||
*/
|
||||
export const commonKeyCodes = {
|
||||
// Letters
|
||||
KeyA: 'KeyA',
|
||||
KeyB: 'KeyB',
|
||||
KeyC: 'KeyC',
|
||||
KeyD: 'KeyD',
|
||||
KeyE: 'KeyE',
|
||||
KeyF: 'KeyF',
|
||||
KeyG: 'KeyG',
|
||||
KeyH: 'KeyH',
|
||||
KeyI: 'KeyI',
|
||||
KeyJ: 'KeyJ',
|
||||
KeyK: 'KeyK',
|
||||
KeyL: 'KeyL',
|
||||
KeyM: 'KeyM',
|
||||
KeyN: 'KeyN',
|
||||
KeyO: 'KeyO',
|
||||
KeyP: 'KeyP',
|
||||
KeyQ: 'KeyQ',
|
||||
KeyR: 'KeyR',
|
||||
KeyS: 'KeyS',
|
||||
KeyT: 'KeyT',
|
||||
KeyU: 'KeyU',
|
||||
KeyV: 'KeyV',
|
||||
KeyW: 'KeyW',
|
||||
KeyX: 'KeyX',
|
||||
KeyY: 'KeyY',
|
||||
KeyZ: 'KeyZ',
|
||||
|
||||
// Numbers
|
||||
Digit1: 'Digit1',
|
||||
Digit2: 'Digit2',
|
||||
Digit3: 'Digit3',
|
||||
Digit4: 'Digit4',
|
||||
Digit5: 'Digit5',
|
||||
Digit6: 'Digit6',
|
||||
Digit7: 'Digit7',
|
||||
Digit8: 'Digit8',
|
||||
Digit9: 'Digit9',
|
||||
Digit0: 'Digit0',
|
||||
|
||||
// Special keys
|
||||
Space: 'Space',
|
||||
Enter: 'Enter',
|
||||
Escape: 'Escape',
|
||||
Backspace: 'Backspace',
|
||||
Tab: 'Tab',
|
||||
|
||||
// Modifiers
|
||||
ShiftLeft: 'ShiftLeft',
|
||||
ShiftRight: 'ShiftRight',
|
||||
ControlLeft: 'ControlLeft',
|
||||
ControlRight: 'ControlRight',
|
||||
AltLeft: 'AltLeft',
|
||||
AltRight: 'AltRight',
|
||||
MetaLeft: 'MetaLeft',
|
||||
MetaRight: 'MetaRight',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Initialize the keyboard layout mapper on module load
|
||||
*/
|
||||
if (typeof window !== 'undefined') {
|
||||
keyboardLayoutMapper.init().catch(console.error);
|
||||
}
|
||||
33
apps/mail/utils/keyboard-utils.ts
Normal file
33
apps/mail/utils/keyboard-utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Converts a key string to its corresponding keyboard event code
|
||||
* @param key - The key string to convert
|
||||
* @returns The keyboard event code
|
||||
*/
|
||||
export function getKeyCodeFromKey(key: string): string {
|
||||
// Handle single characters
|
||||
if (key.length === 1) {
|
||||
const upperKey = key.toUpperCase();
|
||||
if (upperKey >= 'A' && upperKey <= 'Z') {
|
||||
return `Key${upperKey}`;
|
||||
}
|
||||
if (key >= '0' && key <= '9') {
|
||||
return `Digit${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special keys
|
||||
const specialKeys: Record<string, string> = {
|
||||
space: 'Space',
|
||||
enter: 'Enter',
|
||||
escape: 'Escape',
|
||||
backspace: 'Backspace',
|
||||
tab: 'Tab',
|
||||
shift: 'ShiftLeft',
|
||||
ctrl: 'ControlLeft',
|
||||
control: 'ControlLeft',
|
||||
alt: 'AltLeft',
|
||||
meta: 'MetaLeft',
|
||||
};
|
||||
|
||||
return specialKeys[key.toLowerCase()] || key;
|
||||
}
|
||||
@@ -208,22 +208,24 @@ export class GoogleMailManager implements MailManager {
|
||||
|
||||
type LabelCount = { label: string; count: number };
|
||||
|
||||
const mapped: LabelCount[] = (await Promise.all(
|
||||
results.map(async (res) => {
|
||||
if ('_tag' in res && res._tag === 'LabelFetchFailed') {
|
||||
return null;
|
||||
}
|
||||
let labelName = (res.data.name ?? res.data.id ?? '').toLowerCase();
|
||||
if (labelName === 'draft') {
|
||||
labelName = 'drafts';
|
||||
}
|
||||
const isTotalLabel = labelName === 'drafts' || labelName === 'sent';
|
||||
return {
|
||||
label: labelName,
|
||||
count: Number(isTotalLabel ? res.data.threadsTotal : res.data.threadsUnread),
|
||||
};
|
||||
}),
|
||||
)).filter((item): item is LabelCount => item !== null);
|
||||
const mapped: LabelCount[] = (
|
||||
await Promise.all(
|
||||
results.map(async (res) => {
|
||||
if ('_tag' in res && res._tag === 'LabelFetchFailed') {
|
||||
return null;
|
||||
}
|
||||
let labelName = (res.data.name ?? res.data.id ?? '').toLowerCase();
|
||||
if (labelName === 'draft') {
|
||||
labelName = 'drafts';
|
||||
}
|
||||
const isTotalLabel = labelName === 'drafts' || labelName === 'sent';
|
||||
return {
|
||||
label: labelName,
|
||||
count: Number(isTotalLabel ? res.data.threadsTotal : res.data.threadsUnread),
|
||||
};
|
||||
}),
|
||||
)
|
||||
).filter((item): item is LabelCount => item !== null);
|
||||
|
||||
// Get archive count
|
||||
try {
|
||||
@@ -847,27 +849,36 @@ export class GoogleMailManager implements MailManager {
|
||||
|
||||
const chunkSize = 15;
|
||||
const delayBetweenChunks = 100;
|
||||
const allResults = [];
|
||||
const allResults: Array<{
|
||||
threadId: string;
|
||||
status: 'fulfilled' | 'rejected';
|
||||
value?: unknown;
|
||||
reason?: unknown;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < threadIds.length; i += chunkSize) {
|
||||
const chunk = threadIds.slice(i, i + chunkSize);
|
||||
|
||||
const promises = chunk.map(async (threadId) => {
|
||||
try {
|
||||
const response = await this.gmail.users.threads.modify({
|
||||
userId: 'me',
|
||||
id: threadId,
|
||||
requestBody: requestBody,
|
||||
});
|
||||
return { threadId, status: 'fulfilled' as const, value: response.data };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.errors?.[0]?.message || error.message || error;
|
||||
return { threadId, status: 'rejected' as const, reason: { error: errorMessage } };
|
||||
}
|
||||
});
|
||||
const effects = chunk.map((threadId) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const response = await this.gmail.users.threads.modify({
|
||||
userId: 'me',
|
||||
id: threadId,
|
||||
requestBody,
|
||||
});
|
||||
return { threadId, status: 'fulfilled' as const, value: response.data };
|
||||
},
|
||||
catch: (error: any) => {
|
||||
const errorMessage = error?.errors?.[0]?.message || error.message || error;
|
||||
return { threadId, status: 'rejected' as const, reason: { error: errorMessage } };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const chunkResults = await Promise.all(promises);
|
||||
const chunkResults = await Effect.runPromise(
|
||||
Effect.all(effects, { concurrency: 'unbounded' }),
|
||||
);
|
||||
allResults.push(...chunkResults);
|
||||
|
||||
if (i + chunkSize < threadIds.length) {
|
||||
|
||||
@@ -5,18 +5,22 @@ import { Effect, Duration, Schedule } from 'effect';
|
||||
* – HTTP 429 Too Many Requests
|
||||
* – HTTP 403 with reason == userRateLimitExceeded or quotaExceeded
|
||||
*/
|
||||
export function isGmailRateLimit(err: unknown): boolean {
|
||||
export function isRateLimit(err: unknown): boolean {
|
||||
const e: any = err || {};
|
||||
const status = e.code ?? e.status ?? e.response?.status;
|
||||
|
||||
|
||||
if (status === 429) return true;
|
||||
if (status === 403) {
|
||||
const errors = e.errors ??
|
||||
e.response?.data?.error?.errors ??
|
||||
[];
|
||||
const errors = e.errors ?? e.response?.data?.error?.errors ?? [];
|
||||
return errors.some((x: any) =>
|
||||
['userRateLimitExceeded', 'rateLimitExceeded', 'quotaExceeded',
|
||||
'dailyLimitExceeded', 'backendError', 'limitExceeded'].includes(x.reason)
|
||||
[
|
||||
'userRateLimitExceeded',
|
||||
'rateLimitExceeded',
|
||||
'quotaExceeded',
|
||||
'dailyLimitExceeded',
|
||||
'backendError',
|
||||
'limitExceeded',
|
||||
].includes(x.reason),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
@@ -28,16 +32,15 @@ export function isGmailRateLimit(err: unknown): boolean {
|
||||
* – waits 60 seconds between retries (conservative for Gmail user quotas)
|
||||
* – stops immediately for any other error
|
||||
*/
|
||||
export const gmailRateLimitSchedule = Schedule
|
||||
.recurWhile(isGmailRateLimit)
|
||||
.pipe(Schedule.intersect(Schedule.recurs(10))) // max 10 attempts
|
||||
.pipe(Schedule.addDelay(() => Duration.seconds(60))); // 60s delay between retries
|
||||
export const rateLimitSchedule = Schedule.recurWhile(isRateLimit)
|
||||
.pipe(Schedule.intersect(Schedule.recurs(10))) // max 10 attempts
|
||||
.pipe(Schedule.addDelay(() => Duration.seconds(60))); // 60s delay between retries
|
||||
|
||||
/**
|
||||
* Generic wrapper that applies the schedule
|
||||
*/
|
||||
export function withGmailRetry<A>(
|
||||
export function withRetry<A>(
|
||||
eff: Effect.Effect<A, unknown, never>,
|
||||
): Effect.Effect<A, unknown, never> {
|
||||
return eff.pipe(Effect.retry(gmailRateLimitSchedule));
|
||||
return eff.pipe(Effect.retry(rateLimitSchedule));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import { FOLDERS } from '../lib/utils';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { McpAgent } from 'agents/mcp';
|
||||
|
||||
import { withGmailRetry } from '../lib/gmail-rate-limit';
|
||||
import { withRetry } from '../lib/gmail-rate-limit';
|
||||
import { createDb } from '../db';
|
||||
import { Effect } from 'effect';
|
||||
import { z } from 'zod';
|
||||
@@ -895,6 +895,7 @@ export class ZeroAgent extends AIChatAgent<typeof env> {
|
||||
}
|
||||
this.syncThreadsInProgress.set(threadId, true);
|
||||
|
||||
console.log('Server: syncThread called for thread', threadId);
|
||||
try {
|
||||
const threadData = await this.getWithRetry(threadId);
|
||||
const latest = threadData.latest;
|
||||
@@ -937,6 +938,10 @@ export class ZeroAgent extends AIChatAgent<typeof env> {
|
||||
});
|
||||
}
|
||||
this.syncThreadsInProgress.delete(threadId);
|
||||
console.log('Server: syncThread result', {
|
||||
threadId,
|
||||
labels: threadData.labels,
|
||||
});
|
||||
return { success: true, threadId, threadData };
|
||||
} else {
|
||||
this.syncThreadsInProgress.delete(threadId);
|
||||
@@ -957,13 +962,13 @@ export class ZeroAgent extends AIChatAgent<typeof env> {
|
||||
private async listWithRetry(params: Parameters<MailManager['list']>[0]) {
|
||||
if (!this.driver) throw new Error('No driver available');
|
||||
|
||||
return Effect.runPromise(withGmailRetry(Effect.tryPromise(() => this.driver!.list(params))));
|
||||
return Effect.runPromise(withRetry(Effect.tryPromise(() => this.driver!.list(params))));
|
||||
}
|
||||
|
||||
private async getWithRetry(threadId: string): Promise<IGetThreadResponse> {
|
||||
if (!this.driver) throw new Error('No driver available');
|
||||
|
||||
return Effect.runPromise(withGmailRetry(Effect.tryPromise(() => this.driver!.get(threadId))));
|
||||
return Effect.runPromise(withRetry(Effect.tryPromise(() => this.driver!.get(threadId))));
|
||||
}
|
||||
|
||||
async syncThreads(folder: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { activeDriverProcedure, router, privateProcedure } from '../trpc';
|
||||
import { updateWritingStyleMatrix } from '../../services/writing-style-service';
|
||||
import { activeDriverProcedure, router, privateProcedure } from '../trpc';
|
||||
import { IGetThreadResponseSchema } from '../../lib/driver/types';
|
||||
import { processEmailHtml } from '../../lib/email-processor';
|
||||
import { defaultPageSize, FOLDERS } from '../../lib/utils';
|
||||
@@ -153,7 +153,6 @@ export const mailRouter = router({
|
||||
|
||||
if (threadIds.length) {
|
||||
await agent.modifyLabels(threadIds, addLabels, removeLabels);
|
||||
console.log('Server: Successfully updated thread labels');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user