mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
//
|
|
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
|
//
|
|
// This file is part of the Bichon Email Archiving Project
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
import * as React from "react"
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "@/components/ui/resizable"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
|
import { AccountSwitcher } from "./account-switcher"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { list_mailboxes, MailboxData } from "@/api/mailbox/api"
|
|
import { useQuery } from "@tanstack/react-query"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import MailboxProvider, { MailboxDialogType } from "../context"
|
|
import useDialogState from "@/hooks/use-dialog-state"
|
|
import { MailboxDialog } from "./mailbox-detail"
|
|
import { MailList } from "./mail-list"
|
|
import { list_messages } from "@/api/mailbox/envelope/api"
|
|
import { MailDisplayDrawer } from "./mail-display-drawer"
|
|
import { toast } from "@/hooks/use-toast"
|
|
import { EnvelopeDeleteDialog } from "./delete-dialog"
|
|
import Logo from '@/assets/logo.svg'
|
|
import { EmailEnvelope } from "@/api"
|
|
import { EnvelopeListPagination } from "@/components/pagination"
|
|
import { RichTreeView, TreeItemCheckbox, TreeItemContent, TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemIconContainer, TreeItemLabel, TreeItemProvider, TreeItemRoot, useTreeItem, useTreeItemModel, UseTreeItemParameters } from "@mui/x-tree-view"
|
|
import { buildTree, ExtendedTreeItemProps } from "@/lib/build-tree"
|
|
import { useTheme } from "@/context/theme-context"
|
|
import { styled } from "@mui/material/styles"
|
|
import { animated, useSpring } from "@react-spring/web"
|
|
import { TransitionProps } from "@mui/material/transitions"
|
|
import Collapse from "@mui/material/Collapse"
|
|
import { FolderIcon, MoreVertical, Trash2 } from "lucide-react"
|
|
import { RestoreMessageDialog } from "./restore-message-dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
import { useTranslation } from "react-i18next"
|
|
import { MailBoxDeleteDialog } from "./delete-mailbox-dialog"
|
|
|
|
|
|
interface MailProps {
|
|
defaultLayout: number[] | undefined
|
|
defaultCollapsed?: boolean
|
|
navCollapsedSize: number,
|
|
lastSelectedAccountId?: number | undefined
|
|
}
|
|
|
|
interface ListMessagesOptions {
|
|
accountId: number | undefined;
|
|
mailboxId: number | undefined;
|
|
page: number;
|
|
page_size: number;
|
|
}
|
|
|
|
const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessagesOptions) => {
|
|
return useQuery({
|
|
queryKey: ['mailbox-list-messages', `${accountId}`, mailboxId, page, page_size],
|
|
queryFn: () => {
|
|
return list_messages(accountId!, mailboxId!, page, page_size);
|
|
},
|
|
enabled: !!accountId && !!mailboxId,
|
|
retry: 0,
|
|
staleTime: 1000,
|
|
});
|
|
};
|
|
|
|
interface CustomLabelProps {
|
|
exists?: number;
|
|
attributes?: { attr: string; extension: string | null }[],
|
|
children: React.ReactNode;
|
|
id: string;
|
|
icon?: React.ElementType;
|
|
expandable?: boolean;
|
|
onDelete: (id: string) => void;
|
|
}
|
|
|
|
function CustomLabel({
|
|
expandable,
|
|
exists,
|
|
attributes,
|
|
children,
|
|
id,
|
|
onDelete,
|
|
...other
|
|
}: CustomLabelProps) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<TreeItemLabel
|
|
{...other}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<FolderIcon className="mr-2" />
|
|
<span className="font-medium text-sm text-inherit">
|
|
{children}
|
|
</span>
|
|
<div className="ml-auto flex items-center">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-20">
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onSelect={(e) => {
|
|
e.preventDefault();
|
|
onDelete(id);
|
|
}}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
<span>{t('common.delete')}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</TreeItemLabel>
|
|
);
|
|
}
|
|
|
|
const CustomCollapse = styled(Collapse)({
|
|
padding: 0,
|
|
});
|
|
|
|
const AnimatedCollapse = animated(CustomCollapse);
|
|
|
|
function TransitionComponent(props: TransitionProps) {
|
|
const style = useSpring({
|
|
to: {
|
|
opacity: props.in ? 1 : 0,
|
|
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
|
|
},
|
|
});
|
|
|
|
return <AnimatedCollapse style={style} {...props} />;
|
|
}
|
|
|
|
interface CustomTreeItemProps
|
|
extends Omit<UseTreeItemParameters, 'rootRef'>,
|
|
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
|
|
|
|
|
|
|
|
export function Mail({
|
|
defaultLayout = [20, 80],
|
|
defaultCollapsed = false,
|
|
navCollapsedSize,
|
|
lastSelectedAccountId,
|
|
}: MailProps) {
|
|
const [open, setOpen] = useDialogState<MailboxDialogType>(null)
|
|
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed)
|
|
const [selectedMailbox, setSelectedMailbox] = React.useState<MailboxData | undefined>(undefined);
|
|
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(lastSelectedAccountId);
|
|
const [selectedEvelope, setSelectedEvelope] = React.useState<EmailEnvelope | undefined>(undefined);
|
|
const [page, setPage] = React.useState(0);
|
|
const [pageSize, setPageSize] = React.useState(30);
|
|
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
|
|
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
|
|
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
|
|
|
|
const { theme } = useTheme()
|
|
|
|
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
|
queryKey: ['account-mailboxes', `${selectedAccountId}`],
|
|
queryFn: () => list_mailboxes(selectedAccountId!, false),
|
|
enabled: !!selectedAccountId,
|
|
})
|
|
|
|
|
|
const tree = buildTree(mailboxes ?? []);
|
|
|
|
const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
|
|
accountId: selectedAccountId,
|
|
mailboxId: selectedMailbox?.id,
|
|
page: page + 1,
|
|
page_size: pageSize
|
|
});
|
|
|
|
const hasNextPage = () => {
|
|
return page + 1 < envelopes?.total_pages!;
|
|
}
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
setPage(newPage);
|
|
}
|
|
|
|
|
|
const handlePageSizeChange = (newSize: number) => {
|
|
setPage(0);
|
|
setPageSize(newSize);
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (isError && error) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Failed to load messages",
|
|
description: error.message || "An unknown error occurred. Please try again.",
|
|
});
|
|
}
|
|
}, [isError, error]);
|
|
|
|
// const handleItemSelectionToggle = (
|
|
// _event: React.SyntheticEvent | null,
|
|
// itemId: string,
|
|
// isSelected: boolean,
|
|
// ) => {
|
|
// if (isSelected) {
|
|
// setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
|
|
// setPage(0);
|
|
// }
|
|
// };
|
|
|
|
const handleItemClick = (
|
|
_event: React.SyntheticEvent | null,
|
|
itemId: string
|
|
) => {
|
|
//console.log(itemId)
|
|
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
|
|
setPage(0);
|
|
};
|
|
|
|
const handleDeleteClick = (id: string) => {
|
|
setDeleteMailboxId(id);
|
|
setOpen('delete');
|
|
};
|
|
|
|
const CustomTreeItem = React.useMemo(() => {
|
|
return React.forwardRef(function CustomTreeItem(
|
|
props: CustomTreeItemProps,
|
|
ref: React.Ref<HTMLLIElement>,
|
|
) {
|
|
const { id, itemId, label, disabled, children, ...other } = props;
|
|
|
|
const {
|
|
getContextProviderProps,
|
|
getRootProps,
|
|
getContentProps,
|
|
getIconContainerProps,
|
|
getCheckboxProps,
|
|
getLabelProps,
|
|
getGroupTransitionProps,
|
|
getDragAndDropOverlayProps,
|
|
status,
|
|
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
|
|
|
|
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
|
|
|
|
return (
|
|
<TreeItemProvider {...getContextProviderProps()}>
|
|
<TreeItemRoot {...getRootProps(other)}>
|
|
<TreeItemContent {...getContentProps()}>
|
|
<TreeItemIconContainer {...getIconContainerProps()}>
|
|
<TreeItemIcon status={status} />
|
|
</TreeItemIconContainer>
|
|
<TreeItemCheckbox {...getCheckboxProps()} />
|
|
<CustomLabel
|
|
{...getLabelProps({
|
|
exists: item.exists,
|
|
id: item.id,
|
|
onDelete: handleDeleteClick,
|
|
attributes: item.attributes,
|
|
expandable: status.expandable && status.expanded,
|
|
})}
|
|
/>
|
|
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
|
|
</TreeItemContent>
|
|
{children && <TransitionComponent {...getGroupTransitionProps()} />}
|
|
</TreeItemRoot>
|
|
</TreeItemProvider>
|
|
);
|
|
});
|
|
}, [theme]);
|
|
|
|
return (
|
|
<MailboxProvider value={{
|
|
open,
|
|
setOpen,
|
|
currentMailbox: selectedMailbox,
|
|
selectedAccountId,
|
|
setCurrentMailbox: setSelectedMailbox,
|
|
currentEnvelope: selectedEvelope,
|
|
setCurrentEnvelope: setSelectedEvelope,
|
|
deleteIds,
|
|
setDeleteIds,
|
|
selected,
|
|
setSelected,
|
|
deleteMailboxId,
|
|
setDeleteMailboxId
|
|
}}>
|
|
<TooltipProvider delayDuration={0}>
|
|
<ResizablePanelGroup
|
|
direction="horizontal"
|
|
onLayout={(sizes: number[]) => {
|
|
localStorage.setItem('react-resizable-panels:layout:mail', JSON.stringify(sizes));
|
|
}}
|
|
className="items-stretch"
|
|
>
|
|
<ResizablePanel
|
|
defaultSize={defaultLayout[0]}
|
|
collapsedSize={navCollapsedSize}
|
|
minSize={navCollapsedSize}
|
|
collapsible={true}
|
|
onCollapse={() => {
|
|
setIsCollapsed(true);
|
|
localStorage.setItem('react-resizable-panels:collapsed', JSON.stringify(true));
|
|
}}
|
|
onResize={() => {
|
|
setIsCollapsed(false);
|
|
localStorage.setItem('react-resizable-panels:collapsed', JSON.stringify(false));
|
|
}}
|
|
className={cn(
|
|
isCollapsed &&
|
|
"min-w-[50px] transition-all duration-300 ease-in-out"
|
|
)}
|
|
>
|
|
<Separator className="mb-2" />
|
|
<ScrollArea className='h-[calc(100vh-8rem)] w-full pr-4 -mr-4 py-1'>
|
|
<div>
|
|
<AccountSwitcher onAccountSelect={(accountId) => {
|
|
localStorage.setItem('mailbox:selectedAccountId', `${accountId}`);
|
|
setSelectedAccountId(accountId);
|
|
setSelectedMailbox(undefined);
|
|
}} defaultAccountId={lastSelectedAccountId} />
|
|
</div>
|
|
<Separator className="mt-2" />
|
|
{isMailboxesLoading ? (
|
|
<div className="space-y-2 p-4">
|
|
{Array.from({ length: 5 }).map((_, index) => (
|
|
<div key={index} className="space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<Skeleton className="h-4 w-4 rounded-full" />
|
|
<Skeleton className="h-4 w-[200px]" />
|
|
</div>
|
|
<div className="pl-6 space-y-2">
|
|
{Array.from({ length: 3 }).map((_, subIndex) => (
|
|
<div key={subIndex} className="flex items-center space-x-2">
|
|
<Skeleton className="h-4 w-4 rounded-full" />
|
|
<Skeleton className="h-4 w-[150px]" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<RichTreeView
|
|
//checkboxSelection
|
|
items={tree}
|
|
expansionTrigger="iconContainer"
|
|
onItemClick={handleItemClick}
|
|
slots={{ item: CustomTreeItem }}
|
|
/>
|
|
)}
|
|
</ScrollArea>
|
|
</ResizablePanel>
|
|
<ResizableHandle withHandle className="h-[calc(100vh-7rem)]" />
|
|
<ResizablePanel defaultSize={defaultLayout[1]}>
|
|
{selectedMailbox && <div>
|
|
<Separator />
|
|
<div className="flex items-center px-4 py-2">
|
|
<h2 className="text-xl font-bold cursor-pointer hover:underline" onClick={() => setOpen("mailbox")}>
|
|
{selectedMailbox?.name}
|
|
</h2>
|
|
</div>
|
|
<Separator />
|
|
<div className="mt-2">
|
|
<ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
|
|
<MailList
|
|
isLoading={isMessagesLoading}
|
|
items={(envelopes?.items ?? []).sort((a, b) => {
|
|
const dateA = a.date;
|
|
const dateB = b.date;
|
|
return dateB - dateA;
|
|
})}
|
|
/>
|
|
</ScrollArea>
|
|
{selectedMailbox && <div className="flex justify-center mt-4">
|
|
<EnvelopeListPagination
|
|
totalItems={envelopes?.total_items ?? 0}
|
|
hasNextPage={hasNextPage}
|
|
pageIndex={page}
|
|
pageSize={pageSize}
|
|
setPageIndex={handlePageChange}
|
|
setPageSize={handlePageSizeChange}
|
|
/>
|
|
</div>}
|
|
</div>
|
|
</div>}
|
|
{!selectedMailbox && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
|
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
|
<img
|
|
src={Logo}
|
|
className='mb-6 opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100'
|
|
width={350}
|
|
height={350}
|
|
alt='Bichon Logo'
|
|
/>
|
|
</div>
|
|
</div>
|
|
}
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</TooltipProvider>
|
|
<MailboxDialog
|
|
key='mailbox-detail'
|
|
open={open === 'mailbox'}
|
|
onOpenChange={() => setOpen('mailbox')}
|
|
/>
|
|
<MailDisplayDrawer
|
|
key='mail-display'
|
|
open={open === 'display'}
|
|
onOpenChange={() => setOpen('display')}
|
|
/>
|
|
<EnvelopeDeleteDialog
|
|
key='envelope-move-to-trash'
|
|
open={open === 'move-to-trash'}
|
|
onOpenChange={() => setOpen('move-to-trash')}
|
|
/>
|
|
|
|
<RestoreMessageDialog
|
|
key='envelope-restore'
|
|
open={open === 'restore'}
|
|
onOpenChange={() => setOpen('restore')}
|
|
/>
|
|
<MailBoxDeleteDialog
|
|
key='mailbox-delete'
|
|
open={open === 'delete'}
|
|
onOpenChange={() => setOpen('delete')}
|
|
/>
|
|
|
|
</MailboxProvider >
|
|
)
|
|
} |