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:
2026-08-07 13:01:04 +00:00
co-authored by Claude Opus 5
parent f3538cde25
commit 33492262b5
2 changed files with 36 additions and 18 deletions
@@ -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;