From 7a9e603f560d6589a4eabd36360cec0230291451 Mon Sep 17 00:00:00 2001 From: Ahmet Kilinc Date: Tue, 15 Jul 2025 17:58:48 +0100 Subject: [PATCH] new create email tabs --- apps/mail/app/(routes)/mail/compose/page.tsx | 53 --- apps/mail/app/(routes)/mail/layout.tsx | 2 + apps/mail/app/routes.ts | 1 - .../context/command-palette-context.tsx | 5 +- apps/mail/components/create/compose-tabs.tsx | 328 ++++++++++++++++++ .../mail/components/create/email-composer.tsx | 37 +- apps/mail/components/mail/thread-display.tsx | 9 +- apps/mail/components/ui/app-sidebar.tsx | 70 ++-- apps/mail/lib/hotkeys/global-hotkeys.tsx | 6 +- apps/mail/store/composeTabsStore.ts | 125 +++++++ 10 files changed, 526 insertions(+), 110 deletions(-) delete mode 100644 apps/mail/app/(routes)/mail/compose/page.tsx create mode 100644 apps/mail/components/create/compose-tabs.tsx create mode 100644 apps/mail/store/composeTabsStore.ts diff --git a/apps/mail/app/(routes)/mail/compose/page.tsx b/apps/mail/app/(routes)/mail/compose/page.tsx deleted file mode 100644 index 1f873d1e3..000000000 --- a/apps/mail/app/(routes)/mail/compose/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { CreateEmail } from '@/components/create/create-email'; -import { authProxy } from '@/lib/auth-proxy'; -import { useLoaderData } from 'react-router'; -import type { Route } from './+types/page'; - -export async function clientLoader({ request }: Route.ClientLoaderArgs) { - const session = await authProxy.api.getSession({ headers: request.headers }); - if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`); - const url = new URL(request.url); - if (url.searchParams.get('to')?.startsWith('mailto:')) { - return Response.redirect( - `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`, - ); - } - - return Object.fromEntries(url.searchParams.entries()) as { - to?: string; - subject?: string; - body?: string; - draftId?: string; - cc?: string; - bcc?: string; - }; -} - -export default function ComposePage() { - const params = useLoaderData(); - - return ( - - - - - - - - - ); -} diff --git a/apps/mail/app/(routes)/mail/layout.tsx b/apps/mail/app/(routes)/mail/layout.tsx index d2da273de..f24175b2b 100644 --- a/apps/mail/app/(routes)/mail/layout.tsx +++ b/apps/mail/app/(routes)/mail/layout.tsx @@ -1,4 +1,5 @@ import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper'; +import { ComposeTabs } from '@/components/create/compose-tabs'; import { OnboardingWrapper } from '@/components/onboarding'; import { AppSidebar } from '@/components/ui/app-sidebar'; import { Outlet } from 'react-router'; @@ -10,6 +11,7 @@ export default function MailLayout() {
+ ); diff --git a/apps/mail/app/routes.ts b/apps/mail/app/routes.ts index 57d9fbe23..a1589e1ec 100644 --- a/apps/mail/app/routes.ts +++ b/apps/mail/app/routes.ts @@ -28,7 +28,6 @@ export default [ prefix('/mail', [ index('(routes)/mail/page.tsx'), route('/create', '(routes)/mail/create/page.tsx'), - route('/compose', '(routes)/mail/compose/page.tsx'), route('/under-construction/:path', '(routes)/mail/under-construction/[path]/page.tsx'), route('/:folder', '(routes)/mail/[folder]/page.tsx'), ]), diff --git a/apps/mail/components/context/command-palette-context.tsx b/apps/mail/components/context/command-palette-context.tsx index 92f8ef3c1..7b420f41d 100644 --- a/apps/mail/components/context/command-palette-context.tsx +++ b/apps/mail/components/context/command-palette-context.tsx @@ -38,6 +38,7 @@ import { } from '@/components/ui/command'; import { getMainSearchTerm, parseNaturalLanguageSearch } from '@/lib/utils'; import { DialogDescription, DialogTitle } from '@/components/ui/dialog'; +import { addComposeTabAtom } from '@/store/composeTabsStore'; import { useSearchValue } from '@/hooks/use-search-value'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useLocation, useNavigate } from 'react-router'; @@ -57,6 +58,7 @@ import { m } from '@/paraglide/messages'; import { Pencil2 } from '../icons/icons'; import { Button } from '../ui/button'; import { useQueryState } from 'nuqs'; +import { useSetAtom } from 'jotai'; import { toast } from 'sonner'; type CommandPaletteContext = { @@ -193,6 +195,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { const [commandInputValue, setCommandInputValue] = useState(''); const navigate = useNavigate(); const { pathname } = useLocation(); + const addTab = useSetAtom(addComposeTabAtom); const { userLabels = [] } = useLabels(); const trpc = useTRPC(); @@ -656,7 +659,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { icon: Pencil2, shortcut: 'c', onClick: () => { - setIsComposeOpen('true'); + addTab({}); }, }); diff --git a/apps/mail/components/create/compose-tabs.tsx b/apps/mail/components/create/compose-tabs.tsx new file mode 100644 index 000000000..3125b3acb --- /dev/null +++ b/apps/mail/components/create/compose-tabs.tsx @@ -0,0 +1,328 @@ +import { + activeComposeTabIdAtom, + addComposeTabAtom, + composeTabsAtom, + fullscreenTabIdAtom, + removeComposeTabAtom, + toggleFullscreenTabAtom, + toggleMinimizeTabAtom, + updateComposeTabAtom, +} from '@/store/composeTabsStore'; + +import { Maximize2, Minimize2, Minus, Plus, X } from 'lucide-react'; +import { useActiveConnection } from '@/hooks/use-connections'; +import { useEmailAliases } from '@/hooks/use-email-aliases'; +import { AnimatePresence, motion } from 'motion/react'; +import { useTRPC } from '@/providers/query-provider'; +import { useMutation } from '@tanstack/react-query'; +import { useSettings } from '@/hooks/use-settings'; +import { EmailComposer } from './email-composer'; +import { Button } from '@/components/ui/button'; +import { useSession } from '@/lib/auth-client'; +import { serializeFiles } from '@/lib/schemas'; +import { useAtom, useSetAtom } from 'jotai'; +import { toast } from 'sonner'; + +export function ComposeTabs() { + const [composeTabs] = useAtom(composeTabsAtom); + const [activeTabId, setActiveTabId] = useAtom(activeComposeTabIdAtom); + const [fullscreenTabId] = useAtom(fullscreenTabIdAtom); + const addTab = useSetAtom(addComposeTabAtom); + const removeTab = useSetAtom(removeComposeTabAtom); + const updateTab = useSetAtom(updateComposeTabAtom); + const toggleMinimize = useSetAtom(toggleMinimizeTabAtom); + const toggleFullscreen = useSetAtom(toggleFullscreenTabAtom); + + const { data: session } = useSession(); + const { data: activeConnection } = useActiveConnection(); + const { data: aliases } = useEmailAliases(); + const { data: settings, isLoading: settingsLoading } = useSettings(); + + const trpc = useTRPC(); + const { mutateAsync: sendEmail } = useMutation(trpc.mail.send.mutationOptions()); + + const userEmail = activeConnection?.email || session?.user?.email || ''; + + const handleAddTab = () => { + addTab({}); + }; + + const handleCloseTab = (tabId: string, e: React.MouseEvent) => { + e.stopPropagation(); + const tab = composeTabs.get(tabId); + + if (tab && (tab.body || tab.subject || tab.to?.length)) { + toast.error('Unsaved changes', { + description: 'You have unsaved changes. Are you sure you want to close this tab?', + action: { + label: 'Close anyway', + onClick: () => removeTab(tabId), + }, + }); + return; + } + + removeTab(tabId); + }; + + const handleMinimizeTab = (tabId: string, e: React.MouseEvent) => { + e.stopPropagation(); + toggleMinimize(tabId); + }; + + const handleFullscreenTab = (tabId: string, e: React.MouseEvent) => { + e.stopPropagation(); + toggleFullscreen(tabId); + }; + + const handleSendEmail = async ( + tabId: string, + data: { + to: string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + message: string; + attachments: File[]; + fromEmail?: string; + }, + ) => { + const fromEmail = data.fromEmail || aliases?.[0]?.email || userEmail; + + if (!fromEmail) { + toast.error('No email address available to send from'); + return; + } + + const zeroSignature = settings?.settings.zeroSignature + ? '

Sent via Zero

' + : ''; + + try { + await sendEmail({ + to: data.to.map((email) => ({ email, name: email?.split('@')[0] || email })), + cc: data.cc?.map((email) => ({ email, name: email?.split('@')[0] || email })), + bcc: data.bcc?.map((email) => ({ email, name: email?.split('@')[0] || email })), + subject: data.subject, + message: data.message + zeroSignature, + threadId: undefined, + attachments: data.attachments.length > 0 ? await serializeFiles(data.attachments) : [], + fromEmail: fromEmail, + }); + + toast.success('Email sent successfully'); + removeTab(tabId); + } catch (error) { + console.error('Error sending email:', error); + toast.error('Failed to send email'); + } + }; + + const tabs = Array.from(composeTabs.entries()); + + if (tabs.length === 0) { + return null; + } + + const isFullscreen = !!fullscreenTabId; + const fullscreenTab = fullscreenTabId ? composeTabs.get(fullscreenTabId) : null; + + if (isFullscreen && fullscreenTab) { + return ( + +
+
+

{fullscreenTab.subject || 'New Email'}

+
+ + +
+
+
+ handleSendEmail(fullscreenTabId, data)} + onClose={() => removeTab(fullscreenTabId)} + onChange={(updates) => updateTab({ id: fullscreenTabId, updates })} + className="h-full" + autofocus={true} + settingsLoading={settingsLoading} + /> +
+
+
+ ); + } + + return ( + <> +
+ + {Array.from(composeTabs.values()).map((tab) => { + const index = Array.from(composeTabs.values()).indexOf(tab); + + return ( + + + {tab.isMinimized ? ( + toggleMinimize(tab.id)} + > + + {tab.to || tab.subject || 'New Email'} + + + + ) : ( + +
+

{tab.subject || 'New Email'}

+
+ + + +
+
+ +
+ handleSendEmail(tab.id, data)} + onClose={() => removeTab(tab.id)} + onChange={(updates) => updateTab({ id: tab.id, updates })} + className="h-full" + autofocus={activeTabId === tab.id} + settingsLoading={settingsLoading} + /> +
+
+ )} +
+
+ ); + })} +
+ + + + +
+ + ); +} diff --git a/apps/mail/components/create/email-composer.tsx b/apps/mail/components/create/email-composer.tsx index e6bf47fb6..180ef6e0f 100644 --- a/apps/mail/components/create/email-composer.tsx +++ b/apps/mail/components/create/email-composer.tsx @@ -79,10 +79,20 @@ interface EmailComposerProps { scheduleAt?: string; }) => Promise; onClose?: () => void; + onChange?: (updates: { + to?: string[]; + cc?: string[]; + bcc?: string[]; + subject?: string; + body?: string; + attachments?: File[]; + }) => void; className?: string; autofocus?: boolean; settingsLoading?: boolean; editorClassName?: string; + draftId?: string | null; + inATab?: boolean; } @@ -108,10 +118,13 @@ export function EmailComposer({ initialAttachments = [], onSendEmail, onClose, + onChange, className, autofocus = false, settingsLoading = false, editorClassName, + draftId: propDraftId, + inATab = false, }: EmailComposerProps) { const { data: aliases } = useEmailAliases(); const { data: settings } = useSettings(); @@ -126,6 +139,7 @@ export function EmailComposer({ const [isComposeOpen, setIsComposeOpen] = useQueryState('isComposeOpen'); const { data: emailData } = useThread(threadId ?? null); const [draftId, setDraftId] = useQueryState('draftId'); + const currentDraftId = propDraftId || draftId; const [aiGeneratedMessage, setAiGeneratedMessage] = useState(null); const [aiIsLoading, setAiIsLoading] = useState(false); const [isGeneratingSubject, setIsGeneratingSubject] = useState(false); @@ -520,6 +534,21 @@ export function EmailComposer({ setShowLeaveConfirmation(false); }; + // Add useEffect to notify parent of changes + useEffect(() => { + if (onChange && hasUnsavedChanges) { + const values = getValues(); + onChange({ + to: values.to, + cc: showCc ? values.cc : undefined, + bcc: showBcc ? values.bcc : undefined, + subject: values.subject, + body: editor?.getHTML() || '', + attachments: values.attachments, + }); + } + }, [hasUnsavedChanges, getValues, showCc, showBcc, editor, onChange]); + // Component unmount protection useEffect(() => { return () => { @@ -614,7 +643,11 @@ export function EmailComposer({ return (
@@ -647,7 +680,7 @@ export function EmailComposer({ > Bcc - {onClose && ( + {!inATab && onClose && (
- - - - - - + ); } diff --git a/apps/mail/lib/hotkeys/global-hotkeys.tsx b/apps/mail/lib/hotkeys/global-hotkeys.tsx index ae99f779d..317dc704b 100644 --- a/apps/mail/lib/hotkeys/global-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/global-hotkeys.tsx @@ -1,18 +1,20 @@ import { useCommandPalette } from '@/components/context/command-palette-context'; import { useOptimisticActions } from '@/hooks/use-optimistic-actions'; import { enhancedKeyboardShortcuts } from '@/config/shortcuts'; +import { addComposeTabAtom } from '@/store/composeTabsStore'; import { useShortcuts } from './use-hotkey-utils'; import { useQueryState } from 'nuqs'; +import { useSetAtom } from 'jotai'; export function GlobalHotkeys() { - const [, setComposeOpen] = useQueryState('isComposeOpen'); const { clearAllFilters } = useCommandPalette(); const [, setIsCommandPaletteOpen] = useQueryState('isCommandPaletteOpen'); const { undoLastAction } = useOptimisticActions(); + const addTab = useSetAtom(addComposeTabAtom); const scope = 'global'; const handlers = { - newEmail: () => setComposeOpen('true'), + newEmail: () => addTab({}), commandPalette: () => setIsCommandPaletteOpen('true'), clearAllFilters: () => clearAllFilters(), undoLastAction: () => { diff --git a/apps/mail/store/composeTabsStore.ts b/apps/mail/store/composeTabsStore.ts new file mode 100644 index 000000000..c1f502aa3 --- /dev/null +++ b/apps/mail/store/composeTabsStore.ts @@ -0,0 +1,125 @@ +import { atomWithStorage } from 'jotai/utils'; +import { atom } from 'jotai'; + +export interface ComposeTab { + id: string; + to?: string[]; + cc?: string[]; + bcc?: string[]; + subject?: string; + body?: string; + draftId?: string | null; + attachments?: File[]; + createdAt: number; + lastModified: number; + isMinimized?: boolean; +} + +export const composeTabsAtom = atomWithStorage>('composeTabs', new Map(), { + getItem: (key, initialValue): Map => { + const stored = localStorage.getItem(key); + if (!stored) return initialValue; + try { + const parsed = JSON.parse(stored); + return new Map(parsed); + } catch { + return initialValue; + } + }, + setItem: (key, value) => { + localStorage.setItem(key, JSON.stringify(Array.from(value.entries()))); + }, + removeItem: (key) => localStorage.removeItem(key), +}); + +export const activeComposeTabIdAtom = atom(null); +export const fullscreenTabIdAtom = atom(null); + +export const addComposeTabAtom = atom(null, async (get, set, tab: Partial) => { + const tabs = await get(composeTabsAtom); + const id = `compose-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const newTab: ComposeTab = { + id, + createdAt: Date.now(), + lastModified: Date.now(), + isMinimized: false, + ...tab, + }; + + const newTabs = new Map(tabs); + newTabs.set(id, newTab); + set(composeTabsAtom, newTabs); + set(activeComposeTabIdAtom, id); + + return id; +}); + +export const removeComposeTabAtom = atom(null, async (get, set, tabId: string) => { + const tabs = await get(composeTabsAtom); + const newTabs = new Map(tabs); + newTabs.delete(tabId); + set(composeTabsAtom, newTabs); + + if (get(fullscreenTabIdAtom) === tabId) { + set(fullscreenTabIdAtom, null); + } + + if (get(activeComposeTabIdAtom) === tabId) { + const remainingTabs = Array.from(newTabs.keys()); + set( + activeComposeTabIdAtom, + remainingTabs.length > 0 ? remainingTabs[remainingTabs.length - 1] : null, + ); + } +}); + +export const updateComposeTabAtom = atom( + null, + async (get, set, { id, updates }: { id: string; updates: Partial }) => { + const tabs = await get(composeTabsAtom); + const tab = tabs.get(id); + if (!tab) return; + + const updatedTab = { + ...tab, + ...updates, + lastModified: Date.now(), + }; + + const newTabs = new Map(tabs); + newTabs.set(id, updatedTab); + set(composeTabsAtom, newTabs); + }, +); + +export const toggleMinimizeTabAtom = atom(null, async (get, set, tabId: string) => { + const tabs = await get(composeTabsAtom); + const tab = tabs.get(tabId); + if (!tab) return; + + const updatedTab = { + ...tab, + isMinimized: !tab.isMinimized, + }; + + const newTabs = new Map(tabs); + newTabs.set(tabId, updatedTab); + set(composeTabsAtom, newTabs); + + if (!updatedTab.isMinimized) { + set(activeComposeTabIdAtom, tabId); + } +}); + +export const toggleFullscreenTabAtom = atom(null, (get, set, tabId: string | null) => { + const currentFullscreen = get(fullscreenTabIdAtom); + + if (currentFullscreen === tabId) { + set(fullscreenTabIdAtom, null); + } else { + set(fullscreenTabIdAtom, tabId); + if (tabId) { + set(activeComposeTabIdAtom, tabId); + } + } +});