mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
222 lines
7.7 KiB
TypeScript
222 lines
7.7 KiB
TypeScript
//
|
|
// Copyright (c) 2025-2026 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 { useState } from 'react';
|
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
import { ChevronDown, ChevronUp, Loader2, MessageSquareText } from 'lucide-react';
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { get_thread_messages } from '@/api/mailbox/envelope/api';
|
|
import { MailMessageView } from './mail-message-view';
|
|
import { useSearchContext } from './context';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { format } from 'date-fns';
|
|
|
|
interface MailThreadDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
|
|
const { currentEnvelope } = useSearchContext();
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
const { t } = useTranslation();
|
|
|
|
const threadId = currentEnvelope?.thread_id;
|
|
const accountId = currentEnvelope?.account_id;
|
|
|
|
const {
|
|
data,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
isLoading,
|
|
isError,
|
|
error,
|
|
} = useInfiniteQuery({
|
|
queryKey: ['thread', accountId, threadId],
|
|
queryFn: ({ pageParam = 1 }) =>
|
|
get_thread_messages(accountId!, threadId!, pageParam, 10),
|
|
getNextPageParam: (lastPage) =>
|
|
lastPage.current_page && lastPage.total_pages
|
|
? lastPage.current_page < lastPage.total_pages
|
|
? lastPage.current_page + 1
|
|
: undefined
|
|
: undefined,
|
|
enabled: open && !!accountId && !!threadId,
|
|
initialPageParam: 1,
|
|
});
|
|
|
|
const allMessages = data?.pages.flatMap((page) => page.items) ?? [];
|
|
const totalCount = data?.pages[0]?.total_items ?? 0;
|
|
|
|
const toggleExpand = (id: string) => {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="w-full max-width-full p-0 max-h-full flex flex-col md:max-w-3xl lg:max-w-4xl">
|
|
{/* Header */}
|
|
<DialogHeader className="p-4 pb-3 border-b shrink-0">
|
|
<div className="flex items-center justify-between">
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<MessageSquareText className="w-5 h-5" />
|
|
<div className="text-sm">
|
|
{t('search.thread.title', { count: totalCount })}
|
|
</div>
|
|
</DialogTitle>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{isLoading && <ThreadSkeleton />}
|
|
|
|
{isError && (
|
|
<div className="text-center text-destructive text-sm">
|
|
{t('search.thread.error')}: {(error as Error)?.message}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && allMessages.length === 0 && (
|
|
<div className="text-center text-muted-foreground text-sm">
|
|
{t('search.thread.empty')}
|
|
</div>
|
|
)}
|
|
|
|
{allMessages
|
|
.sort((a, b) => a.date - b.date)
|
|
.map((msg) => {
|
|
const isExpanded = expandedIds.has(msg.id);
|
|
const preview = msg.preview;
|
|
const date = new Date(msg.date);
|
|
const formattedDate = isNaN(date.getTime())
|
|
? t('search.thread.invalidDate')
|
|
: format(date, 'yyyy-MM-dd HH:mm:ss');
|
|
|
|
return (
|
|
<Card
|
|
key={msg.id}
|
|
className={`transition-all ${isExpanded ? 'ring-2 ring-primary' : ''}`}
|
|
>
|
|
<CardHeader
|
|
className="cursor-pointer pb-3"
|
|
onClick={() => toggleExpand(msg.id)}
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="font-medium truncate">{msg.from}</span>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="text-muted-foreground truncate">
|
|
{msg.to.join(', ')}
|
|
</span>
|
|
</div>
|
|
<p className="font-medium mt-1 text-sm">
|
|
{msg.subject || t('search.thread.noSubject')}
|
|
</p>
|
|
{!isExpanded && preview && (
|
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
{preview}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>{formattedDate}</span>
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-4 h-4" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{isExpanded && (
|
|
<CardContent className="p-0">
|
|
<div className="h-96 border-t m-5">
|
|
<MailMessageView
|
|
envelope={msg}
|
|
showActions={false}
|
|
showAttachments={false}
|
|
showHeader={false}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
);
|
|
})}
|
|
|
|
{hasNextPage && (
|
|
<div className="flex justify-center py-3">
|
|
<Button
|
|
onClick={() => fetchNextPage()}
|
|
disabled={isFetchingNextPage}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
{isFetchingNextPage ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
{t('search.thread.loadingMore')}
|
|
</>
|
|
) : (
|
|
t('search.thread.loadMore')
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// Skeleton
|
|
function ThreadSkeleton() {
|
|
return (
|
|
<div className="space-y-4">
|
|
{[...Array(3)].map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader>
|
|
<Skeleton className="h-4 w-48 mb-2" />
|
|
<Skeleton className="h-5 w-64 mb-1" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-32 mt-2" />
|
|
</CardHeader>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|