fix: fix inconsistent behavior with threading email messages in the inbox/folders
This commit is contained in:
@@ -335,6 +335,7 @@ export default function Home() {
|
|||||||
viewingAccountId,
|
viewingAccountId,
|
||||||
selectAccountMailbox,
|
selectAccountMailbox,
|
||||||
setViewingAccount,
|
setViewingAccount,
|
||||||
|
refreshCurrentMailbox,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
// Pro shell: populate per-account mailbox cache so the sidebar can render
|
// Pro shell: populate per-account mailbox cache so the sidebar can render
|
||||||
@@ -1168,7 +1169,26 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the current mailbox to update the UI
|
// Refresh the current mailbox to update the UI
|
||||||
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
|
if (!isScheduledView) {
|
||||||
|
await refreshCurrentMailbox(client);
|
||||||
|
// Re-fetch the replied thread's cross-folder data so the expanded
|
||||||
|
// view shows the newly sent reply without collapsing.
|
||||||
|
if (originalEmailId) {
|
||||||
|
const emailState = useEmailStore.getState();
|
||||||
|
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
|
||||||
|
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||||
|
const accountId = client.getAccountId();
|
||||||
|
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
|
||||||
|
if (fullEmails.length > 0) {
|
||||||
|
useEmailStore.setState((state) => {
|
||||||
|
const c = new Map(state.threadEmailsCache);
|
||||||
|
c.set(repliedEmail.threadId!, fullEmails);
|
||||||
|
return { threadEmailsCache: c };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send email:", error);
|
console.error("Failed to send email:", error);
|
||||||
}
|
}
|
||||||
@@ -2201,7 +2221,22 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh emails to show the sent reply
|
// Refresh emails to show the sent reply
|
||||||
await fetchEmails(client, selectedMailbox);
|
await refreshCurrentMailbox(client);
|
||||||
|
// Re-fetch the replied thread's cross-folder data so the expanded
|
||||||
|
// view shows the newly sent reply without collapsing.
|
||||||
|
const emailState = useEmailStore.getState();
|
||||||
|
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
|
||||||
|
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||||
|
const accountId = client.getAccountId();
|
||||||
|
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
|
||||||
|
if (fullEmails.length > 0) {
|
||||||
|
useEmailStore.setState((state) => {
|
||||||
|
const c = new Map(state.threadEmailsCache);
|
||||||
|
c.set(repliedEmail.threadId!, fullEmails);
|
||||||
|
return { threadEmailsCache: c };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state while checking auth
|
// Show loading state while checking auth
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export function EmailList({
|
|||||||
isLoadingThread,
|
isLoadingThread,
|
||||||
toggleThreadExpansion,
|
toggleThreadExpansion,
|
||||||
fetchThreadEmails,
|
fetchThreadEmails,
|
||||||
|
markThreadAsRead,
|
||||||
|
threadEmailCounts,
|
||||||
searchFilters,
|
searchFilters,
|
||||||
setSearchFilters,
|
setSearchFilters,
|
||||||
clearSearchFilters,
|
clearSearchFilters,
|
||||||
@@ -110,9 +112,9 @@ export function EmailList({
|
|||||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||||
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails, disableThreading, isScheduledView]);
|
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -250,10 +252,12 @@ export function EmailList({
|
|||||||
if (!isExpanded && client) {
|
if (!isExpanded && client) {
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
await fetchThreadEmails(client, threadId);
|
await fetchThreadEmails(client, threadId);
|
||||||
|
// Mark all unread emails in this thread as read
|
||||||
|
void markThreadAsRead(client, threadId);
|
||||||
} else {
|
} else {
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
|
||||||
|
|
||||||
// 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
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
const client = useAuthStore((s) => s.client);
|
const client = useAuthStore((s) => s.client);
|
||||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
||||||
|
const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
|
||||||
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
|
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
|
||||||
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
|
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
|
||||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||||
@@ -92,7 +93,24 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
|||||||
|
|
||||||
// Refresh the currently-active mail list so the new sent message /
|
// Refresh the currently-active mail list so the new sent message /
|
||||||
// updated keyword status shows up.
|
// updated keyword status shows up.
|
||||||
await fetchEmails(client, selectedMailbox);
|
await refreshCurrentMailbox(client);
|
||||||
|
// Re-fetch the replied thread's cross-folder data so the expanded
|
||||||
|
// view shows the newly sent reply without collapsing.
|
||||||
|
if (data.sourceEmailId) {
|
||||||
|
const emailState = useEmailStore.getState();
|
||||||
|
const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId);
|
||||||
|
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||||
|
const accountId = client.getAccountId();
|
||||||
|
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
|
||||||
|
if (fullEmails.length > 0) {
|
||||||
|
useEmailStore.setState((state) => {
|
||||||
|
const c = new Map(state.threadEmailsCache);
|
||||||
|
c.set(repliedEmail.threadId!, fullEmails);
|
||||||
|
return { threadEmailsCache: c };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
closeTab(tabIdRef.current);
|
closeTab(tabIdRef.current);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send email:', error);
|
console.error('Failed to send email:', error);
|
||||||
|
|||||||
@@ -388,6 +388,16 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
return { id: threadId, emailIds: emails.map(e => e.id) };
|
return { id: threadId, emailIds: emails.map(e => e.id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getThreads(threadIds: string[]): Promise<Thread[]> {
|
||||||
|
return threadIds
|
||||||
|
.map(tid => {
|
||||||
|
const emails = this.data.emails.filter(e => e.threadId === tid);
|
||||||
|
if (emails.length === 0) return null;
|
||||||
|
return { id: tid, emailIds: emails.map(e => e.id) };
|
||||||
|
})
|
||||||
|
.filter((t): t is Thread => t !== null);
|
||||||
|
}
|
||||||
|
|
||||||
async getThreadEmails(threadId: string): Promise<Email[]> {
|
async getThreadEmails(threadId: string): Promise<Email[]> {
|
||||||
return this.data.emails
|
return this.data.emails
|
||||||
.filter(e => e.threadId === threadId)
|
.filter(e => e.threadId === threadId)
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export interface IJMAPClient {
|
|||||||
|
|
||||||
// ── Threads ───────────────────────────────────────────────────
|
// ── Threads ───────────────────────────────────────────────────
|
||||||
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
|
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
|
||||||
|
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
|
||||||
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
|
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
|
||||||
|
|
||||||
// ── Compose / Send ────────────────────────────────────────────
|
// ── Compose / Send ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1904,6 +1904,24 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getThreads(threadIds: string[], accountId?: string): Promise<Thread[]> {
|
||||||
|
if (threadIds.length === 0) return [];
|
||||||
|
try {
|
||||||
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
const response = await this.request([
|
||||||
|
["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
||||||
|
return (response.methodResponses[0][1].list || []) as Thread[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get threads:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
|
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
|||||||
+10
-2
@@ -5,8 +5,16 @@ import type { Email, ThreadGroup } from "./jmap/types";
|
|||||||
* Single-email threads are still returned as ThreadGroups with emailCount=1.
|
* Single-email threads are still returned as ThreadGroups with emailCount=1.
|
||||||
* When disableThreading is true, each email is placed into its own group using
|
* When disableThreading is true, each email is placed into its own group using
|
||||||
* its message ID as the key, so the list shows individual messages.
|
* its message ID as the key, so the list shows individual messages.
|
||||||
|
*
|
||||||
|
* @param threadEmailCounts - Optional map of threadId → total email count across
|
||||||
|
* all folders (from Thread/get). When provided, emailCount reflects the full
|
||||||
|
* thread size rather than just the emails in the current folder.
|
||||||
*/
|
*/
|
||||||
export function groupEmailsByThread(emails: Email[], disableThreading = false): ThreadGroup[] {
|
export function groupEmailsByThread(
|
||||||
|
emails: Email[],
|
||||||
|
disableThreading = false,
|
||||||
|
threadEmailCounts?: Map<string, number>,
|
||||||
|
): ThreadGroup[] {
|
||||||
if (!emails || emails.length === 0) {
|
if (!emails || emails.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -53,7 +61,7 @@ export function groupEmailsByThread(emails: Email[], disableThreading = false):
|
|||||||
hasAttachment,
|
hasAttachment,
|
||||||
hasAnswered,
|
hasAnswered,
|
||||||
hasForwarded,
|
hasForwarded,
|
||||||
emailCount: sortedEmails.length,
|
emailCount: threadEmailCounts?.get(threadId) ?? sortedEmails.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+207
-5
@@ -60,6 +60,8 @@ interface EmailStore {
|
|||||||
expandedThreadIds: Set<string>;
|
expandedThreadIds: Set<string>;
|
||||||
threadEmailsCache: Map<string, Email[]>;
|
threadEmailsCache: Map<string, Email[]>;
|
||||||
isLoadingThread: string | null;
|
isLoadingThread: string | null;
|
||||||
|
// Full thread email counts (from Thread/get across all folders)
|
||||||
|
threadEmailCounts: Map<string, number>;
|
||||||
|
|
||||||
// Keyword/tag filter
|
// Keyword/tag filter
|
||||||
selectedKeyword: string | null;
|
selectedKeyword: string | null;
|
||||||
@@ -199,6 +201,8 @@ interface EmailStore {
|
|||||||
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
|
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
|
||||||
collapseAllThreads: () => void;
|
collapseAllThreads: () => void;
|
||||||
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
||||||
|
fetchThreadEmailCounts: (client: IJMAPClient) => Promise<void>;
|
||||||
|
markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise<void>;
|
||||||
|
|
||||||
// Mailbox management
|
// Mailbox management
|
||||||
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
|
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
|
||||||
@@ -519,6 +523,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
expandedThreadIds: new Set(),
|
expandedThreadIds: new Set(),
|
||||||
threadEmailsCache: new Map(),
|
threadEmailsCache: new Map(),
|
||||||
isLoadingThread: null,
|
isLoadingThread: null,
|
||||||
|
threadEmailCounts: new Map(),
|
||||||
|
|
||||||
// Keyword/tag filter
|
// Keyword/tag filter
|
||||||
selectedKeyword: null,
|
selectedKeyword: null,
|
||||||
@@ -565,6 +570,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
selectedKeyword: null,
|
selectedKeyword: null,
|
||||||
expandedThreadIds: new Set(),
|
expandedThreadIds: new Set(),
|
||||||
threadEmailsCache: new Map(),
|
threadEmailsCache: new Map(),
|
||||||
|
threadEmailCounts: new Map(),
|
||||||
isLoadingThread: null,
|
isLoadingThread: null,
|
||||||
}),
|
}),
|
||||||
fetchAccountMailboxes: async (client, accountId) => {
|
fetchAccountMailboxes: async (client, accountId) => {
|
||||||
@@ -595,6 +601,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
selectedEmailIds: new Set(),
|
selectedEmailIds: new Set(),
|
||||||
expandedThreadIds: new Set(),
|
expandedThreadIds: new Set(),
|
||||||
threadEmailsCache: new Map(),
|
threadEmailsCache: new Map(),
|
||||||
|
threadEmailCounts: new Map(),
|
||||||
}),
|
}),
|
||||||
fetchTagCounts: async (client) => {
|
fetchTagCounts: async (client) => {
|
||||||
try {
|
try {
|
||||||
@@ -617,6 +624,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
selectedKeyword: null,
|
selectedKeyword: null,
|
||||||
expandedThreadIds: new Set(),
|
expandedThreadIds: new Set(),
|
||||||
threadEmailsCache: new Map(),
|
threadEmailsCache: new Map(),
|
||||||
|
threadEmailCounts: new Map(),
|
||||||
isLoadingThread: null,
|
isLoadingThread: null,
|
||||||
}),
|
}),
|
||||||
setLoading: (loading) => set({ isLoading: loading }),
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
@@ -785,8 +793,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
|
// Clear thread caches since the email list was fully replaced
|
||||||
|
threadEmailsCache: new Map(),
|
||||||
|
expandedThreadIds: new Set(),
|
||||||
|
isLoadingThread: null,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
// Fetch full thread counts in the background (non-blocking)
|
||||||
|
void get().fetchThreadEmailCounts(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch emails:', error);
|
console.error('Failed to fetch emails:', error);
|
||||||
set({
|
set({
|
||||||
@@ -923,6 +937,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoadingMore: false
|
isLoadingMore: false
|
||||||
});
|
});
|
||||||
|
// Fetch full thread counts for newly loaded threads in the background
|
||||||
|
if (newEmails.length > 0) {
|
||||||
|
void get().fetchThreadEmailCounts(client);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load more emails:', error);
|
console.error('Failed to load more emails:', error);
|
||||||
set({
|
set({
|
||||||
@@ -1214,7 +1232,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
|
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
|
||||||
: state.selectedEmail,
|
: state.selectedEmail,
|
||||||
mailboxes: updatedMailboxes,
|
mailboxes: updatedMailboxes,
|
||||||
processingReadStatus: newProcessingSet
|
processingReadStatus: newProcessingSet,
|
||||||
|
// Also update threadEmailsCache so expanded dropdowns reflect the change
|
||||||
|
threadEmailsCache: (() => {
|
||||||
|
let updated = false;
|
||||||
|
const newCache = new Map(state.threadEmailsCache);
|
||||||
|
for (const [tid, cachedEmails] of newCache) {
|
||||||
|
const idx = cachedEmails.findIndex(e => e.id === emailId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
newCache.set(tid, cachedEmails.map((e, i) =>
|
||||||
|
i === idx ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
|
||||||
|
));
|
||||||
|
updated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated ? newCache : state.threadEmailsCache;
|
||||||
|
})(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2327,11 +2361,74 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// hasMore should reflect whether there are still more emails beyond
|
// hasMore should reflect whether there are still more emails beyond
|
||||||
// what we have loaded, using the fresh total from the server.
|
// what we have loaded, using the fresh total from the server.
|
||||||
const hasMore = merged.length < (result.total || 0);
|
const hasMore = merged.length < (result.total || 0);
|
||||||
set({
|
|
||||||
emails: merged,
|
// Invalidate thread email caches for threads whose composition changed
|
||||||
hasMoreEmails: hasMore,
|
// so expanded threads pick up new/removed emails.
|
||||||
totalEmails: result.total,
|
const prevThreadIds = new Set(currentEmails.map(e => e.threadId));
|
||||||
|
const nextThreadIds = new Set(merged.map(e => e.threadId));
|
||||||
|
const changedThreadIds = new Set<string>();
|
||||||
|
for (const tid of prevThreadIds) {
|
||||||
|
if (!nextThreadIds.has(tid)) changedThreadIds.add(tid);
|
||||||
|
}
|
||||||
|
for (const tid of nextThreadIds) {
|
||||||
|
if (!prevThreadIds.has(tid)) changedThreadIds.add(tid);
|
||||||
|
}
|
||||||
|
// Also check threads where the set of email IDs changed
|
||||||
|
const prevEmailsByThread = new Map<string, Set<string>>();
|
||||||
|
for (const e of currentEmails) {
|
||||||
|
if (!prevEmailsByThread.has(e.threadId)) prevEmailsByThread.set(e.threadId, new Set());
|
||||||
|
prevEmailsByThread.get(e.threadId)!.add(e.id);
|
||||||
|
}
|
||||||
|
const nextEmailsByThread = new Map<string, Set<string>>();
|
||||||
|
for (const e of merged) {
|
||||||
|
if (!nextEmailsByThread.has(e.threadId)) nextEmailsByThread.set(e.threadId, new Set());
|
||||||
|
nextEmailsByThread.get(e.threadId)!.add(e.id);
|
||||||
|
}
|
||||||
|
for (const [tid, nextIds] of nextEmailsByThread) {
|
||||||
|
const prevIds = prevEmailsByThread.get(tid);
|
||||||
|
if (!prevIds || prevIds.size !== nextIds.size) {
|
||||||
|
changedThreadIds.add(tid);
|
||||||
|
} else {
|
||||||
|
for (const id of nextIds) {
|
||||||
|
if (!prevIds.has(id)) { changedThreadIds.add(tid); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const newCache = new Map(state.threadEmailsCache);
|
||||||
|
for (const tid of changedThreadIds) {
|
||||||
|
newCache.delete(tid);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
emails: merged,
|
||||||
|
hasMoreEmails: hasMore,
|
||||||
|
totalEmails: result.total,
|
||||||
|
threadEmailsCache: newCache,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-fetch cross-folder thread data for any currently expanded threads
|
||||||
|
// so they show the complete conversation (not just current-folder emails).
|
||||||
|
const expandedNow = get().expandedThreadIds;
|
||||||
|
if (expandedNow.size > 0) {
|
||||||
|
const effectiveClient2 = resolveActionClient(client);
|
||||||
|
const accountId = effectiveClient2.getAccountId();
|
||||||
|
for (const tid of expandedNow) {
|
||||||
|
void effectiveClient2.getThreadEmails(tid, accountId).then((fullEmails) => {
|
||||||
|
if (fullEmails.length > 0) {
|
||||||
|
set((state) => {
|
||||||
|
const c = new Map(state.threadEmailsCache);
|
||||||
|
c.set(tid, fullEmails);
|
||||||
|
return { threadEmailsCache: c };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full thread counts in the background (non-blocking)
|
||||||
|
void get().fetchThreadEmailCounts(client);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh current mailbox:', error);
|
console.error('Failed to refresh current mailbox:', error);
|
||||||
@@ -2401,6 +2498,90 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markThreadAsRead: async (client, threadId) => {
|
||||||
|
const state = get();
|
||||||
|
const threadEmails = state.threadEmailsCache.get(threadId) ?? [];
|
||||||
|
const mainEmails = state.emails.filter(e => e.threadId === threadId);
|
||||||
|
|
||||||
|
// Combine unique emails from both sources
|
||||||
|
const allEmailMap = new Map<string, Email>();
|
||||||
|
for (const e of mainEmails) allEmailMap.set(e.id, e);
|
||||||
|
for (const e of threadEmails) allEmailMap.set(e.id, e);
|
||||||
|
|
||||||
|
const unreadIds = Array.from(allEmailMap.values())
|
||||||
|
.filter(e => !e.keywords?.$seen)
|
||||||
|
.map(e => e.id);
|
||||||
|
|
||||||
|
if (unreadIds.length === 0) return;
|
||||||
|
|
||||||
|
// Group by account for unified view support
|
||||||
|
const emailsById = new Map<string, Email>();
|
||||||
|
for (const e of allEmailMap.values()) emailsById.set(e.id, e);
|
||||||
|
|
||||||
|
// Group unread IDs by account client
|
||||||
|
const groups = new Map<IJMAPClient, string[]>();
|
||||||
|
for (const id of unreadIds) {
|
||||||
|
const email = emailsById.get(id)!;
|
||||||
|
const { client: actionClient } = resolveEmailActionContext(email, client);
|
||||||
|
if (!groups.has(actionClient)) groups.set(actionClient, []);
|
||||||
|
groups.get(actionClient)!.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark all as read on the server
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(groups.entries()).map(([actionClient, emailIds]) =>
|
||||||
|
actionClient.batchMarkAsRead(emailIds, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark thread as read:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set((state) => {
|
||||||
|
const unreadSet = new Set(unreadIds);
|
||||||
|
|
||||||
|
const updatedEmails = state.emails.map(e =>
|
||||||
|
unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update threadEmailsCache
|
||||||
|
const newCache = new Map(state.threadEmailsCache);
|
||||||
|
const cached = newCache.get(threadId);
|
||||||
|
if (cached) {
|
||||||
|
newCache.set(threadId, cached.map(e =>
|
||||||
|
unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mailbox unread counters
|
||||||
|
const affectedEmails = state.emails.filter(e => unreadSet.has(e.id));
|
||||||
|
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||||
|
let delta = 0;
|
||||||
|
for (const email of affectedEmails) {
|
||||||
|
if (email.mailboxIds?.[mailbox.id]) delta -= 1;
|
||||||
|
}
|
||||||
|
if (delta === 0) return mailbox;
|
||||||
|
return {
|
||||||
|
...mailbox,
|
||||||
|
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
|
||||||
|
unreadThreads: Math.max(0, mailbox.unreadThreads + delta),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emails: updatedEmails,
|
||||||
|
threadEmailsCache: newCache,
|
||||||
|
mailboxes: updatedMailboxes,
|
||||||
|
selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id)
|
||||||
|
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
|
||||||
|
: state.selectedEmail,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
collapseAllThreads: () => {
|
collapseAllThreads: () => {
|
||||||
set({
|
set({
|
||||||
expandedThreadIds: new Set(),
|
expandedThreadIds: new Set(),
|
||||||
@@ -2414,6 +2595,27 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
set({ threadEmailsCache: newCache });
|
set({ threadEmailsCache: newCache });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchThreadEmailCounts: async (client) => {
|
||||||
|
const { emails } = get();
|
||||||
|
if (emails.length === 0) return;
|
||||||
|
|
||||||
|
const uniqueThreadIds = [...new Set(emails.map(e => e.threadId).filter(Boolean))];
|
||||||
|
if (uniqueThreadIds.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const effectiveClient = resolveActionClient(client);
|
||||||
|
const threads = await effectiveClient.getThreads(uniqueThreadIds);
|
||||||
|
|
||||||
|
const newCounts = new Map(get().threadEmailCounts);
|
||||||
|
for (const thread of threads) {
|
||||||
|
newCounts.set(thread.id, thread.emailIds?.length ?? 0);
|
||||||
|
}
|
||||||
|
set({ threadEmailCounts: newCounts });
|
||||||
|
} catch {
|
||||||
|
// Non-critical — fall back to inbox-only counts
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Mailbox management
|
// Mailbox management
|
||||||
createMailbox: async (client, name, parentId) => {
|
createMailbox: async (client, name, parentId) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user