new create email tabs

This commit is contained in:
Ahmet Kilinc
2025-07-15 17:58:48 +01:00
parent 6a632086e3
commit 7a9e603f56
10 changed files with 526 additions and 110 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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'),
]),

View File

@@ -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({});
},
});

View 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>
</>
);
}

View File

@@ -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]"

View File

@@ -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]" />

View File

@@ -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>
);
}

View File

@@ -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: () => {

View 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);
}
}
});