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 { 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 { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
|
||||||
import type { EmailSummary } from 'types';
|
import type { EmailSummary } from 'types';
|
||||||
import { useComposer } from './Compose';
|
import { useComposer } from './Compose';
|
||||||
import { emailPath, useSelectedEmailId } from './shared';
|
import { emailPath, useSelectedEmailId } from './shared';
|
||||||
@@ -57,7 +56,13 @@ export const EmailList = () => {
|
|||||||
const [, openCompose] = useComposer();
|
const [, openCompose] = useComposer();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const selectedId = useSelectedEmailId();
|
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 [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
@@ -66,9 +71,11 @@ export const EmailList = () => {
|
|||||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [search]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [debouncedSearch]);
|
}, [debouncedSearch, folder]);
|
||||||
|
|
||||||
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
||||||
queryKey: ['email-accounts'],
|
queryKey: ['email-accounts'],
|
||||||
@@ -102,9 +109,18 @@ export const EmailList = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
|
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.
|
// 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).
|
// The EventSource closes automatically when this component unmounts (i.e. when you leave /email).
|
||||||
@@ -126,11 +142,6 @@ export const EmailList = () => {
|
|||||||
return () => es.close();
|
return () => es.close();
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const handleFolderChange = (newFolder: string) => {
|
|
||||||
setFolder(newFolder);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
const handleSync = async () => {
|
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
|
// `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.
|
// 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.
|
// 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' });
|
document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [messages, selectedId, navigate]);
|
}, [messages, selectedId, navigate, qs]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
|
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) => {
|
{FOLDERS.map((f) => {
|
||||||
const Icon = f.icon;
|
const Icon = f.icon;
|
||||||
const isActive = folder === f.key;
|
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 (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={f.key}
|
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 ${
|
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'
|
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
title={f.label}
|
title={f.label}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
<span className="hidden sm:inline">{f.label}</span>
|
<span className="hidden sm:inline">{f.label}</span>
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +357,7 @@ export const EmailList = () => {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
to={emailPath(msg.id)}
|
to={emailPath(msg.id, qs)}
|
||||||
data-email-id={msg.id}
|
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 ${
|
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'
|
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
|
// 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.
|
// 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. */
|
/** 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;
|
export const useSelectedEmailId = (): string | null => useParams<{ emailId?: string }>().emailId ?? null;
|
||||||
|
|||||||
Reference in New Issue
Block a user