fix: enhance email loading and deduplication logic in email store #119
This commit is contained in:
@@ -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<ReturnType<typeof setTimeout> | 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
|
||||
|
||||
+17
-3
@@ -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);
|
||||
|
||||
|
||||
+43
-10
@@ -342,6 +342,9 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user