feat: Mail infinite scroll, remove sort header + pagination, connect filter/sort to MailList
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user