diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..65e434bdf2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM oven/bun:canary + +WORKDIR /app + +# Install turbo globally +RUN bun install -g next turbo + + +COPY package.json bun.lock turbo.json ./ + +RUN mkdir -p apps packages + +COPY apps/*/package.json ./apps/ +COPY packages/*/package.json ./packages/ +COPY packages/tsconfig/ ./packages/tsconfig/ + +RUN bun install + +COPY . . + +# Installing with full context. Prevent missing dependencies error. +RUN bun install + + +RUN bun run build + +ENV NODE_ENV=production + +# Resolve Nextjs TextEncoder error. +ENV NODE_OPTIONS=--no-experimental-fetch + +EXPOSE 3000 + +CMD ["bun", "run", "start", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/apps/mail/app/(routes)/layout.tsx b/apps/mail/app/(routes)/layout.tsx index a589635d84..0089d1ccea 100644 --- a/apps/mail/app/(routes)/layout.tsx +++ b/apps/mail/app/(routes)/layout.tsx @@ -1,24 +1,27 @@ 'use client'; import { CommandPaletteProvider } from '@/components/context/command-palette-context'; +import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper'; import { dexieStorageProvider } from '@/lib/idb'; import { SWRConfig } from 'swr'; export default function Layout({ children }: { children: React.ReactNode }) { return ( - -
- - {children} - -
-
+ + +
+ + {children} + +
+
+
); } diff --git a/apps/mail/app/(routes)/mail/layout.tsx b/apps/mail/app/(routes)/mail/layout.tsx index a19215dc2e..10d8b89a92 100644 --- a/apps/mail/app/(routes)/mail/layout.tsx +++ b/apps/mail/app/(routes)/mail/layout.tsx @@ -1,12 +1,13 @@ -import { KeyboardShortcuts } from '@/components/mail/keyboard-shortcuts'; import { AppSidebar } from '@/components/ui/app-sidebar'; +import { GlobalHotkeys } from '@/lib/hotkeys/global-hotkeys'; +import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper'; export default function MailLayout({ children }: { children: React.ReactNode }) { return ( - <> + - +
{children}
- +
); } diff --git a/apps/mail/app/(routes)/settings/shortcuts/hotkey-recorder.tsx b/apps/mail/app/(routes)/settings/shortcuts/hotkey-recorder.tsx new file mode 100644 index 0000000000..f84a3a4591 --- /dev/null +++ b/apps/mail/app/(routes)/settings/shortcuts/hotkey-recorder.tsx @@ -0,0 +1,94 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import type { MessageKey } from '@/config/navigation'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; + +interface HotkeyRecorderProps { + isOpen: boolean; + onClose: () => void; + onHotkeyRecorded: (keys: string[]) => void; + currentKeys: string[]; +} + +export function HotkeyRecorder({ + isOpen, + onClose, + onHotkeyRecorded, + currentKeys, +}: HotkeyRecorderProps) { + const t = useTranslations(); + const [recordedKeys, setRecordedKeys] = useState([]); + const [isRecording, setIsRecording] = useState(false); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + if (!isRecording) return; + + const key = e.key === ' ' ? 'Space' : e.key; + + const formattedKey = key.length === 1 ? key.toUpperCase() : key; + + if (!recordedKeys.includes(formattedKey)) { + setRecordedKeys((prev) => [...prev, formattedKey]); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + e.preventDefault(); + if (isRecording) { + setIsRecording(false); + if (recordedKeys.length > 0) { + onHotkeyRecorded(recordedKeys); + onClose(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [isOpen, isRecording, recordedKeys, onHotkeyRecorded, onClose]); + + useEffect(() => { + if (isOpen) { + setRecordedKeys([]); + setIsRecording(true); + } + }, [isOpen]); + + return ( + + + + + {t('pages.settings.shortcuts.actions.recordHotkey' as MessageKey)} + + +
+
+ {isRecording + ? t('pages.settings.shortcuts.actions.pressKeys' as MessageKey) + : t('pages.settings.shortcuts.actions.releaseKeys' as MessageKey)} +
+
+ {(recordedKeys.length > 0 ? recordedKeys : currentKeys).map((key) => ( + + {key} + + ))} +
+
+
+
+ ); +} diff --git a/apps/mail/app/(routes)/settings/shortcuts/page.tsx b/apps/mail/app/(routes)/settings/shortcuts/page.tsx index 197bac1236..5c04c64775 100644 --- a/apps/mail/app/(routes)/settings/shortcuts/page.tsx +++ b/apps/mail/app/(routes)/settings/shortcuts/page.tsx @@ -1,16 +1,35 @@ 'use client'; import { SettingsCard } from '@/components/settings/settings-card'; -import { keyboardShortcuts } from '@/config/shortcuts'; //import the shortcuts +import { keyboardShortcuts, type Shortcut } from '@/config/shortcuts'; import type { MessageKey } from '@/config/navigation'; +import { HotkeyRecorder } from './hotkey-recorder'; +import { useState, type ReactNode, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; -import type { ReactNode } from 'react'; +import { formatDisplayKeys } from '@/lib/hotkeys/use-hotkey-utils'; +import { hotkeysDB } from '@/lib/hotkeys/hotkeys-db'; +import { toast } from 'sonner'; export default function ShortcutsPage() { - const shortcuts = keyboardShortcuts; //now gets shortcuts from the config file + const [shortcuts, setShortcuts] = useState(keyboardShortcuts); const t = useTranslations(); + useEffect(() => { + // Load any custom shortcuts from IndexedDB + hotkeysDB.getAllHotkeys() + .then(savedShortcuts => { + if (savedShortcuts.length > 0) { + const updatedShortcuts = keyboardShortcuts.map(defaultShortcut => { + const savedShortcut = savedShortcuts.find(s => s.action === defaultShortcut.action); + return savedShortcut || defaultShortcut; + }); + setShortcuts(updatedShortcuts); + } + }) + .catch(console.error); + }, []); + return (
- - +
} >
{shortcuts.map((shortcut, index) => ( - + {t(`pages.settings.shortcuts.actions.${shortcut.action}` as MessageKey)} ))} @@ -35,20 +72,57 @@ export default function ShortcutsPage() { ); } -function Shortcut({ children, keys }: { children: ReactNode; keys: string[] }) { +function Shortcut({ children, keys, action }: { children: ReactNode; keys: string[]; action: string }) { + const [isRecording, setIsRecording] = useState(false); + const displayKeys = formatDisplayKeys(keys); + + const handleHotkeyRecorded = async (newKeys: string[]) => { + try { + // Find the original shortcut to preserve its type and description + const originalShortcut = keyboardShortcuts.find(s => s.action === action); + if (!originalShortcut) { + throw new Error('Original shortcut not found'); + } + + const updatedShortcut: Shortcut = { + ...originalShortcut, + keys: newKeys, + }; + + await hotkeysDB.saveHotkey(updatedShortcut); + toast.success('Shortcut saved successfully'); + } catch (error) { + console.error('Failed to save shortcut:', error); + toast.error('Failed to save shortcut'); + } + }; + return ( -
- {children} -
- {keys.map((key) => ( - - {key} - - ))} + <> +
setIsRecording(true)} + role="button" + tabIndex={0} + > + {children} +
+ {displayKeys.map((key) => ( + + {key} + + ))} +
-
+ setIsRecording(false)} + onHotkeyRecorded={handleHotkeyRecorded} + currentKeys={keys} + /> + ); } diff --git a/apps/mail/app/api/v1/hotkeys/route.ts b/apps/mail/app/api/v1/hotkeys/route.ts new file mode 100644 index 0000000000..b66736ef22 --- /dev/null +++ b/apps/mail/app/api/v1/hotkeys/route.ts @@ -0,0 +1,91 @@ +import type { Shortcut } from '@/config/shortcuts'; +import { userHotkeys } from '@zero/db/schema'; +import { NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import { auth } from '@/lib/auth'; +import { eq } from 'drizzle-orm'; +import { db } from '@zero/db'; + +export async function GET() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const result = await db.select().from(userHotkeys).where(eq(userHotkeys.userId, session.user.id)); + + return NextResponse.json(result[0]?.shortcuts || []); +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const shortcuts = (await request.json()) as Shortcut[]; + const now = new Date(); + + await db + .insert(userHotkeys) + .values({ + userId: session.user.id, + shortcuts, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [userHotkeys.userId], + set: { + shortcuts, + updatedAt: now, + }, + }); + + return NextResponse.json({ success: true }); +} + +export async function PUT(request: Request) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const shortcut = (await request.json()) as Shortcut; + const now = new Date(); + + const result = await db.select().from(userHotkeys).where(eq(userHotkeys.userId, session.user.id)); + + const existingShortcuts = (result[0]?.shortcuts || []) as Shortcut[]; + const updatedShortcuts = existingShortcuts.map((s: Shortcut) => + s.action === shortcut.action ? shortcut : s, + ); + + if (!existingShortcuts.some((s: Shortcut) => s.action === shortcut.action)) { + updatedShortcuts.push(shortcut); + } + + await db + .insert(userHotkeys) + .values({ + userId: session.user.id, + shortcuts: updatedShortcuts, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [userHotkeys.userId], + set: { + shortcuts: updatedShortcuts, + updatedAt: now, + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/apps/mail/components/create/create-email.tsx b/apps/mail/components/create/create-email.tsx index a91396644b..80ba4024bb 100644 --- a/apps/mail/components/create/create-email.tsx +++ b/apps/mail/components/create/create-email.tsx @@ -1,10 +1,17 @@ 'use client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { UploadedFileIcon } from '@/components/create/uploaded-file-icon'; import { generateHTML, generateJSON } from '@tiptap/core'; import { useConnections } from '@/hooks/use-connections'; import { createDraft, getDraft } from '@/actions/drafts'; import { ArrowUpIcon, Paperclip, X } from 'lucide-react'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { Separator } from '@/components/ui/separator'; import { SidebarToggle } from '../ui/sidebar-toggle'; import Paragraph from '@tiptap/extension-paragraph'; @@ -23,17 +30,12 @@ import Bold from '@tiptap/extension-bold'; import { type JSONContent } from 'novel'; import { useQueryState } from 'nuqs'; import { Plus } from 'lucide-react'; +import { useEffect } from 'react'; +import posthog from 'posthog-js'; import { toast } from 'sonner'; import * as React from 'react'; import Editor from './editor'; import './prosemirror.css'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import posthog from 'posthog-js'; const MAX_VISIBLE_ATTACHMENTS = 12; @@ -62,9 +64,8 @@ const filterContacts = (contacts: any[], searchTerm: string, excludeEmails: stri const term = searchTerm.toLowerCase(); return contacts.filter( (contact) => - (contact.email?.toLowerCase().includes(term) || - contact.name?.toLowerCase().includes(term)) && - !excludeEmails.includes(contact.email) + (contact.email?.toLowerCase().includes(term) || contact.name?.toLowerCase().includes(term)) && + !excludeEmails.includes(contact.email), ); }; @@ -99,6 +100,7 @@ export function CreateEmail({ const [draftId, setDraftId] = useQueryState('draftId'); const [includeSignature, setIncludeSignature] = React.useState(true); const { settings } = useSettings(); + const { enableScope, disableScope } = useHotkeysContext(); const [isCardHovered, setIsCardHovered] = React.useState(false); const dragCounter = React.useRef(0); @@ -129,17 +131,17 @@ export function CreateEmail({ const filteredContacts = React.useMemo( () => filterContacts(contacts, toInput, toEmails), - [contacts, toInput, toEmails] + [contacts, toInput, toEmails], ); const filteredCcContacts = React.useMemo( () => filterContacts(contacts, ccInput, [...toEmails, ...ccEmails]), - [contacts, ccInput, toEmails, ccEmails] + [contacts, ccInput, toEmails, ccEmails], ); const filteredBccContacts = React.useMemo( () => filterContacts(contacts, bccInput, [...toEmails, ...ccEmails, ...bccEmails]), - [contacts, bccInput, toEmails, ccEmails, bccEmails] + [contacts, bccInput, toEmails, ccEmails, bccEmails], ); React.useEffect(() => { @@ -459,6 +461,16 @@ export function CreateEmail({ } }, [initialTo, initialSubject, initialBody, defaultValue]); + useEffect(() => { + console.log('Enabling compose scope (CreateEmail)'); + enableScope('compose'); + + return () => { + console.log('Disabling compose scope (CreateEmail)'); + disableScope('compose'); + }; + }, [enableScope, disableScope]); + const toDropdownRef = React.useRef(null); const ccDropdownRef = React.useRef(null); const bccDropdownRef = React.useRef(null); @@ -506,8 +518,13 @@ export function CreateEmail({
-
-
+
+
{isDragging && isCardHovered && (
@@ -516,7 +533,7 @@ export function CreateEmail({
)} -
+
@@ -544,55 +561,58 @@ export function CreateEmail({
))} -
- handleEmailInputChange('to', e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (filteredContacts.length > 0) { - const selectedEmail = filteredContacts[selectedContactIndex]?.email; - if (selectedEmail) handleAddEmail('to', selectedEmail); - setSelectedContactIndex(0); - } else { - handleAddEmail('to', toInput); - } - } else if (e.key === 'ArrowDown' && filteredContacts.length > 0) { - e.preventDefault(); - setSelectedContactIndex((prev) => - Math.min(prev + 1, filteredContacts.length - 1), - ); - } else if (e.key === 'ArrowUp' && filteredContacts.length > 0) { - e.preventDefault(); - setSelectedContactIndex((prev) => Math.max(prev - 1, 0)); - } - }} - /> - {toInput && filteredContacts.length > 0 && ( -
- {filteredContacts.map((contact, index) => ( - - ))} -
- )} + } else { + handleAddEmail('to', toInput); + } + } else if (e.key === 'ArrowDown' && filteredContacts.length > 0) { + e.preventDefault(); + setSelectedContactIndex((prev) => + Math.min(prev + 1, filteredContacts.length - 1), + ); + } else if (e.key === 'ArrowUp' && filteredContacts.length > 0) { + e.preventDefault(); + setSelectedContactIndex((prev) => Math.max(prev - 1, 0)); + } + }} + /> + {toInput && filteredContacts.length > 0 && ( +
+ {filteredContacts.map((contact, index) => ( + + ))} +
+ )}
))} -
+
0) { - const selectedEmail = filteredCcContacts[selectedCcContactIndex]?.email; + const selectedEmail = + filteredCcContacts[selectedCcContactIndex]?.email; if (selectedEmail) { handleAddEmail('cc', selectedEmail); setSelectedCcContactIndex(0); @@ -689,7 +710,10 @@ export function CreateEmail({ }} /> {ccInput && filteredCcContacts.length > 0 && ( -
+
{filteredCcContacts.map((contact, index) => ( ))} @@ -739,7 +765,7 @@ export function CreateEmail({
))} -
+
0) { - const selectedEmail = filteredBccContacts[selectedBccContactIndex]?.email; + const selectedEmail = + filteredBccContacts[selectedBccContactIndex]?.email; if (selectedEmail) { handleAddEmail('bcc', selectedEmail); setSelectedBccContactIndex(0); @@ -772,7 +799,10 @@ export function CreateEmail({ }} /> {bccInput && filteredBccContacts.length > 0 && ( -
+
{filteredBccContacts.map((contact, index) => ( ))} @@ -837,7 +869,7 @@ export function CreateEmail({
-
+
@@ -897,9 +929,7 @@ export function CreateEmail({ console.log('CreateEmail: Successfully applied AI content'); } catch (error) { console.error('CreateEmail: Error applying AI content', error); - toast.error( - 'Error applying AI content to your email. Please try again.', - ); + toast.error('Error applying AI content to your email. Please try again.'); } }} /> @@ -914,8 +944,8 @@ export function CreateEmail({ {attachments.length}{' '} {t('common.replyCompose.attachmentCount', { - count: attachments.length, - })} + count: attachments.length, + })} @@ -928,8 +958,8 @@ export function CreateEmail({

{attachments.length}{' '} {t('common.replyCompose.fileCount', { - count: attachments.length, - })} + count: attachments.length, + })}

@@ -982,16 +1012,14 @@ export function CreateEmail({
-
: null +
+
+ ) : null; - if (demo) return demoContent + if (demo) return demoContent; - const content = latestMessage && getThreadData ? ( -
+ const content = + latestMessage && getThreadData ? ( +

- + {highlightText(latestMessage.sender.name, searchValue.highlight)} {' '} {getThreadData.hasUnread && !isMailSelected ? ( @@ -332,9 +340,9 @@ const Thread = memo(

+
-
- ) : null; + ) : null; return latestMessage ? ( { const [threadId, setThreadId] = useQueryState('threadId'); const [category, setCategory] = useQueryState('category'); const [searchValue, setSearchValue] = useSearchValue(); + const { enableScope, disableScope } = useHotkeysContext(); const { data: { threads: items, nextPageToken }, isValidating, isLoading, loadMore, mutate, - isReachingEnd + isReachingEnd, } = useThreads(); const allCategories = Categories(); @@ -483,7 +492,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { } // Otherwise select all items else if (items.length > 0) { - // TODO: debug + // TODO: debug const allIds = items.map((item) => item.id); setMail((prev) => ({ ...prev, @@ -494,74 +503,6 @@ export const MailList = memo(({ isCompact }: MailListProps) => { } }, [items, setMail, mail.bulkSelected, t]); - useHotKey('Meta+Shift+u', () => { - markAsUnread({ ids: mail.bulkSelected }).then((result) => { - if (result.success) { - toast.success(t('common.mail.markedAsUnread')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsUnread')); - }); - }); - - useHotKey('Control+Shift+u', () => { - markAsUnread({ ids: mail.bulkSelected }).then((response) => { - if (response.success) { - toast.success(t('common.mail.markedAsUnread')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsUnread')); - }); - }); - - useHotKey('Meta+Shift+i', () => { - markAsRead({ ids: mail.bulkSelected }).then((data) => { - if (data.success) { - toast.success(t('common.mail.markedAsRead')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsRead')); - }); - }); - - useHotKey('Control+Shift+i', () => { - markAsRead({ ids: mail.bulkSelected }).then((response) => { - if (response.success) { - toast.success(t('common.mail.markedAsRead')); - setMail((prev) => ({ - ...prev, - bulkSelected: [], - })); - } else toast.error(t('common.mail.failedToMarkAsRead')); - }); - }); - - // useHotKey('Meta+a', (event) => { - // event?.preventDefault(); - // selectAll(); - // }); - - useHotKey('Control+a', (event) => { - event?.preventDefault(); - selectAll(); - }); - - // useHotKey('Meta+n', (event) => { - // event?.preventDefault(); - // selectAll(); - // }); - - // useHotKey('Control+n', (event) => { - // event?.preventDefault(); - // selectAll(); - // }); - const getSelectMode = useCallback((): MailSelectMode => { if (isKeyPressed('Control') || isKeyPressed('Meta')) { return 'mass'; @@ -612,6 +553,14 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
{ + console.log('[MailList] Mouse Enter - Enabling scope: mail-list'); + enableScope('mail-list'); + }} + onMouseLeave={() => { + console.log('[MailList] Mouse Leave - Disabling scope: mail-list'); + disableScope('mail-list'); + }} > {items.map((data, index) => { @@ -688,7 +637,7 @@ const MailLabels = memo( {getLabelIcon(label)} - + {t('common.notes.title')} @@ -734,7 +683,7 @@ const MailLabels = memo( {getLabelIcon(label)} - + {labelContent} diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 16922e03d9..35454b8a23 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -52,7 +52,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { clearBulkSelectionAtom } from './use-mail'; import { useThreads } from '@/hooks/use-threads'; import { Button } from '@/components/ui/button'; -import { useHotKey } from '@/hooks/use-hot-key'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { useSession } from '@/lib/auth-client'; import { useStats } from '@/hooks/use-stats'; import { useRouter } from 'next/navigation'; @@ -218,6 +218,7 @@ export function MailLayout() { const { data: session, isPending } = useSession(); const t = useTranslations(); const prevFolderRef = useRef(folder); + const { enableScope, disableScope } = useHotkeysContext(); useEffect(() => { if (prevFolderRef.current !== folder && mail.bulkSelected.length > 0) { @@ -250,15 +251,27 @@ export function MailLayout() { const [threadId, setThreadId] = useQueryState('threadId'); - const handleClose = () => { - setThreadId(null); - } + useEffect(() => { + if (threadId) { + console.log('Enabling thread-display scope, disabling mail-list'); + enableScope('thread-display'); + disableScope('mail-list'); + } else { + console.log('Enabling mail-list scope, disabling thread-display'); + enableScope('mail-list'); + disableScope('thread-display'); + } - // Search bar is always visible now, no need for keyboard shortcuts to toggle it - useHotKey('Esc', (event) => { - event?.preventDefault(); - // Handle other Esc key functionality if needed - }); + return () => { + console.log('Cleaning up mail/thread scopes'); + disableScope('thread-display'); + disableScope('mail-list'); + }; + }, [threadId, enableScope, disableScope]); + + const handleClose = useCallback(() => { + setThreadId(null); + }, [setThreadId]); // Add mailto protocol handler registration useEffect(() => { diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index cb64149e48..984f956930 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -20,19 +20,13 @@ import { Forward, ReplyAll, } from 'lucide-react'; -import { - type Dispatch, - type SetStateAction, - useRef, - useState, - useEffect, - useCallback, - useReducer, -} from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useRef, useState, useEffect, useCallback, useReducer } from 'react'; import { UploadedFileIcon } from '@/components/create/uploaded-file-icon'; -import { useForm, SubmitHandler, useWatch } from 'react-hook-form'; +import { extractTextFromHTML } from '@/actions/extractText'; +import { useForm, SubmitHandler } from 'react-hook-form'; import { generateAIResponse } from '@/actions/ai-reply'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { Separator } from '@/components/ui/separator'; import { useMail } from '@/components/mail/use-mail'; import { useSettings } from '@/hooks/use-settings'; @@ -40,19 +34,17 @@ import Editor from '@/components/create/editor'; import { Button } from '@/components/ui/button'; import { useThread } from '@/hooks/use-threads'; import { useSession } from '@/lib/auth-client'; +import { createDraft } from '@/actions/drafts'; import { useTranslations } from 'next-intl'; import { sendEmail } from '@/actions/send'; import type { JSONContent } from 'novel'; import { useQueryState } from 'nuqs'; +import { Input } from '../ui/input'; +import posthog from 'posthog-js'; import { Sender } from '@/types'; import { toast } from 'sonner'; import type { z } from 'zod'; -import { createDraft } from '@/actions/drafts'; -import { extractTextFromHTML } from '@/actions/extractText'; -import { Input } from '../ui/input'; -import posthog from 'posthog-js'; - // Utility function to check if an email is a noreply address const isNoReplyAddress = (email: string): boolean => { const lowerEmail = email.toLowerCase(); @@ -166,6 +158,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const [mail, setMail] = useMail(); const { settings } = useSettings(); const [draftId, setDraftId] = useQueryState('draftId'); + const { enableScope, disableScope } = useHotkeysContext(); const [isEditingRecipients, setIsEditingRecipients] = useState(false); const [showCc, setShowCc] = useState(false); const [showBcc, setShowBcc] = useState(false); @@ -250,7 +243,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { } if (!emailData) return; try { - const originalEmail = emailData.latest + const originalEmail = emailData.latest; const userEmail = session?.activeConnection?.email?.toLowerCase(); if (!userEmail) { @@ -276,16 +269,16 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const ccRecipients: Sender[] | undefined = showCc ? ccEmails.map((email) => ({ - email, - name: email.split('@')[0] || 'User', - })) + email, + name: email.split('@')[0] || 'User', + })) : undefined; const bccRecipients: Sender[] | undefined = showBcc ? bccEmails.map((email) => ({ - email, - name: email.split('@')[0] || 'User', - })) + email, + name: email.split('@')[0] || 'User', + })) : undefined; const messageId = originalEmail.messageId; @@ -302,7 +295,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { quotedMessage, ); - const inReplyTo = messageId + const inReplyTo = messageId; const existingRefs = originalEmail.references?.split(' ') || []; const references = [...existingRefs, originalEmail?.inReplyTo, cleanEmailAddress(messageId)] .filter(Boolean) @@ -320,10 +313,9 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { References: references, 'Thread-Id': threadId ?? '', }, - threadId + threadId, }).then(() => mutate()); - - + if (ccRecipients && bccRecipients) { posthog.capture('Reply Email Sent with CC and BCC'); } else if (ccRecipients) { @@ -509,15 +501,15 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const isMessageEmpty = !getValues('messageContent') || getValues('messageContent') === - JSON.stringify({ - type: 'doc', - content: [ - { - type: 'paragraph', - content: [], - }, - ], - }); + JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [], + }, + ], + }); // Check if form is valid for submission const isFormValid = !isMessageEmpty || attachments.length > 0; @@ -532,16 +524,20 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const originalSender = latestEmail?.sender?.name || 'the recipient'; // Create a summary of the thread content for context - const threadContent = (await Promise.all(emailData.messages.map(async (email) => { - const body = await extractTextFromHTML(email.decodedBody || 'No content'); - return ` + const threadContent = ( + await Promise.all( + emailData.messages.map(async (email) => { + const body = await extractTextFromHTML(email.decodedBody || 'No content'); + return ` ${email.sender?.name || 'Unknown'} <${email.sender?.email || 'unknown@email.com'}> ${email.subject || 'No Subject'} ${new Date(email.receivedOn || '').toLocaleString()} ${body} `; - }))).join('\n\n'); + }), + ) + ).join('\n\n'); const suggestion = await generateAIResponse(threadContent, originalSender); aiDispatch({ type: 'SET_SUGGESTION', payload: suggestion }); @@ -705,7 +701,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { if (isEditingRecipients || mode === 'forward') { return (
-
+
{icon} @@ -925,6 +921,21 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { } }, [mode, emailData, getValues, attachments, draftId, setDraftId]); + useEffect(() => { + if (composerIsOpen) { + console.log('Enabling compose scope (ReplyCompose)'); + enableScope('compose'); + } else { + console.log('Disabling compose scope (ReplyCompose)'); + disableScope('compose'); + } + + return () => { + console.log('Cleaning up compose scope (ReplyCompose)'); + disableScope('compose'); + }; + }, [composerIsOpen, enableScope, disableScope]); + // Simplified composer visibility check if (!composerIsOpen) { if (!emailData || emailData.messages.length === 0) return null; @@ -1038,7 +1049,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { {composerState.isDragging && } {/* Header */} -
+
{renderHeaderContent()}
diff --git a/apps/mail/components/providers/hotkey-provider-wrapper.tsx b/apps/mail/components/providers/hotkey-provider-wrapper.tsx new file mode 100644 index 0000000000..cc89ddc636 --- /dev/null +++ b/apps/mail/components/providers/hotkey-provider-wrapper.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { HotkeysProvider } from 'react-hotkeys-hook'; +import { GlobalHotkeys } from '@/lib/hotkeys/global-hotkeys'; +import { MailListHotkeys } from '@/lib/hotkeys/mail-list-hotkeys'; +import { ThreadDisplayHotkeys } from '@/lib/hotkeys/thread-display-hotkeys'; +import { ComposeHotkeys } from '@/lib/hotkeys/compose-hotkeys'; +import React from 'react'; + +interface HotkeyProviderWrapperProps { + children: React.ReactNode; +} + +export function HotkeyProviderWrapper({ children }: HotkeyProviderWrapperProps) { + return ( + + + + + + {children} + + ); +} \ No newline at end of file diff --git a/apps/mail/components/ui/ai-sidebar.tsx b/apps/mail/components/ui/ai-sidebar.tsx index 126385c7cb..59771a8cff 100644 --- a/apps/mail/components/ui/ai-sidebar.tsx +++ b/apps/mail/components/ui/ai-sidebar.tsx @@ -6,15 +6,13 @@ import { X, MessageSquare } from 'lucide-react'; import { useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { createContext, useContext } from 'react'; interface AISidebarProps { className?: string; } -// Create a context to manage the AI sidebar state globally -import { createContext, useContext } from 'react'; -import { useHotKey } from '@/hooks/use-hot-key'; - type AISidebarContextType = { open: boolean; setOpen: (open: boolean) => void; @@ -47,14 +45,6 @@ export function AISidebarProvider({ children }: { children: React.ReactNode }) { export function AISidebar({ className }: AISidebarProps) { const { open, setOpen } = useAISidebar(); - useHotKey('Meta+0', () => { - setOpen(!open); - }); - - useHotKey('Control+0', () => { - setOpen(!open); - }); - return ( <>