A responsive mailbox block with folders, message lists, compose flow, confirmations, and toast feedback.
registry/corr/components/mailbox.tsx
"use client"
import * as React from "react"
import { toast } from "sonner"
import {
ArchiveIcon,
ArrowLeftIcon,
BoldIcon,
Edit3Icon,
ForwardIcon,
FolderIcon,
InboxIcon,
ItalicIcon,
MailIcon,
MoreHorizontalIcon,
PaperclipIcon,
PanelLeftIcon,
ReplyIcon,
RefreshCcwIcon,
SearchIcon,
SendIcon,
StarIcon,
Trash2Icon,
UnderlineIcon,
XIcon,
} from "lucide-react"
import { FileUploadDropzone, type FileUploadDropzoneFile } from "./file-upload-dropzone"
import { ScrollBlur } from "./scroll-blur"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from "@/components/ui/menubar"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { Separator } from "@/components/ui/separator"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/components/ui/sidebar"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
export type MailboxFolder = {
id: string
label: string
count?: number
icon?: React.ComponentType<{ className?: string }>
}
export type MailboxContact = {
id: string
name: string
email: string
avatar?: string
}
export type MailboxMessage = {
id: string
folderId: string
from: MailboxContact
to?: MailboxContact[]
cc?: MailboxContact[]
bcc?: MailboxContact[]
subject: string
preview: string
body: string
timestamp: string
unread?: boolean
starred?: boolean
attachments?: number
thread?: MailboxThreadReply[]
}
export type MailboxThreadReply = {
id: string
from: MailboxContact
to?: MailboxContact[]
cc?: MailboxContact[]
body: string
timestamp: string
}
type MailboxPane = "folders" | "list" | "detail" | "compose"
const STARRED_FOLDER_ID = "starred"
type MailboxComposeDraft = {
to?: string[]
cc?: string[]
bcc?: string[]
subject?: string
body?: string
}
export const defaultMailboxFolders: MailboxFolder[] = [
{ id: "inbox", label: "Inbox", count: 6, icon: InboxIcon },
{ id: "drafts", label: "Drafts", count: 2, icon: Edit3Icon },
{ id: "sent", label: "Sent", icon: SendIcon },
{ id: "archive", label: "Archive", icon: ArchiveIcon },
{ id: "trash", label: "Trash", icon: Trash2Icon },
]
export const defaultMailboxContacts: MailboxContact[] = [
{ id: "mina", name: "Mina Patel", email: "mina@corr.sh" },
{ id: "avery", name: "Avery Stone", email: "avery@corr.sh" },
{ id: "nora", name: "Nora West", email: "nora@corr.sh" },
{ id: "theo", name: "Theo Brooks", email: "theo@corr.sh" },
]
export const defaultMailboxMessages: MailboxMessage[] = [
{
id: "msg-1",
folderId: "inbox",
from: defaultMailboxContacts[0],
to: [defaultMailboxContacts[1]],
cc: [defaultMailboxContacts[2]],
subject: "Registry publish checklist",
preview: "The install payloads are ready for review before release.",
body: "I checked the registry payloads, preview screenshots, and install commands. The only remaining item is confirming the chart state examples before we publish.",
timestamp: "09:42",
unread: true,
starred: true,
attachments: 2,
thread: [
{
id: "msg-1-thread-1",
from: defaultMailboxContacts[1],
to: [defaultMailboxContacts[0]],
body: "The install command examples are good. Can we include one screenshot of the generated JSON as well?",
timestamp: "08:58",
},
{
id: "msg-1-thread-2",
from: defaultMailboxContacts[0],
to: [defaultMailboxContacts[1]],
cc: [defaultMailboxContacts[2]],
body: "Yes, I added the JSON preview and checked the responsive shell captures. I am just doing one last pass on chart states.",
timestamp: "09:17",
},
{
id: "msg-1-thread-3",
from: defaultMailboxContacts[2],
to: [defaultMailboxContacts[0], defaultMailboxContacts[1]],
body: "Chart states are in. Loading, empty, and error examples now all use the shared wrapper.",
timestamp: "09:31",
},
],
},
{
id: "msg-2",
folderId: "inbox",
from: defaultMailboxContacts[1],
subject: "Preview shell screenshots",
preview: "Mobile and tablet frames are looking cleaner after the shell update.",
body: "The responsive preview shell now gives us a much better way to inspect the same component across breakpoints without duplicating examples.",
timestamp: "Yesterday",
},
{
id: "msg-3",
folderId: "inbox",
from: defaultMailboxContacts[2],
subject: "Component metrics",
preview: "Copy and install events are now visible in the dashboard.",
body: "The component metrics are flowing through the install card. We can use this to compare page views, source copies, and registry JSON requests.",
timestamp: "May 20",
unread: true,
},
{
id: "msg-4",
folderId: "inbox",
from: defaultMailboxContacts[3],
subject: "Form builder drag preview",
preview: "The drag overlay now follows the cursor more closely.",
body: "The form builder drag preview is behaving much better after moving the overlay into a body-level portal and using the grip handle for drag activation.",
timestamp: "May 19",
attachments: 1,
},
{
id: "msg-5",
folderId: "inbox",
from: defaultMailboxContacts[1],
subject: "Timeline mobile agenda",
preview: "The mobile agenda can expose slot creation between items.",
body: "The timeline mobile mode should keep the agenda readable while still making slot creation available through the inline add affordance.",
timestamp: "May 18",
},
{
id: "msg-6",
folderId: "inbox",
from: defaultMailboxContacts[2],
subject: "Kanban tab behaviour",
preview: "Tablet and mobile now use bucket tabs instead of squeezing columns.",
body: "The kanban board should adapt into bucket tabs at constrained widths and keep move actions available from each card menu.",
timestamp: "May 17",
unread: true,
},
{
id: "msg-7",
folderId: "inbox",
from: defaultMailboxContacts[0],
subject: "Mailbox block polish",
preview: "The toolbar, compose flow, and scroll areas are getting closer.",
body: "The mailbox block now has a top toolbar, independent scroll areas, context menus, and a compose workflow with attachments.",
timestamp: "May 16",
},
{
id: "msg-8",
folderId: "drafts",
from: defaultMailboxContacts[3],
subject: "Draft: launch notes",
preview: "Short release notes for the latest registry additions.",
body: "Need to mention the mailbox block, chart state utility, and responsive previews in the release notes.",
timestamp: "Draft",
},
{
id: "msg-9",
folderId: "sent",
from: defaultMailboxContacts[0],
subject: "Re: Design review",
preview: "Sent over the final screenshots and implementation notes.",
body: "Thanks, I sent the final screenshots and noted the mobile behaviour in the implementation summary.",
timestamp: "May 18",
},
]
export function MailboxBlock({
folders = defaultMailboxFolders,
messages: initialMessages = defaultMailboxMessages,
contacts = defaultMailboxContacts,
initialFolderId = "inbox",
initialMessageId,
onSend,
onDelete,
onArchive,
onMarkRead,
className,
}: {
folders?: MailboxFolder[]
messages?: MailboxMessage[]
contacts?: MailboxContact[]
initialFolderId?: string
initialMessageId?: string
onSend?: (message: MailboxMessage) => void
onDelete?: (message: MailboxMessage) => void
onArchive?: (message: MailboxMessage) => void
onMarkRead?: (message: MailboxMessage) => void
className?: string
}) {
const rootRef = React.useRef<HTMLDivElement | null>(null)
const [width, setWidth] = React.useState(1024)
const [messages, setMessages] = React.useState(initialMessages)
const [folderId, setFolderId] = React.useState(initialFolderId)
const [selectedId, setSelectedId] = React.useState(
initialMessageId ?? initialMessages.find((message) => message.folderId === initialFolderId)?.id
)
const [pane, setPane] = React.useState<MailboxPane>("list")
const [deleteTarget, setDeleteTarget] = React.useState<MailboxMessage | null>(null)
const [query, setQuery] = React.useState("")
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [composeDraft, setComposeDraft] = React.useState<MailboxComposeDraft | undefined>()
const [refreshing, setRefreshing] = React.useState(false)
const [unreadFirst, setUnreadFirst] = React.useState(false)
const [compactList, setCompactList] = React.useState(false)
const compact = width < 760
React.useEffect(() => {
const element = rootRef.current
if (!element) return
const observer = new ResizeObserver(([entry]) => {
setWidth(entry.contentRect.width)
})
observer.observe(element)
return () => observer.disconnect()
}, [])
const selectedFolder =
folderId === STARRED_FOLDER_ID
? { id: STARRED_FOLDER_ID, label: "Starred", icon: StarIcon }
: folders.find((folder) => folder.id === folderId) ?? folders[0]
const folderMessages = messages
.filter(
(message) =>
(folderId === STARRED_FOLDER_ID ? message.starred : message.folderId === folderId) &&
`${message.subject} ${message.from.name} ${message.preview}`
.toLowerCase()
.includes(query.toLowerCase())
)
.sort((first, second) => {
if (!unreadFirst) return 0
return Number(Boolean(second.unread)) - Number(Boolean(first.unread))
})
const selectedMessage =
refreshing ? undefined : messages.find((message) => message.id === selectedId) ?? folderMessages[0]
React.useEffect(() => {
if (refreshing) return
if (selectedId || !folderMessages[0]) return
setSelectedId(folderMessages[0].id)
}, [folderMessages, refreshing, selectedId])
function selectFolder(nextFolderId: string) {
setFolderId(nextFolderId)
const nextMessage =
nextFolderId === STARRED_FOLDER_ID
? messages.find((message) => message.starred)
: messages.find((message) => message.folderId === nextFolderId)
setSelectedId(nextMessage?.id)
setPane("list")
}
function markAllAsRead() {
setMessages((current) =>
current.map((message) =>
folderId === STARRED_FOLDER_ID
? message.starred
? { ...message, unread: false }
: message
: message.folderId === folderId
? { ...message, unread: false }
: message
)
)
toast.success("Mailbox marked as read", {
description: `${selectedFolder.label} has no unread messages.`,
})
}
function refreshMailbox() {
setRefreshing(true)
setSelectedId(undefined)
window.setTimeout(() => {
setRefreshing(false)
setSelectedId(folderMessages[0]?.id)
toast.success("Mailbox refreshed", {
description: "Latest messages are up to date.",
})
}, 900)
}
function toggleUnreadFirst() {
setUnreadFirst((current) => !current)
}
function toggleCompactList() {
setCompactList((current) => !current)
}
function selectMessage(message: MailboxMessage) {
setSelectedId(message.id)
if (message.unread) {
const readMessage = { ...message, unread: false }
setMessages((current) =>
current.map((item) => (item.id === message.id ? readMessage : item))
)
onMarkRead?.(readMessage)
}
if (compact) setPane("detail")
}
function archiveMessage(message: MailboxMessage) {
const archived = { ...message, folderId: "archive", unread: false }
setMessages((current) =>
current.map((item) => (item.id === message.id ? archived : item))
)
onArchive?.(archived)
toast.success("Email archived", { description: message.subject })
if (compact) setPane("list")
}
function deleteMessage(message: MailboxMessage) {
setMessages((current) => current.filter((item) => item.id !== message.id))
onDelete?.(message)
setDeleteTarget(null)
setSelectedId(folderMessages.find((item) => item.id !== message.id)?.id)
toast.error("Email deleted", { description: message.subject })
if (compact) setPane("list")
}
function moveMessage(message: MailboxMessage, nextFolderId: string) {
const moved = { ...message, folderId: nextFolderId, unread: false }
setMessages((current) =>
current.map((item) => (item.id === message.id ? moved : item))
)
setSelectedId(folderMessages.find((item) => item.id !== message.id)?.id)
const nextFolder = folders.find((folder) => folder.id === nextFolderId)
toast.success("Email moved", {
description: `${message.subject} moved to ${nextFolder?.label ?? "folder"}.`,
})
}
function toggleStarMessage(message: MailboxMessage) {
const nextMessage = { ...message, starred: !message.starred }
setMessages((current) =>
current.map((item) => (item.id === message.id ? nextMessage : item))
)
if (folderId === STARRED_FOLDER_ID && message.starred) {
setSelectedId(messages.find((item) => item.id !== message.id && item.starred)?.id)
}
}
function openCompose(draft?: MailboxComposeDraft) {
setComposeDraft(draft)
setPane("compose")
}
function replyToMessage(message: MailboxMessage) {
setSelectedId(message.id)
openCompose({
to: [message.from.email],
subject: withSubjectPrefix(message.subject, "Re:"),
body: buildThreadQuote(message, "reply"),
})
}
function replyAllToMessage(message: MailboxMessage) {
const currentUserEmail = contacts[0]?.email
const emails = uniqueEmails([
message.from.email,
...(message.to ?? []).map((contact) => contact.email),
]).filter((email) => email !== currentUserEmail)
const ccEmails = uniqueEmails((message.cc ?? []).map((contact) => contact.email)).filter(
(email) => email !== currentUserEmail && !emails.includes(email)
)
setSelectedId(message.id)
openCompose({
to: emails,
cc: ccEmails,
subject: withSubjectPrefix(message.subject, "Re:"),
body: buildThreadQuote(message, "reply"),
})
}
function forwardMessage(message: MailboxMessage) {
setSelectedId(message.id)
openCompose({
subject: withSubjectPrefix(message.subject, "Fwd:"),
body: buildThreadQuote(message, "forward"),
})
}
return (
<Card
ref={rootRef}
data-slot="mailbox-block"
className={cn("relative h-[34rem] min-w-0 overflow-hidden p-0", className)}
>
<SidebarProvider
className="h-full min-h-0"
style={
{
"--sidebar-width": compact ? "15rem" : sidebarCollapsed ? "4.25rem" : "15rem",
} as React.CSSProperties
}
>
{compact ? (
<div className="flex h-full min-w-0 flex-1 flex-col">
<MailboxToolbar
compact
pane={pane}
query={query}
onQueryChange={setQuery}
showSearch={width > 560}
iconOnlyCompose={width < 640}
selectedMessage={selectedMessage}
unreadFirst={unreadFirst}
compactList={compactList}
onMarkAllRead={markAllAsRead}
onRefresh={refreshMailbox}
onShowArchived={() => selectFolder("archive")}
onReply={() => selectedMessage && replyToMessage(selectedMessage)}
onForward={() => selectedMessage && forwardMessage(selectedMessage)}
onDelete={() => selectedMessage && setDeleteTarget(selectedMessage)}
onShowStarred={() => selectFolder(STARRED_FOLDER_ID)}
onToggleUnreadFirst={toggleUnreadFirst}
onToggleCompactList={toggleCompactList}
onBack={() => setPane(pane === "detail" || pane === "compose" ? "list" : "folders")}
onFolders={() => setPane("folders")}
onCompose={() => openCompose()}
/>
<div className="grid min-h-0 flex-1 grid-cols-1">
{pane === "folders" ? (
<FolderPane
folders={folders}
messages={messages}
folderId={folderId}
onSelect={selectFolder}
onCompose={() => openCompose()}
/>
) : null}
{pane === "list" ? (
<MessageListPane
folder={selectedFolder}
folders={folders}
messages={folderMessages}
selectedId={selectedMessage?.id}
refreshing={refreshing}
compactList={compactList}
query={query}
onQueryChange={setQuery}
onSelect={selectMessage}
onReply={replyToMessage}
onReplyAll={replyAllToMessage}
onForward={forwardMessage}
onArchive={archiveMessage}
onDelete={setDeleteTarget}
onMove={moveMessage}
showHeader={width <= 560}
/>
) : null}
{pane === "detail" && selectedMessage ? (
<MessageDetailPane
message={selectedMessage}
onReply={() => replyToMessage(selectedMessage)}
onReplyAll={() => replyAllToMessage(selectedMessage)}
onForward={() => forwardMessage(selectedMessage)}
onToggleStar={() => toggleStarMessage(selectedMessage)}
onArchive={() => archiveMessage(selectedMessage)}
onDelete={() => setDeleteTarget(selectedMessage)}
/>
) : null}
{pane === "compose" ? (
<ComposePane
contacts={contacts}
draft={composeDraft}
onCancel={() => setPane(selectedMessage ? "detail" : "list")}
onSend={(message) => {
setMessages((current) => [message, ...current])
onSend?.(message)
setComposeDraft(undefined)
toast.success("Email sent", { description: message.subject })
selectFolder("sent")
}}
/>
) : null}
</div>
</div>
) : (
<>
<MailboxDesktopToolbar
onCompose={() => openCompose()}
query={query}
onQueryChange={setQuery}
selectedMessage={selectedMessage}
unreadFirst={unreadFirst}
compactList={compactList}
onMarkAllRead={markAllAsRead}
onRefresh={refreshMailbox}
onShowArchived={() => selectFolder("archive")}
onReply={() => selectedMessage && replyToMessage(selectedMessage)}
onForward={() => selectedMessage && forwardMessage(selectedMessage)}
onDelete={() => selectedMessage && setDeleteTarget(selectedMessage)}
onShowStarred={() => selectFolder(STARRED_FOLDER_ID)}
onToggleUnreadFirst={toggleUnreadFirst}
onToggleCompactList={toggleCompactList}
/>
<Sidebar collapsible="none" className="min-h-0 border-r pt-10">
<FolderPane
folders={folders}
messages={messages}
folderId={folderId}
onSelect={selectFolder}
onCompose={() => openCompose()}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((collapsed) => !collapsed)}
/>
</Sidebar>
<SidebarInset className="min-w-0 bg-background pt-10">
{pane === "compose" ? (
<ComposePane
contacts={contacts}
draft={composeDraft}
onCancel={() => setPane(selectedMessage ? "detail" : "list")}
onSend={(message) => {
setMessages((current) => [message, ...current])
onSend?.(message)
setComposeDraft(undefined)
toast.success("Email sent", { description: message.subject })
selectFolder("sent")
}}
/>
) : (
<ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1">
<ResizablePanel defaultSize="42%" minSize="5rem" maxSize="82%" className="min-w-0 overflow-hidden">
<MessageListPane
folder={selectedFolder}
folders={folders}
messages={folderMessages}
selectedId={selectedMessage?.id}
refreshing={refreshing}
compactList={compactList}
query={query}
onQueryChange={setQuery}
onSelect={selectMessage}
onReply={replyToMessage}
onReplyAll={replyAllToMessage}
onForward={forwardMessage}
onArchive={archiveMessage}
onDelete={setDeleteTarget}
onMove={moveMessage}
showHeader={false}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="58%" minSize="7rem" className="min-w-0 overflow-hidden">
{selectedMessage ? (
<MessageDetailPane
message={selectedMessage}
onReply={() => replyToMessage(selectedMessage)}
onReplyAll={() => replyAllToMessage(selectedMessage)}
onForward={() => forwardMessage(selectedMessage)}
onToggleStar={() => toggleStarMessage(selectedMessage)}
onArchive={() => archiveMessage(selectedMessage)}
onDelete={() => setDeleteTarget(selectedMessage)}
/>
) : null}
</ResizablePanel>
</ResizablePanelGroup>
)}
</SidebarInset>
</>
)}
</SidebarProvider>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this email?</AlertDialogTitle>
<AlertDialogDescription>
This removes “{deleteTarget?.subject}” from the mailbox demo.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && deleteMessage(deleteTarget)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
}
function MailboxDesktopToolbar({
query,
onQueryChange,
onCompose,
selectedMessage,
unreadFirst,
compactList,
onMarkAllRead,
onRefresh,
onShowArchived,
onReply,
onForward,
onDelete,
onShowStarred,
onToggleUnreadFirst,
onToggleCompactList,
}: {
query: string
onQueryChange: (query: string) => void
onCompose: () => void
selectedMessage?: MailboxMessage
unreadFirst: boolean
compactList: boolean
onMarkAllRead: () => void
onRefresh: () => void
onShowArchived: () => void
onReply: () => void
onForward: () => void
onDelete: () => void
onShowStarred: () => void
onToggleUnreadFirst: () => void
onToggleCompactList: () => void
}) {
return (
<div className="absolute inset-x-0 top-0 z-20 flex h-10 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur">
<Menubar className="h-8 border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger>Mailbox</MenubarTrigger>
<MenubarContent>
<MenubarItem onSelect={onMarkAllRead}>Mark all as read</MenubarItem>
<MenubarItem onSelect={onRefresh}>
<RefreshCcwIcon />
Refresh mailbox
</MenubarItem>
<MenubarSeparator />
<MenubarItem onSelect={onShowArchived}>Show archived</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Message</MenubarTrigger>
<MenubarContent>
<MenubarItem onSelect={onCompose}>
<Edit3Icon />
Compose
</MenubarItem>
<MenubarItem disabled={!selectedMessage} onSelect={onReply}>
<ReplyIcon />
Reply
</MenubarItem>
<MenubarItem disabled={!selectedMessage} onSelect={onForward}>
<ForwardIcon />
Forward
</MenubarItem>
<MenubarSeparator />
<MenubarItem disabled={!selectedMessage} variant="destructive" onSelect={onDelete}>
<Trash2Icon />
Delete
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>View</MenubarTrigger>
<MenubarContent>
<MenubarItem onSelect={onToggleUnreadFirst}>
{unreadFirst ? "Default order" : "Unread first"}
</MenubarItem>
<MenubarItem onSelect={onShowStarred}>Show starred</MenubarItem>
<MenubarItem onSelect={onToggleCompactList}>
{compactList ? "Comfortable list" : "Compact list"}
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
<div className="relative ml-auto w-52 shrink-0">
<SearchIcon className="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Search mail"
className="h-8 pl-7"
/>
</div>
<Button size="sm" onClick={onCompose}>
<Edit3Icon className="size-3.5" />
Compose
</Button>
</div>
)
}
function MailboxToolbar({
compact,
pane,
query,
onQueryChange,
showSearch,
onBack,
onFolders,
onCompose,
iconOnlyCompose = false,
selectedMessage,
unreadFirst,
compactList,
onMarkAllRead,
onRefresh,
onShowArchived,
onReply,
onForward,
onDelete,
onShowStarred,
onToggleUnreadFirst,
onToggleCompactList,
}: {
compact: boolean
pane: MailboxPane
query: string
onQueryChange: (query: string) => void
showSearch: boolean
onBack: () => void
onFolders: () => void
onCompose: () => void
iconOnlyCompose?: boolean
selectedMessage?: MailboxMessage
unreadFirst: boolean
compactList: boolean
onMarkAllRead: () => void
onRefresh: () => void
onShowArchived: () => void
onReply: () => void
onForward: () => void
onDelete: () => void
onShowStarred: () => void
onToggleUnreadFirst: () => void
onToggleCompactList: () => void
}) {
return (
<div className="scrollbar-none flex h-10 shrink-0 items-center gap-2 overflow-x-auto border-b px-2">
{compact && pane !== "folders" ? (
<Button
variant="ghost"
size="icon-sm"
onClick={onBack}
aria-label="Back"
className="shrink-0"
>
<ArrowLeftIcon />
</Button>
) : null}
{compact ? (
<Button
variant="outline"
size={compact ? "icon-sm" : "sm"}
onClick={onFolders}
aria-label="Folders"
className="h-8 shrink-0"
>
<FolderIcon className="size-3.5" />
{!compact ? <span>Folders</span> : null}
</Button>
) : null}
<Menubar className="h-8 shrink-0 border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger
aria-label="Mailbox actions"
className={cn(compact && "h-8 w-8 justify-center px-0")}
>
{compact ? (
<>
<InboxIcon className="size-3.5" />
<span className="sr-only">Mailbox</span>
</>
) : (
"Mailbox"
)}
</MenubarTrigger>
<MenubarContent>
<MenubarItem onSelect={onMarkAllRead}>Mark all as read</MenubarItem>
<MenubarItem onSelect={onRefresh}>
<RefreshCcwIcon />
Refresh mailbox
</MenubarItem>
<MenubarSeparator />
<MenubarItem onSelect={onShowArchived}>Show archived</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger
aria-label="Message actions"
className={cn(compact && "h-8 w-8 justify-center px-0")}
>
{compact ? (
<>
<MailIcon className="size-3.5" />
<span className="sr-only">Message</span>
</>
) : (
"Message"
)}
</MenubarTrigger>
<MenubarContent>
<MenubarItem onSelect={onCompose}>
<Edit3Icon />
Compose
</MenubarItem>
<MenubarItem disabled={!selectedMessage} onSelect={onReply}>
<ReplyIcon />
Reply
</MenubarItem>
<MenubarItem disabled={!selectedMessage} onSelect={onForward}>
<ForwardIcon />
Forward
</MenubarItem>
<MenubarSeparator />
<MenubarItem disabled={!selectedMessage} variant="destructive" onSelect={onDelete}>
<Trash2Icon />
Delete
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger
aria-label="View options"
className={cn(compact && "h-8 w-8 justify-center px-0")}
>
{compact ? (
<>
<PanelLeftIcon className="size-3.5" />
<span className="sr-only">View</span>
</>
) : (
"View"
)}
</MenubarTrigger>
<MenubarContent>
<MenubarItem onSelect={onToggleUnreadFirst}>
{unreadFirst ? "Default order" : "Unread first"}
</MenubarItem>
<MenubarItem onSelect={onShowStarred}>Show starred</MenubarItem>
<MenubarItem onSelect={onToggleCompactList}>
{compactList ? "Comfortable list" : "Compact list"}
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
{showSearch ? (
<div className="relative ml-auto w-44 shrink-0">
<SearchIcon className="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Search mail"
className="h-8 pl-7"
/>
</div>
) : <span className="min-w-2 flex-1" />}
<Button
size="icon-sm"
onClick={onCompose}
aria-label="Compose"
className={cn(
"h-8 shrink-0",
compact || iconOnlyCompose ? "w-8" : "w-auto px-3"
)}
>
<Edit3Icon className="size-3.5" />
<span className={compact || iconOnlyCompose ? "sr-only" : undefined}>Compose</span>
</Button>
</div>
)
}
function FolderPane({
folders,
messages,
folderId,
onSelect,
onCompose,
collapsed = false,
onToggleCollapsed,
}: {
folders: MailboxFolder[]
messages: MailboxMessage[]
folderId: string
onSelect: (folderId: string) => void
onCompose: () => void
collapsed?: boolean
onToggleCollapsed?: () => void
}) {
return (
<>
<SidebarHeader className="border-b p-3">
<SidebarMenu>
<SidebarMenuItem>
<div className={cn("flex items-center gap-3", collapsed && "justify-center")}>
{!collapsed ? (
<SidebarMenuButton size="lg" className="min-w-0 flex-1 gap-3">
<span className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<MailIcon className="size-4" />
</span>
<span className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Acme Mail</span>
<span className="truncate text-xs text-muted-foreground">Shared inbox</span>
</span>
</SidebarMenuButton>
) : null}
{onToggleCollapsed ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onToggleCollapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<PanelLeftIcon className="size-4" />
</Button>
) : null}
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="min-h-0 flex-1 overflow-hidden">
<ScrollBlur className="h-full min-h-0" edgeSize={24}>
<SidebarGroup>
{!collapsed ? <SidebarGroupLabel>Mailboxes</SidebarGroupLabel> : null}
<SidebarGroupContent>
<SidebarMenu>
{folders.map((folder) => {
const Icon = folder.icon ?? MailIcon
const count = messages.filter((message) => message.folderId === folder.id).length
return (
<SidebarMenuItem key={folder.id}>
<SidebarMenuButton
isActive={folderId === folder.id}
onClick={() => onSelect(folder.id)}
tooltip={collapsed ? folder.label : undefined}
className={cn(collapsed && "justify-center px-0")}
>
<Icon />
{!collapsed ? <span>{folder.label}</span> : null}
{count && !collapsed ? (
<Badge
variant="secondary"
className="ml-auto h-5 rounded-full px-1.5 text-[10px]"
>
{count}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
{!collapsed ? <SidebarGroupLabel>Actions</SidebarGroupLabel> : null}
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={onCompose}
tooltip={collapsed ? "Compose" : undefined}
className={cn(collapsed && "justify-center px-0")}
>
<Edit3Icon />
{!collapsed ? <span>Compose</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={folderId === STARRED_FOLDER_ID}
onClick={() => onSelect(STARRED_FOLDER_ID)}
tooltip={collapsed ? "Starred" : undefined}
className={cn(collapsed && "justify-center px-0")}
>
<StarIcon />
{!collapsed ? <span>Starred</span> : null}
{messages.filter((message) => message.starred).length && !collapsed ? (
<Badge
variant="secondary"
className="ml-auto h-5 rounded-full px-1.5 text-[10px]"
>
{messages.filter((message) => message.starred).length}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</ScrollBlur>
</SidebarContent>
{!collapsed ? (
<SidebarFooter className="border-t">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="gap-3">
<Avatar className="size-8 rounded-lg after:hidden">
<AvatarImage className="rounded-lg" alt="" />
<AvatarFallback className="rounded-lg">LC</AvatarFallback>
</Avatar>
<span className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Liam Corrigan</span>
<span className="truncate text-xs text-muted-foreground">liam@corr.sh</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
) : null}
</>
)
}
function MessageListPane({
folder,
folders,
messages,
selectedId,
refreshing = false,
compactList = false,
query,
onQueryChange,
onSelect,
onReply,
onReplyAll,
onForward,
onArchive,
onDelete,
onMove,
showHeader = true,
}: {
folder: MailboxFolder
folders: MailboxFolder[]
messages: MailboxMessage[]
selectedId?: string
refreshing?: boolean
compactList?: boolean
query: string
onQueryChange: (query: string) => void
onSelect: (message: MailboxMessage) => void
onReply: (message: MailboxMessage) => void
onReplyAll: (message: MailboxMessage) => void
onForward: (message: MailboxMessage) => void
onArchive: (message: MailboxMessage) => void
onDelete: (message: MailboxMessage) => void
onMove: (message: MailboxMessage, folderId: string) => void
showHeader?: boolean
}) {
return (
<section className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
{showHeader ? (
<div className="h-14 min-w-0 overflow-hidden border-b p-3">
<div className="relative">
<SearchIcon className="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={`Search ${folder.label.toLowerCase()}`}
className="h-8 pl-7"
/>
</div>
</div>
) : null}
<ScrollBlur className="min-h-0 flex-1" edgeSize={28}>
<div className="space-y-1 p-2">
{refreshing ? (
<MessageListSkeleton />
) : (
messages.map((message) => (
<ContextMenu key={message.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => onSelect(message)}
className={cn(
"w-full rounded-md border text-left transition-colors hover:bg-muted/40",
compactList ? "p-2" : "p-3",
selectedId === message.id && "border-foreground/20 bg-muted/30"
)}
>
<MessageListItem message={message} compact={compactList} />
</button>
</ContextMenuTrigger>
<MessageContextMenu
message={message}
folders={folders}
currentFolderId={folder.id}
onReply={onReply}
onReplyAll={onReplyAll}
onForward={onForward}
onArchive={onArchive}
onDelete={onDelete}
onMove={onMove}
/>
</ContextMenu>
))
)}
</div>
</ScrollBlur>
<div className="flex h-8 shrink-0 items-center border-t px-3 text-xs text-muted-foreground">
{refreshing ? "Refreshing..." : `${messages.length} message${messages.length === 1 ? "" : "s"}`}
</div>
</section>
)
}
function MessageListItem({ message, compact = false }: { message: MailboxMessage; compact?: boolean }) {
return (
<div className="flex min-w-0 items-start gap-2">
<Avatar className={cn("shrink-0 rounded-md after:hidden", compact ? "size-7" : "size-8")}>
<AvatarImage src={message.from.avatar} alt="" className="rounded-md" />
<AvatarFallback className="rounded-md">
{initials(message.from.name)}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="flex items-center gap-2">
<span className={cn("truncate text-sm", message.unread && "font-semibold")}>
{message.from.name}
</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
{message.timestamp}
</span>
</span>
<span className={cn("block truncate text-xs font-medium", compact ? "mt-0.5" : "mt-1")}>
{message.subject}
</span>
{!compact ? (
<span className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{message.preview}
</span>
) : null}
</span>
</div>
)
}
function MessageListSkeleton() {
return (
<>
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex gap-2 rounded-md border p-3">
<div className="size-8 shrink-0 animate-pulse rounded-md bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3 w-2/5 animate-pulse rounded bg-muted" />
<div className="h-3 w-3/5 animate-pulse rounded bg-muted" />
<div className="h-3 w-4/5 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</>
)
}
function MessageContextMenu({
message,
folders,
currentFolderId,
onReply,
onReplyAll,
onForward,
onArchive,
onDelete,
onMove,
}: {
message: MailboxMessage
folders: MailboxFolder[]
currentFolderId: string
onReply: (message: MailboxMessage) => void
onReplyAll: (message: MailboxMessage) => void
onForward: (message: MailboxMessage) => void
onArchive: (message: MailboxMessage) => void
onDelete: (message: MailboxMessage) => void
onMove: (message: MailboxMessage, folderId: string) => void
}) {
return (
<ContextMenuContent className="w-52">
<ContextMenuItem onSelect={() => onReply(message)}>
<ReplyIcon />
Reply
</ContextMenuItem>
<ContextMenuItem onSelect={() => onReplyAll(message)}>
<ReplyIcon />
Reply all
</ContextMenuItem>
<ContextMenuItem onSelect={() => onForward(message)}>
<ForwardIcon />
Forward
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<FolderIcon />
Move to folder
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-40">
{folders.map((folder) => {
const Icon = folder.icon ?? FolderIcon
return (
<ContextMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onSelect={() => onMove(message, folder.id)}
>
<Icon />
{folder.label}
</ContextMenuItem>
)
})}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onSelect={() => onArchive(message)}>
<ArchiveIcon />
Archive
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={() => onDelete(message)}>
<Trash2Icon />
Delete
</ContextMenuItem>
</ContextMenuContent>
)
}
function MessageDetailPane({
message,
onReply,
onReplyAll,
onForward,
onToggleStar,
onArchive,
onDelete,
}: {
message: MailboxMessage
onReply: () => void
onReplyAll: () => void
onForward: () => void
onToggleStar: () => void
onArchive: () => void
onDelete: () => void
}) {
return (
<section className="min-h-0 min-w-0 flex-1 overflow-hidden">
<ScrollBlur className="h-full min-h-0" edgeSize={32}>
<article className="min-w-0 space-y-4 p-4">
<div className="flex items-start gap-3">
<Avatar className="size-10 rounded-md after:hidden">
<AvatarImage src={message.from.avatar} alt="" className="rounded-md" />
<AvatarFallback className="rounded-md">
{initials(message.from.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-lg font-semibold tracking-tight">
{message.subject}
</h2>
<p className="break-words text-sm text-muted-foreground">
{message.from.name} · {message.from.email}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onArchive}>
<ArchiveIcon />
Archive
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2Icon />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<Separator />
<p className="break-words text-sm leading-7 text-muted-foreground">{message.body}</p>
{message.thread?.length ? (
<div className="space-y-2 border-t pt-4">
<div className="text-xs font-medium text-muted-foreground">
{message.thread.length} earlier repl{message.thread.length === 1 ? "y" : "ies"}
</div>
{message.thread.map((reply) => (
<ThreadReplyCard key={reply.id} reply={reply} />
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={onReply}>
<ReplyIcon className="size-3.5" />
Reply
</Button>
<Button size="sm" variant="outline" onClick={onReplyAll}>
<ReplyIcon className="size-3.5" />
Reply all
</Button>
<Button size="sm" variant="outline" onClick={onForward}>
<ForwardIcon className="size-3.5" />
Forward
</Button>
<Button
size="icon-sm"
variant={message.starred ? "default" : "outline"}
aria-label={message.starred ? "Remove star" : "Star"}
aria-pressed={message.starred}
onClick={onToggleStar}
>
<StarIcon className={cn(message.starred && "fill-current")} />
</Button>
</div>
</article>
</ScrollBlur>
</section>
)
}
function ThreadReplyCard({ reply }: { reply: MailboxThreadReply }) {
return (
<section className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start gap-3">
<Avatar className="size-8 rounded-md after:hidden">
<AvatarImage src={reply.from.avatar} alt="" className="rounded-md" />
<AvatarFallback className="rounded-md">
{initials(reply.from.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{reply.from.name}</div>
<div className="truncate text-xs text-muted-foreground">
to {formatReplyRecipients(reply.to)}
{reply.cc?.length ? ` · cc ${formatReplyRecipients(reply.cc)}` : ""}
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{reply.timestamp}</span>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{reply.body}</p>
</div>
</div>
</section>
)
}
function ComposePane({
contacts,
draft,
onCancel,
onSend,
}: {
contacts: MailboxContact[]
draft?: MailboxComposeDraft
onCancel: () => void
onSend: (message: MailboxMessage) => void
}) {
const [recipients, setRecipients] = React.useState<string[]>(draft?.to ?? [])
const [cc, setCc] = React.useState<string[]>(draft?.cc ?? [])
const [bcc, setBcc] = React.useState<string[]>(draft?.bcc ?? [])
const [subject, setSubject] = React.useState(draft?.subject ?? "")
const [body, setBody] = React.useState(draft?.body ?? "")
const [attachments, setAttachments] = React.useState<FileUploadDropzoneFile[]>([])
const [attachmentDialogOpen, setAttachmentDialogOpen] = React.useState(false)
const editorRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
const nextBody = draft?.body ?? ""
setRecipients(draft?.to ?? [])
setCc(draft?.cc ?? [])
setBcc(draft?.bcc ?? [])
setSubject(draft?.subject ?? "")
setBody(nextBody)
setAttachments([])
setAttachmentDialogOpen(false)
window.requestAnimationFrame(() => {
if (editorRef.current) {
editorRef.current.innerHTML = formatComposerBody(nextBody)
}
})
}, [draft])
React.useEffect(() => {
if (!attachmentDialogOpen) return
if (!attachments.length) return
if (attachments.some((item) => item.status === "uploading")) return
if (!attachments.every((item) => item.status === "success")) return
const timer = window.setTimeout(() => {
setAttachmentDialogOpen(false)
toast.success("Attachments ready", {
description: `${attachments.length} file${attachments.length === 1 ? "" : "s"} attached.`,
})
}, 250)
return () => window.clearTimeout(timer)
}, [attachmentDialogOpen, attachments])
function syncBodyFromEditor() {
setBody(editorRef.current?.innerText ?? "")
}
function getComposerBody() {
return editorRef.current?.innerText.trim() ?? body.trim()
}
function applyFormat(command: "bold" | "italic" | "underline") {
editorRef.current?.focus()
document.execCommand(command)
syncBodyFromEditor()
}
return (
<section className="min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
<div className="flex h-full min-h-0 flex-col">
<ScrollBlur className="min-h-0 flex-1" edgeSize={32}>
<div className="space-y-3 border-b p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">Compose</h2>
<p className="text-xs text-muted-foreground">Draft a new email</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
const nextBody = getComposerBody()
onSend({
id: `sent-${Date.now()}`,
folderId: "sent",
from: contacts[0],
to: contacts.filter((contact) => recipients.includes(contact.email)),
cc: contacts.filter((contact) => cc.includes(contact.email)),
bcc: contacts.filter((contact) => bcc.includes(contact.email)),
subject,
preview: nextBody,
body: nextBody,
timestamp: "Now",
})
}}
>
<SendIcon className="size-3.5" />
Send
</Button>
</div>
</div>
<div className="grid gap-2">
<RecipientCombobox
label="To"
contacts={contacts}
value={recipients}
onValueChange={setRecipients}
/>
<RecipientCombobox
label="Cc"
contacts={contacts}
value={cc}
onValueChange={setCc}
/>
<RecipientCombobox
label="Bcc"
contacts={contacts}
value={bcc}
onValueChange={setBcc}
/>
</div>
<Input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Subject" />
<div className="flex items-center justify-between gap-2">
<ToggleGroup type="multiple" size="sm" variant="outline">
<ToggleGroupItem
value="bold"
aria-label="Bold"
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyFormat("bold")}
>
<BoldIcon className="size-3.5" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label="Italic"
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyFormat("italic")}
>
<ItalicIcon className="size-3.5" />
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label="Underline"
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyFormat("underline")}
>
<UnderlineIcon className="size-3.5" />
</ToggleGroupItem>
</ToggleGroup>
<Dialog open={attachmentDialogOpen} onOpenChange={setAttachmentDialogOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="Attach files">
<PaperclipIcon />
{attachments.length ? (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-foreground text-[10px] font-medium text-background">
{attachments.length}
</span>
) : null}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Attach files</DialogTitle>
<DialogDescription>
Add supporting files before sending this email.
</DialogDescription>
</DialogHeader>
<FileUploadDropzone
files={attachments}
onFilesChange={setAttachments}
maxFiles={4}
label="Drop email attachments here"
description="or click to browse files"
simulateUpload
/>
<DialogFooter showCloseButton>
<Button
type="button"
onClick={() =>
toast.success("Attachments ready", {
description: `${attachments.length} file${attachments.length === 1 ? "" : "s"} attached.`,
})
}
disabled={!attachments.length}
>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{attachments.length ? (
<div className="flex flex-wrap gap-2 border-b px-4 py-2">
{attachments.map((item) => (
<Badge key={item.id} variant="secondary" className="rounded-md pr-1">
<PaperclipIcon className="size-3" />
{item.file.name}
<button
type="button"
onClick={() =>
setAttachments((current) =>
current.filter((attachment) => attachment.id !== item.id)
)
}
className="ml-1 inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
aria-label={`Remove ${item.file.name}`}
>
<XIcon className="size-3" />
</button>
</Badge>
))}
</div>
) : null}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-placeholder="Write a message..."
onInput={syncBodyFromEditor}
className="min-h-80 bg-background p-4 text-sm leading-7 outline-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] [&_hr]:my-4 [&_hr]:border-border"
/>
</ScrollBlur>
</div>
</section>
)
}
function RecipientCombobox({
contacts,
label,
value,
onValueChange,
}: {
contacts: MailboxContact[]
label: string
value: string[]
onValueChange: (value: string[]) => void
}) {
const anchor = useComboboxAnchor()
const [open, setOpen] = React.useState(false)
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null)
const items = contacts.map((contact) => contact.email)
React.useEffect(() => {
setPortalContainer(document.body)
}, [])
function openCombobox() {
setOpen(true)
}
return (
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)] items-start gap-2">
<div className="pt-1.5 text-xs font-medium text-muted-foreground">{label}</div>
<Combobox
multiple
open={open}
onOpenChange={setOpen}
autoHighlight
openOnInputClick
items={items}
value={value}
onValueChange={(nextValue) => {
onValueChange(nextValue as string[])
window.requestAnimationFrame(() => setOpen(true))
}}
>
<ComboboxChips
ref={anchor}
className="relative min-h-8 w-full pr-8"
onFocusCapture={openCombobox}
onPointerDownCapture={openCombobox}
>
<ComboboxValue>
{(values) => (
<>
{(values as string[]).map((item) => (
<ComboboxChip key={item}>{item}</ComboboxChip>
))}
<ComboboxChipsInput
placeholder={`Search ${label.toLowerCase()} contacts`}
className="min-w-32"
onFocus={openCombobox}
onClick={openCombobox}
/>
</>
)}
</ComboboxValue>
<ComboboxTrigger
className="absolute right-2 top-1.5 inline-flex size-5 items-center justify-center rounded-sm hover:bg-muted"
onClick={openCombobox}
/>
</ComboboxChips>
<ComboboxContent
anchor={anchor}
container={portalContainer ?? undefined}
sideOffset={4}
className="z-[90]"
>
<ComboboxEmpty>No contacts found.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
<span className="grid min-w-0">
<span className="truncate font-medium">
{contacts.find((contact) => contact.email === item)?.name ?? item}
</span>
<span className="truncate text-muted-foreground">{item}</span>
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
}
function withSubjectPrefix(subject: string, prefix: "Re:" | "Fwd:") {
return subject.toLowerCase().startsWith(prefix.toLowerCase())
? subject
: `${prefix} ${subject}`
}
function uniqueEmails(emails: string[]) {
return Array.from(new Set(emails.filter(Boolean)))
}
function formatReplyRecipients(recipients: MailboxContact[] = []) {
if (!recipients.length) return "me"
return recipients.map((contact) => contact.name).join(", ")
}
function buildThreadQuote(message: MailboxMessage, mode: "reply" | "forward") {
const replies = message.thread ?? []
const quotedMessages = [
...replies.map((reply) => ({
from: reply.from,
to: reply.to,
cc: reply.cc,
timestamp: reply.timestamp,
subject: message.subject,
body: reply.body,
})),
{
from: message.from,
to: message.to,
cc: message.cc,
timestamp: message.timestamp,
subject: message.subject,
body: message.body,
},
]
return quotedMessages
.map((item, index) => {
const header =
mode === "forward"
? [
"Forwarded message:",
`From: ${item.from.name} <${item.from.email}>`,
item.to?.length ? `To: ${item.to.map((contact) => contact.email).join(", ")}` : null,
item.cc?.length ? `Cc: ${item.cc.map((contact) => contact.email).join(", ")}` : null,
`Subject: ${item.subject}`,
`Date: ${item.timestamp}`,
]
.filter(Boolean)
.join("\n")
: `${formatQuoteTimestamp(item.timestamp)}, ${item.from.name} wrote:`
return `${index === 0 ? "\n\n" : "\n"}---\n${header}\n${item.body}`
})
.join("\n")
}
function formatQuoteTimestamp(timestamp: string) {
if (/^\d{1,2}:\d{2}$/.test(timestamp)) {
return `Today @ ${timestamp}`
}
if (timestamp.toLowerCase() === "yesterday") {
return "Yesterday"
}
if (timestamp.toLowerCase() === "now") {
return "Just now"
}
if (timestamp.toLowerCase() === "draft") {
return "Draft"
}
return `On ${timestamp}`
}
function formatComposerBody(body: string) {
return body
.split("\n")
.map((line) => {
if (line.trim() === "---") {
return '<hr aria-hidden="true" />'
}
if (!line) {
return "<div><br /></div>"
}
return `<div>${escapeHtml(line)}</div>`
})
.join("")
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'")
}