email: full-text search (FTS5) — backend index + /email/search + search box in the list
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Send, ShieldAlert, Trash2 } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, Trash2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
@@ -41,6 +41,17 @@ export const EmailList = () => {
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const isSearching = debouncedSearch.length > 0;
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
||||
queryKey: ['email-accounts'],
|
||||
queryFn: () => client.get<EmailAccountRow[]>('/email/accounts'),
|
||||
@@ -50,23 +61,25 @@ export const EmailList = () => {
|
||||
const hasAccounts = emailAccounts.length > 0;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['email-messages', page, folder],
|
||||
queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder],
|
||||
queryFn: () =>
|
||||
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
|
||||
isSearching
|
||||
? client.get<{ messages: EmailSummary[]; total: number }>(`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`)
|
||||
: client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
|
||||
});
|
||||
|
||||
// Auto-switch to "all" if inbox filter returns nothing but emails exist
|
||||
// Auto-switch to "all" if inbox filter returns nothing but emails exist (not while searching)
|
||||
const { data: allCount } = useQuery({
|
||||
queryKey: ['email-messages-all-count'],
|
||||
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'),
|
||||
enabled: folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0,
|
||||
enabled: !isSearching && folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
|
||||
if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
|
||||
setFolder('all');
|
||||
}
|
||||
}, [folder, data?.total, allCount]);
|
||||
}, [isSearching, folder, data?.total, allCount]);
|
||||
|
||||
// Live updates: while /email is open, listen for new-mail pushes (IMAP IDLE → SSE) and refetch.
|
||||
// The EventSource closes automatically when this component unmounts (i.e. when you leave /email).
|
||||
@@ -163,8 +176,8 @@ export const EmailList = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show onboarding empty state only when no emails exist at all
|
||||
const hasNoEmails = total === 0 && folder === 'inbox' && !allCount?.total;
|
||||
// Show onboarding empty state only when no emails exist at all (never while searching)
|
||||
const hasNoEmails = !isSearching && total === 0 && folder === 'inbox' && !allCount?.total;
|
||||
if (hasNoEmails && !isLoading && page === 1) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
|
||||
@@ -252,8 +265,26 @@ export const EmailList = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 opacity-40" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search mail…"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
|
||||
/>
|
||||
{isSearching && <span className="text-xs opacity-50 shrink-0">{total} result{total === 1 ? '' : 's'}</span>}
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer" title="Clear search">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm opacity-40">No emails in this folder</div>
|
||||
<div className="flex flex-1 items-center justify-center text-sm opacity-40">
|
||||
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
||||
{messages.map((msg: EmailSummary) => {
|
||||
|
||||
Reference in New Issue
Block a user