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]);
|
}, [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 virtualItems = virtualizer.getVirtualItems();
|
||||||
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||||
|
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastVirtualItemIndex === undefined) return;
|
if (lastVirtualItemIndex === undefined) return;
|
||||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
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]);
|
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||||
|
|
||||||
// Scroll to the thread group containing the selected email
|
// 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 }],
|
sort: [{ property: "receivedAt", isAscending: false }],
|
||||||
limit,
|
limit,
|
||||||
position,
|
position,
|
||||||
|
calculateTotal: true,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
@@ -694,7 +695,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const getResponse = response.methodResponses?.[1]?.[1];
|
const getResponse = response.methodResponses?.[1]?.[1];
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
|
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 total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
const hasMore = computeHasMore(position, emails.length, total, limit);
|
||||||
|
|
||||||
@@ -1214,6 +1220,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
sort: [{ property: "receivedAt", isAscending: false }],
|
sort: [{ property: "receivedAt", isAscending: false }],
|
||||||
limit,
|
limit,
|
||||||
position,
|
position,
|
||||||
|
calculateTotal: true,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
@@ -1223,7 +1230,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const queryResponse = response.methodResponses?.[0]?.[1];
|
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 total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
const hasMore = computeHasMore(position, emails.length, total, limit);
|
||||||
|
|
||||||
@@ -1250,6 +1260,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
sort: [{ property: "receivedAt", isAscending: false }],
|
sort: [{ property: "receivedAt", isAscending: false }],
|
||||||
limit,
|
limit,
|
||||||
position,
|
position,
|
||||||
|
calculateTotal: true,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
@@ -1259,7 +1270,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const queryResponse = response.methodResponses?.[0]?.[1];
|
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 total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
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
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
|
||||||
|
// Capture position from current email count before the async call
|
||||||
|
const position = emails.length;
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
const { searchFilters } = get();
|
const { searchFilters } = get();
|
||||||
@@ -355,9 +358,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
if (hasFilters) {
|
if (hasFilters) {
|
||||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||||
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
|
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
|
||||||
} else {
|
} else {
|
||||||
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
|
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Load more from mailbox
|
// 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)
|
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
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({
|
set({
|
||||||
emails: [...emails, ...result.emails],
|
emails: [...currentEmails, ...newEmails],
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoadingMore: false
|
isLoadingMore: false
|
||||||
@@ -1242,12 +1254,30 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
get().handleNewEmailNotification(result.emails[0]);
|
get().handleNewEmailNotification(result.emails[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip state update if emails haven't actually changed to avoid
|
// Merge the refreshed first page with the existing loaded emails.
|
||||||
// unnecessary re-renders that cause a visible list flicker
|
// 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 =
|
const hasChanged =
|
||||||
currentEmails.length !== result.emails.length ||
|
currentEmails.length !== merged.length ||
|
||||||
result.emails.some((email, i) => {
|
merged.some((email, i) => {
|
||||||
const curr = currentEmails[i];
|
const curr = currentEmails[i];
|
||||||
|
if (!curr) return true;
|
||||||
return (
|
return (
|
||||||
curr.id !== email.id ||
|
curr.id !== email.id ||
|
||||||
curr.threadId !== email.threadId ||
|
curr.threadId !== email.threadId ||
|
||||||
@@ -1256,9 +1286,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hasChanged) {
|
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({
|
set({
|
||||||
emails: result.emails,
|
emails: merged,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user