mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-28 14:56:48 +00:00
new create email tabs
This commit is contained in:
@@ -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<typeof clientLoader>();
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogTitle></DialogTitle>
|
||||
<DialogDescription></DialogDescription>
|
||||
<DialogTrigger></DialogTrigger>
|
||||
<DialogContent className="h-screen w-screen max-w-none border-none bg-[#FAFAFA] p-0 shadow-none dark:bg-[#141414]">
|
||||
<CreateEmail
|
||||
initialTo={params.to || ''}
|
||||
initialSubject={params.subject || ''}
|
||||
initialBody={params.body || ''}
|
||||
initialCc={params.cc || ''}
|
||||
initialBcc={params.bcc || ''}
|
||||
draftId={params.draftId || null}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="bg-sidebar dark:bg-sidebar w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
<ComposeTabs />
|
||||
<OnboardingWrapper />
|
||||
</HotkeyProviderWrapper>
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
]),
|
||||
|
||||
@@ -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({});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
328
apps/mail/components/create/compose-tabs.tsx
Normal file
328
apps/mail/components/create/compose-tabs.tsx
Normal file
@@ -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
|
||||
? '<p style="color: #666; font-size: 12px;">Sent via <a href="https://0.email/" style="color: #0066cc; text-decoration: none;">Zero</a></p>'
|
||||
: '';
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 bg-[#FAFAFA] dark:bg-[#141414]"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<h2 className="text-lg font-semibold">{fullscreenTab.subject || 'New Email'}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleFullscreen(null)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeTab(fullscreenTabId)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<EmailComposer
|
||||
inATab={true}
|
||||
initialTo={fullscreenTab.to || []}
|
||||
initialCc={fullscreenTab.cc || []}
|
||||
initialBcc={fullscreenTab.bcc || []}
|
||||
initialSubject={fullscreenTab.subject || ''}
|
||||
initialMessage={fullscreenTab.body || ''}
|
||||
initialAttachments={fullscreenTab.attachments || []}
|
||||
draftId={fullscreenTab.draftId}
|
||||
onSendEmail={(data) => handleSendEmail(fullscreenTabId, data)}
|
||||
onClose={() => removeTab(fullscreenTabId)}
|
||||
onChange={(updates) => updateTab({ id: fullscreenTabId, updates })}
|
||||
className="h-full"
|
||||
autofocus={true}
|
||||
settingsLoading={settingsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 right-4 z-40 flex max-w-[calc(100vw-32px)] flex-row-reverse items-end gap-3">
|
||||
<AnimatePresence>
|
||||
{Array.from(composeTabs.values()).map((tab) => {
|
||||
const index = Array.from(composeTabs.values()).indexOf(tab);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={tab.id}
|
||||
layout
|
||||
layoutId={`compose-tab-${tab.id}`}
|
||||
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
width: tab.isMinimized ? 'auto' : '450px',
|
||||
height: tab.isMinimized ? 'auto' : '520px',
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
style={{
|
||||
originX: 1,
|
||||
originY: 1,
|
||||
zIndex: activeTabId === tab.id ? 10 : index,
|
||||
}}
|
||||
className={
|
||||
tab.isMinimized
|
||||
? 'cursor-pointer'
|
||||
: 'bg-background overflow-hidden rounded-lg border shadow-2xl'
|
||||
}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{tab.isMinimized ? (
|
||||
<motion.div
|
||||
key={`${tab.id}-minimized`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="hover:bg-accent flex h-10 items-center gap-2 rounded-full border bg-[#FFFFFF] px-4 py-2 shadow-lg dark:bg-[#202020]"
|
||||
onClick={() => toggleMinimize(tab.id)}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{tab.to || tab.subject || 'New Email'}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-destructive/10 h-5 w-5 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`${tab.id}-expanded`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div className="dark:bg-panelDark flex items-center justify-between border-b px-4 py-2">
|
||||
<h3 className="text-sm font-medium">{tab.subject || 'New Email'}</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => toggleMinimize(tab.id)}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => toggleFullscreen(tab.id)}
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dark:bg-panelDark flex-1 overflow-y-auto">
|
||||
<EmailComposer
|
||||
inATab={true}
|
||||
initialTo={tab.to || []}
|
||||
initialCc={tab.cc || []}
|
||||
initialBcc={tab.bcc || []}
|
||||
initialSubject={tab.subject || ''}
|
||||
initialMessage={tab.body || ''}
|
||||
initialAttachments={tab.attachments || []}
|
||||
draftId={tab.draftId}
|
||||
onSendEmail={(data) => handleSendEmail(tab.id, data)}
|
||||
onClose={() => removeTab(tab.id)}
|
||||
onChange={(updates) => updateTab({ id: tab.id, updates })}
|
||||
className="h-full"
|
||||
autofocus={activeTabId === tab.id}
|
||||
settingsLoading={settingsLoading}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full bg-[#FFFFFF] dark:bg-[#202020]"
|
||||
onClick={handleAddTab}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -79,10 +79,20 @@ interface EmailComposerProps {
|
||||
scheduleAt?: string;
|
||||
}) => Promise<void>;
|
||||
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<string | null>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-h-[500px] w-full max-w-[750px] flex-col overflow-hidden rounded-2xl bg-[#FAFAFA] shadow-sm dark:bg-[#202020]',
|
||||
'flex max-h-dvh w-full max-w-[750px] flex-col overflow-hidden bg-[#FAFAFA] shadow-sm dark:bg-[#202020]',
|
||||
{
|
||||
'rounded-2xl': !inATab,
|
||||
'rounded-none': inATab,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -647,7 +680,7 @@ export function EmailComposer({
|
||||
>
|
||||
<span>Bcc</span>
|
||||
</button>
|
||||
{onClose && (
|
||||
{!inATab && onClose && (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="flex h-full items-center gap-2 text-sm font-medium text-[#8C8C8C] hover:text-[#A8A8A8]"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
|
||||
import { focusedIndexAtom } from '@/hooks/use-mail-navigation';
|
||||
import { type ThreadDestination } from '@/lib/thread-actions';
|
||||
import { addComposeTabAtom } from '@/store/composeTabsStore';
|
||||
import { handleUnsubscribe } from '@/lib/email-utils.client';
|
||||
import { useThread, useThreads } from '@/hooks/use-threads';
|
||||
import { useAISidebar } from '@/components/ui/ai-sidebar';
|
||||
@@ -33,21 +34,21 @@ import type { ParsedMessage, Attachment } from '@/types';
|
||||
import { useAnimations } from '@/hooks/use-animations';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { MailDisplaySkeleton } from './mail-skeleton';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cleanHtml } from '@/lib/email-utils';
|
||||
import ReplyCompose from './reply-composer';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { NotesPanel } from './note-panel';
|
||||
import { cn, FOLDERS } from '@/lib/utils';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import MailDisplay from './mail-display';
|
||||
import { useParams } from 'react-router';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { format } from 'date-fns';
|
||||
import { useAtom } from 'jotai';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const formatFileSize = (size: number) => {
|
||||
@@ -152,9 +153,11 @@ function ThreadActionButton({
|
||||
}
|
||||
const isFullscreen = false;
|
||||
export function ThreadDisplay() {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const { toggleOpen: toggleAISidebar } = useAISidebar();
|
||||
const params = useParams<{ folder: string }>();
|
||||
const addTab = useSetAtom(addComposeTabAtom);
|
||||
|
||||
const folder = params?.folder ?? 'inbox';
|
||||
const [id, setThreadId] = useQueryState('threadId');
|
||||
@@ -742,7 +745,7 @@ export function ThreadDisplay() {
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsComposeOpen('true')}
|
||||
onClick={() => addTab({})}
|
||||
className="inline-flex h-7 items-center justify-center gap-0.5 overflow-hidden rounded-lg border bg-white px-2 dark:border-none dark:bg-[#313131]"
|
||||
>
|
||||
<Mail className="mr-1 h-3.5 w-3.5 fill-[#959595]" />
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader } from '@/components/ui/sidebar';
|
||||
import { navigationConfig, bottomNavItems } from '@/config/navigation';
|
||||
// import { useMutation } from '@tanstack/react-query';
|
||||
import { addComposeTabAtom } from '@/store/composeTabsStore';
|
||||
// import { useTRPC } from '@/providers/query-provider';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { CreateEmail } from '../create/create-email';
|
||||
// import { useMutation } from '@tanstack/react-query';
|
||||
import { PencilCompose, X } from '../icons/icons';
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
@@ -27,6 +20,7 @@ import { NavUser } from './nav-user';
|
||||
import { NavMain } from './nav-main';
|
||||
import { useQueryState } from 'nuqs';
|
||||
// import { toast } from 'sonner';
|
||||
import { useSetAtom } from 'jotai';
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { isPro, isLoading } = useBilling();
|
||||
@@ -176,47 +170,27 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
function ComposeButton() {
|
||||
const { state } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
const addTab = useSetAtom(addComposeTabAtom);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useQueryState('isComposeOpen');
|
||||
const [, setDraftId] = useQueryState('draftId');
|
||||
const [, setTo] = useQueryState('to');
|
||||
const [, setActiveReplyId] = useQueryState('activeReplyId');
|
||||
const [, setMode] = useQueryState('mode');
|
||||
|
||||
const handleOpenChange = async (open: boolean) => {
|
||||
if (!open) {
|
||||
setDialogOpen(null);
|
||||
} else {
|
||||
setDialogOpen('true');
|
||||
}
|
||||
setDraftId(null);
|
||||
setTo(null);
|
||||
setActiveReplyId(null);
|
||||
setMode(null);
|
||||
const handleCompose = () => {
|
||||
addTab({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!dialogOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTitle></DialogTitle>
|
||||
<DialogDescription></DialogDescription>
|
||||
|
||||
<DialogTrigger asChild>
|
||||
<button className="relative mb-1.5 inline-flex h-8 w-full items-center justify-center gap-1 self-stretch overflow-hidden rounded-lg border border-gray-200 bg-[#006FFE] text-black dark:border-none dark:text-white">
|
||||
{state === 'collapsed' && !isMobile ? (
|
||||
<PencilCompose className="mt-0.5 fill-white text-black" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2.5 pl-0.5 pr-1">
|
||||
<PencilCompose className="fill-white" />
|
||||
<div className="justify-start text-sm leading-none text-white">
|
||||
{m['common.commandPalette.commands.newEmail']()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="h-screen w-screen max-w-none border-none bg-[#FAFAFA] p-0 shadow-none dark:bg-[#141414]">
|
||||
<CreateEmail />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<button
|
||||
onClick={handleCompose}
|
||||
className="relative mb-1.5 inline-flex h-8 w-full items-center justify-center gap-1 self-stretch overflow-hidden rounded-lg border border-gray-200 bg-white text-black dark:border-none dark:bg-[#2C2C2C] dark:text-white"
|
||||
>
|
||||
{state === 'collapsed' && !isMobile ? (
|
||||
<PencilCompose className="fill-iconLight dark:fill-iconDark mt-0.5 text-black" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2.5 pl-0.5 pr-1">
|
||||
<PencilCompose className="fill-iconLight dark:fill-iconDark" />
|
||||
<div className="justify-start text-sm leading-none">
|
||||
{m['common.commandPalette.commands.newEmail']()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
125
apps/mail/store/composeTabsStore.ts
Normal file
125
apps/mail/store/composeTabsStore.ts
Normal file
@@ -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<Map<string, ComposeTab>>('composeTabs', new Map(), {
|
||||
getItem: (key, initialValue): Map<string, ComposeTab> => {
|
||||
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<string | null>(null);
|
||||
export const fullscreenTabIdAtom = atom<string | null>(null);
|
||||
|
||||
export const addComposeTabAtom = atom(null, async (get, set, tab: Partial<ComposeTab>) => {
|
||||
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<ComposeTab> }) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user