feat: Mail infinite scroll, remove sort header + pagination, connect filter/sort to MailList

This commit is contained in:
Agent Zero
2026-07-30 13:38:43 +02:00
parent 75432cbcfd
commit cfeac52058
2 changed files with 40 additions and 133 deletions
+33 -122
View File
@@ -1,17 +1,15 @@
/** /**
* Mail list component with pagination and virtual scrolling. * Mail list component with infinite scroll.
* Shows list of mails in selected folder with seen/flagged indicators. * No pagination, no sort header — sorting is in toolbar SortPanel.
*/ */
import React, { useRef } from 'react'; import React, { useRef, useEffect } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Mail } from '@/api/mail'; import type { Mail } from '@/api/mail';
import { decodeMimeHeader } from '@/api/mail'; import { decodeMimeHeader } from '@/api/mail';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { Pagination } from '@/components/ui/Pagination'; import { Loader2, Paperclip, Star } from 'lucide-react';
import { ChevronUp, Loader2, Paperclip, Star } from 'lucide-react';
import { formatSmartDate } from '@/utils/date'; import { formatSmartDate } from '@/utils/date';
export interface MailListProps { export interface MailListProps {
@@ -19,16 +17,11 @@ export interface MailListProps {
selectedMailId: string | null; selectedMailId: string | null;
onSelectMail: (mail: Mail) => void; onSelectMail: (mail: Mail) => void;
loading: boolean; loading: boolean;
currentPage: number;
total: number;
pageSize: number;
onPageChange: (page: number) => void;
selectedMailIds: Set<string>; selectedMailIds: Set<string>;
onToggleSelect: (mailId: string) => void; onToggleSelect: (mailId: string) => void;
onSelectAll: () => void; onSelectAll: () => void;
sortBy?: 'date' | 'from' | 'subject'; onLoadMore?: () => void;
sortOrder?: 'asc' | 'desc'; hasMore?: boolean;
onSortChange?: (sortBy: 'date' | 'from' | 'subject', sortOrder: 'asc' | 'desc') => void;
} }
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@@ -40,44 +33,25 @@ export function MailList({
selectedMailId, selectedMailId,
onSelectMail, onSelectMail,
loading, loading,
currentPage,
total,
pageSize,
onPageChange,
selectedMailIds, selectedMailIds,
onToggleSelect, onToggleSelect,
onSelectAll, onSelectAll,
sortBy = 'date', onLoadMore,
sortOrder = 'desc', hasMore = false,
onSortChange,
}: MailListProps) { }: MailListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const totalPages = Math.ceil(total / pageSize);
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id)); const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
const scrollRef = useRef<HTMLUListElement>(null); const scrollRef = useRef<HTMLUListElement>(null);
// Auto-skip virtualization for small datasets // Infinite scroll handler
const shouldVirtualize = mails.length >= 50; const handleScroll = (e: React.UIEvent<HTMLUListElement>) => {
const el = e.currentTarget;
const rowVirtualizer = useVirtualizer({ if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200 && onLoadMore && !loading && hasMore) {
count: shouldVirtualize ? mails.length : 0, onLoadMore();
getScrollElement: () => scrollRef.current, }
estimateSize: () => 80, // approx mail item height
overscan: 8,
enabled: shouldVirtualize,
});
const handleSortFieldChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newSortBy = e.target.value as 'date' | 'from' | 'subject';
onSortChange?.(newSortBy, sortOrder);
}; };
const handleSortOrderToggle = () => { if (loading && mails.length === 0) {
const newOrder = sortOrder === 'asc' ? 'desc' : 'asc';
onSortChange?.(sortBy, newOrder);
};
if (loading) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="mail-list-loading"> <div className="flex items-center justify-center py-12" data-testid="mail-list-loading">
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" /> <Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
@@ -97,7 +71,7 @@ export function MailList({
} }
const renderMailItem = (mail: Mail) => ( const renderMailItem = (mail: Mail) => (
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}> <li key={mail.id} role="listitem" className={clsx('divide-y divide-secondary-100', selectedMailIds.has(mail.id) && 'bg-primary-50')}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="flex items-center pt-3 pl-3 md:pl-4"> <div className="flex items-center pt-3 pl-3 md:pl-4">
<input <input
@@ -167,7 +141,7 @@ export function MailList({
return ( return (
<div className="flex flex-col h-full" data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}> <div className="flex flex-col h-full" data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
{/* Select-all + Sort header in one row */} {/* Select-all header — no sort controls (sorting is in toolbar) */}
<div className="flex items-center justify-between gap-2 px-3 py-2 md:px-4 md:py-2 border-b border-secondary-200 bg-secondary-50 flex-shrink-0"> <div className="flex items-center justify-between gap-2 px-3 py-2 md:px-4 md:py-2 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -184,87 +158,24 @@ export function MailList({
</span> </span>
)} )}
</div> </div>
{onSortChange && ( {loading && mails.length > 0 && <Loader2 className="w-4 h-4 animate-spin text-secondary-400" />}
<div className="flex items-center gap-2" data-testid="mail-sort-bar">
<label htmlFor="mail-sort-by" className="text-xs text-secondary-600">
{t('mail.sortBy')}:
</label>
<select
id="mail-sort-by"
value={sortBy}
onChange={handleSortFieldChange}
className="text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={t('mail.sortBy')}
data-testid="mail-sort-by"
>
<option value="date">{t('mail.sortDate')}</option>
<option value="from">{t('mail.sortFrom')}</option>
<option value="subject">{t('mail.sortSubject')}</option>
</select>
<button
onClick={handleSortOrderToggle}
className="inline-flex items-center gap-1 text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 hover:bg-secondary-100 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
title={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
data-testid="mail-sort-order"
>
<ChevronUp className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
<span>{sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}</span>
</button>
</div>
)}
</div> </div>
{/* Mail list — scrolls, pagination stays fixed */}
{shouldVirtualize ? ( {/* Mail list — infinite scroll, no pagination */}
<ul <ul
ref={scrollRef} ref={scrollRef}
className="divide-y divide-secondary-100 flex-1 overflow-y-auto" className="divide-y divide-secondary-100 flex-1 overflow-y-auto"
role="list" role="list"
style={{ contain: 'strict' }} data-testid="mail-list-items"
> onScroll={handleScroll}
<li >
key="__virtual-spacer" {mails.map((mail) => renderMailItem(mail))}
style={{ {loading && mails.length > 0 && (
height: rowVirtualizer.getTotalSize(), <li className="flex items-center justify-center py-4">
position: 'relative', <Loader2 className="w-5 h-5 animate-spin text-secondary-400" />
width: '100%',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const mail = mails[virtualRow.index];
return (
<div
key={mail.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderMailItem(mail)}
</div>
);
})}
</li> </li>
</ul> )}
) : ( </ul>
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
{mails.map((mail) => renderMailItem(mail))}
</ul>
)}
{totalPages > 1 && (
<div className="flex-shrink-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={onPageChange}
/>
</div>
)}
</div> </div>
); );
} }
+7 -11
View File
@@ -190,7 +190,8 @@ export function MailPage() {
const result = await fetchMails(currentFolderId, mailsPage, sortBy, sortOrder); const result = await fetchMails(currentFolderId, mailsPage, sortBy, sortOrder);
// Only update if still on the same folder // Only update if still on the same folder
if (selectedFolderIdRef.current === currentFolderId) { if (selectedFolderIdRef.current === currentFolderId) {
setMails(result.mails); // Append for infinite scroll (page > 1), replace on page 1 or folder change
setMails(mailsPage > 1 ? (prev) => [...prev, ...result.mails] : result.mails);
setMailsTotal(result.total); setMailsTotal(result.total);
} }
} }
@@ -892,20 +893,15 @@ export function MailPage() {
selectedMailId={selectedMail?.id || null} selectedMailId={selectedMail?.id || null}
onSelectMail={handleSelectMail} onSelectMail={handleSelectMail}
loading={loadingMails} loading={loadingMails}
currentPage={mailsPage}
total={mailsTotal}
pageSize={PAGE_SIZE}
onPageChange={setMailsPage}
selectedMailIds={selectedMailIds} selectedMailIds={selectedMailIds}
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
sortBy={sortBy} onLoadMore={() => {
sortOrder={sortOrder} if (mails.length < mailsTotal) {
onSortChange={(by, order) => { setMailsPage((p) => p + 1);
setSortBy(by); }
setSortOrder(order);
setMailsPage(1);
}} }}
hasMore={mails.length < mailsTotal}
/> />
</ResizablePanel> </ResizablePanel>