diff --git a/apps/mail/components/keyboard-layout-indicator.tsx b/apps/mail/components/keyboard-layout-indicator.tsx new file mode 100644 index 000000000..0cc7ea11b --- /dev/null +++ b/apps/mail/components/keyboard-layout-indicator.tsx @@ -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 ( +
+ + {getLayoutDisplayName(layoutInfo.layout)} +
+ ); +} + +export function useKeyboardLayout() { + const [layoutInfo, setLayoutInfo] = useState(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; +} diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 082ec0914..bf333cdd4 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -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'; diff --git a/apps/mail/components/party.tsx b/apps/mail/components/party.tsx index d17f2af2e..bd8e0c92a 100644 --- a/apps/mail/components/party.tsx +++ b/apps/mail/components/party.tsx @@ -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', diff --git a/apps/mail/components/voice-button.tsx b/apps/mail/components/voice-button.tsx index 9e10fdbb7..f559c29f4 100644 --- a/apps/mail/components/voice-button.tsx +++ b/apps/mail/components/voice-button.tsx @@ -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'; diff --git a/apps/mail/config/shortcuts.ts b/apps/mail/config/shortcuts.ts index 99dd4dd5e..4f7ebdd45 100644 --- a/apps/mail/config/shortcuts.ts +++ b/apps/mail/config/shortcuts.ts @@ -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; 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); diff --git a/apps/mail/hooks/use-optimistic-actions.ts b/apps/mail/hooks/use-optimistic-actions.ts index f61806627..93b5a072c 100644 --- a/apps/mail/hooks/use-optimistic-actions.ts +++ b/apps/mail/hooks/use-optimistic-actions.ts @@ -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: () => { diff --git a/apps/mail/lib/hotkeys/compose-hotkeys.tsx b/apps/mail/lib/hotkeys/compose-hotkeys.tsx index dc0e0b780..a348fb35e 100644 --- a/apps/mail/lib/hotkeys/compose-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/compose-hotkeys.tsx @@ -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 }); diff --git a/apps/mail/lib/hotkeys/global-hotkeys.tsx b/apps/mail/lib/hotkeys/global-hotkeys.tsx index b3cb6b394..ae99f779d 100644 --- a/apps/mail/lib/hotkeys/global-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/global-hotkeys.tsx @@ -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 }); diff --git a/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx b/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx index 0b6f36ad8..bdcf6e153 100644 --- a/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx @@ -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 }); diff --git a/apps/mail/lib/hotkeys/navigation-hotkeys.tsx b/apps/mail/lib/hotkeys/navigation-hotkeys.tsx index b0eec0f1d..2099600a6 100644 --- a/apps/mail/lib/hotkeys/navigation-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/navigation-hotkeys.tsx @@ -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 }); diff --git a/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx b/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx index 746bcdf8d..789711eb3 100644 --- a/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx @@ -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 }); diff --git a/apps/mail/lib/hotkeys/use-hotkey-utils.ts b/apps/mail/lib/hotkeys/use-hotkey-utils.ts index e3d059b9f..ee369abdb 100644 --- a/apps/mail/lib/hotkeys/use-hotkey-utils.ts +++ b/apps/mail/lib/hotkeys/use-hotkey-utils.ts @@ -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 = { 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; diff --git a/apps/mail/providers/client-providers.tsx b/apps/mail/providers/client-providers.tsx index c2692908b..453ee3fb0 100644 --- a/apps/mail/providers/client-providers.tsx +++ b/apps/mail/providers/client-providers.tsx @@ -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'; diff --git a/apps/mail/utils/keyboard-layout-map.ts b/apps/mail/utils/keyboard-layout-map.ts new file mode 100644 index 000000000..582cedb86 --- /dev/null +++ b/apps/mail/utils/keyboard-layout-map.ts @@ -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; + values(): IterableIterator; + entries(): IterableIterator<[string, string]>; + forEach(callback: (value: string, key: string) => void): void; + readonly size: number; +} + +declare global { + interface Navigator { + keyboard?: { + getLayoutMap(): Promise; + }; + } +} + +/** + * 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, + // 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, +); + +class KeyboardLayoutMapper { + private layoutMap: KeyboardLayoutMapAPI | null = null; + private isInitialized = false; + private detectedLayout: LayoutDetectionResult | null = null; + + /** + * Initialize the keyboard layout map + */ + async init(): Promise { + 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 { + // 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([ + ['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 | 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 { + if (!this.layoutMap) return {}; + + const mappings: Record = {}; + this.layoutMap.forEach((value, key) => { + mappings[key] = value; + }); + return mappings; + } + + /** + * Get all key codes as a Set for efficient lookups + */ + getKeyCodesSet(): Set { + if (!this.layoutMap) return new Set(); + return new Set(this.layoutMap.keys()); + } + + /** + * Get all key values as a Set for efficient lookups + */ + getKeyValuesSet(): Set { + 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, setB: Set): Set { + return new Set([...setA].filter((key) => setB.has(key))); + } + + /** + * Get union of two key sets + */ + getKeySetUnion(setA: Set, setB: Set): Set { + return new Set([...setA, ...setB]); + } + + /** + * Get difference between two key sets + */ + getKeySetDifference(setA: Set, setB: Set): Set { + 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); +} diff --git a/apps/mail/utils/keyboard-utils.ts b/apps/mail/utils/keyboard-utils.ts new file mode 100644 index 000000000..6a1dfe319 --- /dev/null +++ b/apps/mail/utils/keyboard-utils.ts @@ -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 = { + 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; +} diff --git a/apps/server/src/lib/driver/google.ts b/apps/server/src/lib/driver/google.ts index 4092d180b..e36ac2a85 100644 --- a/apps/server/src/lib/driver/google.ts +++ b/apps/server/src/lib/driver/google.ts @@ -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) { diff --git a/apps/server/src/lib/gmail-rate-limit.ts b/apps/server/src/lib/gmail-rate-limit.ts index 5ca55e411..113862b47 100644 --- a/apps/server/src/lib/gmail-rate-limit.ts +++ b/apps/server/src/lib/gmail-rate-limit.ts @@ -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( +export function withRetry( eff: Effect.Effect, ): Effect.Effect { - return eff.pipe(Effect.retry(gmailRateLimitSchedule)); + return eff.pipe(Effect.retry(rateLimitSchedule)); } diff --git a/apps/server/src/routes/chat.ts b/apps/server/src/routes/chat.ts index f3c03d229..66457b77b 100644 --- a/apps/server/src/routes/chat.ts +++ b/apps/server/src/routes/chat.ts @@ -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 { } 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 { }); } 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 { private async listWithRetry(params: Parameters[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 { 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) { diff --git a/apps/server/src/trpc/routes/mail.ts b/apps/server/src/trpc/routes/mail.ts index 7d563b19a..9a0a53862 100644 --- a/apps/server/src/trpc/routes/mail.ts +++ b/apps/server/src/trpc/routes/mail.ts @@ -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 }; }