put the email folder in the query string
The last selection still living in a global. `?folder=sent` is now the state, the folder pills are links, and the open email carries it — a bare `/email/:id` would have dropped the query string and snapped the list back to inbox, so the row links and the arrow-key navigate pass it through. The auto-switch to "all" when the inbox is empty writes with `replace`: it is the app correcting its own default, not a place you chose to be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import {
|
||||
ChevronLeft,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { EmailSummary } from 'types';
|
||||
import { useComposer } from './Compose';
|
||||
import { emailPath, useSelectedEmailId } from './shared';
|
||||
@@ -57,7 +56,13 @@ export const EmailList = () => {
|
||||
const [, openCompose] = useComposer();
|
||||
const navigate = useNavigate();
|
||||
const selectedId = useSelectedEmailId();
|
||||
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
|
||||
// Which folder you are reading is addressable state, so it is a query param rather than a global.
|
||||
// It stays a *param* and not a path segment because the path already spells which email is open:
|
||||
// `/email/:emailId` is the thing, the folder is the view you found it through, and switching folders
|
||||
// with a message open should not close it.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const folder = searchParams.get('folder') ?? 'inbox';
|
||||
const qs = searchParams.toString() ? `?${searchParams}` : '';
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
@@ -66,9 +71,11 @@ export const EmailList = () => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
// Page 1 on a new search *or* a new folder. The folder half used to live in the click handler; it
|
||||
// cannot any more, because the folder pills are links and nobody handles their click.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
}, [debouncedSearch, folder]);
|
||||
|
||||
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
||||
queryKey: ['email-accounts'],
|
||||
@@ -102,9 +109,18 @@ export const EmailList = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
|
||||
setFolder('all');
|
||||
// `replace`: this is the app correcting its own default, not a place you chose to be, so Back
|
||||
// should leave /email rather than bounce you between inbox and all.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('folder', 'all');
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [isSearching, folder, data?.total, allCount]);
|
||||
}, [isSearching, folder, data?.total, allCount, setSearchParams]);
|
||||
|
||||
// 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).
|
||||
@@ -126,11 +142,6 @@ export const EmailList = () => {
|
||||
return () => es.close();
|
||||
}, [queryClient]);
|
||||
|
||||
const handleFolderChange = (newFolder: string) => {
|
||||
setFolder(newFolder);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const handleSync = async () => {
|
||||
@@ -191,13 +202,13 @@ export const EmailList = () => {
|
||||
// `replace` on purpose: sweeping down a folder with the arrow keys would otherwise stack one
|
||||
// history entry per row, and Back would then walk the sweep instead of leaving the mailbox.
|
||||
// A click is a real link and does push.
|
||||
navigate(emailPath(nextId), { replace: true });
|
||||
navigate(emailPath(nextId, qs), { replace: true });
|
||||
document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [messages, selectedId, navigate]);
|
||||
}, [messages, selectedId, navigate, qs]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
|
||||
@@ -247,18 +258,24 @@ export const EmailList = () => {
|
||||
{FOLDERS.map((f) => {
|
||||
const Icon = f.icon;
|
||||
const isActive = folder === f.key;
|
||||
// Search-only `to`, so react-router keeps the current pathname — the open email survives a
|
||||
// folder switch. Built from the live params rather than written flat, so anything else in
|
||||
// the query string comes along.
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set('folder', f.key);
|
||||
return (
|
||||
<button
|
||||
<Link
|
||||
key={f.key}
|
||||
onClick={() => handleFolderChange(f.key)}
|
||||
to={{ search: `?${next}` }}
|
||||
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
|
||||
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
||||
}`}
|
||||
title={f.label}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -340,7 +357,7 @@ export const EmailList = () => {
|
||||
return (
|
||||
<Link
|
||||
key={msg.id}
|
||||
to={emailPath(msg.id)}
|
||||
to={emailPath(msg.id, qs)}
|
||||
data-email-id={msg.id}
|
||||
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
|
||||
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useParams } from 'react-router';
|
||||
// no cmd-click, no middle-click — and it meant Back raced the effect that had just written the URL
|
||||
// with `replace`. The param is the state now; there is nothing to keep in sync.
|
||||
|
||||
export const emailPath = (id: string) => `/email/${encodeURIComponent(id)}`;
|
||||
/** `search` carries the folder you are reading in (`?folder=sent`), which a bare pathname would drop. */
|
||||
export const emailPath = (id: string, search = '') => `/email/${encodeURIComponent(id)}${search}`;
|
||||
|
||||
/** The open email, or null on the bare `/email` route — which is a real state, not one to redirect away. */
|
||||
export const useSelectedEmailId = (): string | null => useParams<{ emailId?: string }>().emailId ?? null;
|
||||
|
||||
Reference in New Issue
Block a user