feat: Search pagination and UX improvements

- Add pagination support to search (previously limited to 50 results)
- Search now scoped to current mailbox/folder
- Support shared mailbox folders in search
- Display total results count (e.g., "50 of 1400 conversations")
- Add clear (X) button to search input
- Re-run search when changing folders during active search
- Remove unused sidebar screenshot
This commit is contained in:
Matthieu MALVACHE
2025-12-17 18:39:49 +01:00
committed by Matthieu MALVACHE
parent ec9b322726
commit 93834511c0
7 changed files with 117 additions and 34 deletions
-8
View File
@@ -183,14 +183,6 @@ npm start
**Settings**
<img src="screenshots/06-settings.png" width="100%" alt="Settings">
</td>
</tr>
<tr>
<td colspan="2">
**Sidebar Navigation**
<img src="screenshots/07-inbox-sidebar.png" width="50%" alt="Sidebar">
</td>
</tr>
</table>
+16
View File
@@ -67,6 +67,8 @@ export default function Home() {
toggleStar,
moveToMailbox,
searchEmails,
searchQuery,
setSearchQuery,
isLoading,
isLoadingEmail,
setLoadingEmail,
@@ -469,8 +471,13 @@ export default function Home() {
}
if (client) {
// If there's an active search, re-run it in the new mailbox
if (searchQuery) {
await searchEmails(client, searchQuery);
} else {
await fetchEmails(client, mailboxId);
}
}
};
const handleLogout = () => {
@@ -483,6 +490,13 @@ export default function Home() {
await searchEmails(client, query);
};
const handleClearSearch = async () => {
setSearchQuery("");
if (client && selectedMailbox) {
await fetchEmails(client, selectedMailbox);
}
};
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
if (!client) return;
@@ -645,6 +659,8 @@ export default function Home() {
}}
onLogout={handleLogout}
onSearch={handleSearch}
onClearSearch={handleClearSearch}
activeSearchQuery={searchQuery}
quota={quota}
isPushConnected={isPushConnected}
/>
+8 -1
View File
@@ -60,6 +60,7 @@ export function EmailList({
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
totalEmails,
mailboxes,
selectedMailbox,
expandedThreadIds,
@@ -260,7 +261,13 @@ export function EmailList({
)}
</button>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'}
{isLoading ? 'Loading...' : threadGroups.length > 0
? (totalEmails > threadGroups.length
? `${threadGroups.length} of ${totalEmails} conversations`
: hasMoreEmails
? `${threadGroups.length}+ conversations`
: `${threadGroups.length} conversations`)
: 'No conversations'}
</h2>
</div>
</div>
+24 -1
View File
@@ -24,6 +24,7 @@ import {
ChevronUp,
Users,
User,
X,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
@@ -37,6 +38,8 @@ interface SidebarProps {
onCompose?: () => void;
onLogout?: () => void;
onSearch?: (query: string) => void;
onClearSearch?: () => void;
activeSearchQuery?: string;
quota?: { used: number; total: number } | null;
isPushConnected?: boolean;
className?: string;
@@ -206,6 +209,8 @@ export function Sidebar({
onCompose,
onLogout,
onSearch,
onClearSearch,
activeSearchQuery = "",
quota,
isPushConnected = false,
className,
@@ -215,6 +220,11 @@ export function Sidebar({
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [showMenu, setShowMenu] = useState(false);
const t = useTranslations('sidebar');
// Sync local search query with store's active search query
useEffect(() => {
setSearchQuery(activeSearchQuery);
}, [activeSearchQuery]);
const params = useParams();
const router = useRouter();
@@ -334,9 +344,22 @@ export function Sidebar({
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
className={cn("pl-9", searchQuery && "pr-8")}
data-search-input
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
onClearSearch?.();
}}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</form>
</div>
)}
+26 -11
View File
@@ -753,19 +753,27 @@ export class JMAPClient {
]);
}
async searchEmails(query: string, limit: number = 50): Promise<Email[]> {
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
// Use provided accountId or fallback to primary account
const targetAccountId = accountId || this.accountId;
// Build filter with text search, optionally scoped to a mailbox
const filter: Record<string, unknown> = { text: query };
if (mailboxId) {
filter.inMailbox = mailboxId;
}
const response = await this.request([
["Email/query", {
accountId: this.accountId,
filter: {
text: query,
},
accountId: targetAccountId,
filter: filter,
sort: [{ property: "receivedAt", isAscending: false }],
limit: limit,
position: position,
}, "0"],
["Email/get", {
accountId: this.accountId,
accountId: targetAccountId,
"#ids": {
resultOf: "0",
name: "Email/query",
@@ -788,14 +796,21 @@ export class JMAPClient {
}, "1"],
]);
if (response.methodResponses?.[1]?.[0] === "Email/get") {
return response.methodResponses[1][1].list || [];
}
const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || [];
return [];
// Stalwart doesn't always return 'total', so we use a different strategy:
// If we got exactly 'limit' emails, there might be more
// If we got fewer, we've reached the end
const total = queryResponse?.total || 0;
const hasMore = total > 0
? (position + emails.length) < total // Use total if available
: emails.length === limit; // Otherwise, check if we got a full page
return { emails, hasMore, total };
} catch (error) {
console.error('Search failed:', error);
return [];
return { emails: [], hasMore: false, total: 0 };
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

+37 -7
View File
@@ -200,13 +200,29 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
loadMoreEmails: async (client) => {
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox } = get();
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
set({ isLoadingMore: true, error: null });
try {
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
let result;
// Check if we're in search mode
if (searchQuery) {
// Load more search results (scoped to current mailbox)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
} else {
// Load more from mailbox
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
@@ -215,10 +231,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
}
set({
emails: [...emails, ...result.emails],
@@ -565,8 +579,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
const emails = await client.searchEmails(query);
set({ emails, isLoading: false, hasMoreEmails: false, totalEmails: emails.length });
// Get the current mailbox to scope the search
const selectedMailbox = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to search emails",