diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 5ee56e9d..cf220411 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -221,15 +221,26 @@ export function EmailList({ } }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); - // Range-based load more: trigger when last visible item is near the end + // Range-based load more: trigger when last visible item is near the end. + // Debounce to prevent rapid cascade when thread grouping reduces item + // count below the viewport size (e.g. 2400 emails → fewer thread groups). const virtualItems = virtualizer.getVirtualItems(); const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; + const loadMoreTimerRef = useRef | null>(null); useEffect(() => { if (lastVirtualItemIndex === undefined) return; if (lastVirtualItemIndex >= threadGroups.length - 5) { - handleLoadMore(); + // Clear any pending timer so we don't stack calls + if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); + loadMoreTimerRef.current = setTimeout(() => { + handleLoadMore(); + loadMoreTimerRef.current = null; + }, 150); } + return () => { + if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); + }; }, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]); // Scroll to the thread group containing the selected email diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 86d5981d..154d4125 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -682,6 +682,7 @@ export class JMAPClient implements IJMAPClient { sort: [{ property: "receivedAt", isAscending: false }], limit, position, + calculateTotal: true, }, "0"], ["Email/get", { accountId: targetAccountId, @@ -694,7 +695,12 @@ export class JMAPClient implements IJMAPClient { const getResponse = response.methodResponses?.[1]?.[1]; if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) { - const emails = getResponse.list || []; + const emails = (getResponse.list || []) as Email[]; + // Sort client-side as safety net — some servers may not honour + // the query sort for large mailboxes without additional filters. + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); @@ -1214,6 +1220,7 @@ export class JMAPClient implements IJMAPClient { sort: [{ property: "receivedAt", isAscending: false }], limit, position, + calculateTotal: true, }, "0"], ["Email/get", { accountId: targetAccountId, @@ -1223,7 +1230,10 @@ export class JMAPClient implements IJMAPClient { ]); const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = response.methodResponses?.[1]?.[1]?.list || []; + const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[]; + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); @@ -1250,6 +1260,7 @@ export class JMAPClient implements IJMAPClient { sort: [{ property: "receivedAt", isAscending: false }], limit, position, + calculateTotal: true, }, "0"], ["Email/get", { accountId: targetAccountId, @@ -1259,7 +1270,10 @@ export class JMAPClient implements IJMAPClient { ]); const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = response.methodResponses?.[1]?.[1]?.list || []; + const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[]; + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); diff --git a/stores/email-store.ts b/stores/email-store.ts index a3473135..db01b5be 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -342,6 +342,9 @@ export const useEmailStore = create((set, get) => ({ // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; + // Capture position from current email count before the async call + const position = emails.length; + let result; const { searchFilters } = get(); @@ -355,9 +358,9 @@ export const useEmailStore = create((set, get) => ({ if (hasFilters) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length); + result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position); } else { - result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length); + result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); } } else { // Load more from mailbox @@ -369,11 +372,20 @@ export const useEmailStore = create((set, get) => ({ // Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store) const jmapMailboxId = mailbox?.originalId || selectedMailbox; - result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length, selectedKeyword ? `$label:${selectedKeyword}` : undefined); + result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); } + // Use fresh state when merging to avoid overwriting concurrent updates + // (e.g. refreshCurrentMailbox running during the load) + const currentEmails = get().emails; + + // Deduplicate: the server may return overlapping results if new emails + // arrived between paginated requests and shifted positions. + const existingIds = new Set(currentEmails.map(e => e.id)); + const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id)); + set({ - emails: [...emails, ...result.emails], + emails: [...currentEmails, ...newEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false @@ -1242,12 +1254,30 @@ export const useEmailStore = create((set, get) => ({ get().handleNewEmailNotification(result.emails[0]); } - // Skip state update if emails haven't actually changed to avoid - // unnecessary re-renders that cause a visible list flicker + // Merge the refreshed first page with the existing loaded emails. + // This avoids discarding already-loaded pages which would cause the + // virtual list to shrink and then rapidly re-load (scroll bounce). + const freshMap = new Map(result.emails.map((e: Email) => [e.id, e])); + + // Build the merged list: start with the fresh first page, then append + // existing emails beyond that page (if any), skipping duplicates and + // emails removed from the first page (e.g. deleted or moved). + const merged: Email[] = [...result.emails]; + const mergedIds = new Set(result.emails.map((e: Email) => e.id)); + + for (const email of currentEmails) { + if (!mergedIds.has(email.id)) { + merged.push(email); + mergedIds.add(email.id); + } + } + + // Check if anything actually changed to avoid unnecessary re-renders const hasChanged = - currentEmails.length !== result.emails.length || - result.emails.some((email, i) => { + currentEmails.length !== merged.length || + merged.some((email, i) => { const curr = currentEmails[i]; + if (!curr) return true; return ( curr.id !== email.id || curr.threadId !== email.threadId || @@ -1256,9 +1286,12 @@ export const useEmailStore = create((set, get) => ({ }); if (hasChanged) { + // hasMore should reflect whether there are still more emails beyond + // what we have loaded, using the fresh total from the server. + const hasMore = merged.length < (result.total || 0); set({ - emails: result.emails, - hasMoreEmails: result.hasMore, + emails: merged, + hasMoreEmails: hasMore, totalEmails: result.total, }); }