diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index ac36be12..cc3e2390 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -665,20 +665,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e)); const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e)); + // One Email/set for the whole selection, not a request per message. const handleBulkMarkReadToggle = async () => { - const ids = selectedMessageIds; - if (allSelectedAreRead) { - await Promise.all(ids.map((id) => markUnread(id))); - } else { - await Promise.all(ids.map((id) => markRead(id))); - } + await setKeywordForEmails(selectedMessageIds, '$seen', !allSelectedAreRead); clearSelection(); }; const handleBulkStar = async () => { - const ids = selectedMessageIds; - const next = !allSelectedAreStarred; - await Promise.all(ids.map((id) => toggleStar(id, next))); + await setKeywordForEmails(selectedMessageIds, '$flagged', !allSelectedAreStarred); clearSelection(); }; diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 35f227c9..1937f604 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -811,6 +811,45 @@ describe('email-store', () => { }); }); + describe('bulk keyword actions', () => { + const three = () => [ + { id: 'e1', keywords: {} } as any, + { id: 'e2', keywords: { $flagged: true } } as any, + { id: 'e3', keywords: { $seen: true, $flagged: true } } as any, + ]; + + it('marks a whole selection read with one patch instead of a request per message', async () => { + useEmailStore.setState({ emails: three() }); + + await useEmailStore.getState().setKeywordForEmails(['e1', 'e2', 'e3'], '$seen', true); + + expect(mockPatchKeywords).toHaveBeenCalledTimes(1); + expect(mockPatchKeywords).toHaveBeenCalledWith(['e1', 'e2', 'e3'], { $seen: true }, undefined); + expect(useEmailStore.getState().emails.map((e) => e.keywords)).toEqual([ + { $seen: true }, + { $flagged: true, $seen: true }, + { $seen: true, $flagged: true }, + ]); + }); + + it('keeps rows that leave the open Unread or Starred view, like the single actions', async () => { + useEmailStore.setState({ emails: three(), filters: { isUnread: true } }); + await useEmailStore.getState().setKeywordForEmails(['e1', 'e2'], '$seen', true); + expect(useEmailStore.getState().retainedIds).toEqual(['e1', 'e2']); + + useEmailStore.setState({ emails: three(), filters: { isStarred: true }, retainedIds: [] }); + await useEmailStore.getState().setKeywordForEmails(['e2', 'e3'], '$flagged', true); + expect(useEmailStore.getState().retainedIds).toEqual([]); + await useEmailStore.getState().setKeywordForEmails(['e2', 'e3'], '$flagged', false); + expect(mockPatchKeywords).toHaveBeenLastCalledWith(['e2', 'e3'], { $flagged: null }, undefined); + expect(useEmailStore.getState().retainedIds).toEqual(['e2', 'e3']); + + useEmailStore.setState({ emails: three(), filters: { isUnread: false }, retainedIds: [] }); + await useEmailStore.getState().setKeywordForEmails(['e3'], '$seen', false); + expect(useEmailStore.getState().retainedIds).toEqual(['e3']); + }); + }); + describe('pin and spam keywords', () => { it('togglePin writes $pinned, not $important', async () => { useEmailStore.setState({ emails: [{ id: 'e1', keywords: {} } as any] }); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index fe5cfaeb..5d454d02 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -329,6 +329,7 @@ export interface EmailState { archiveEmailsBatch: (emailIds: string[]) => Promise; moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise; deleteEmailsBatch: (emailIds: string[], trashMailboxId: string, currentMailboxId: string) => Promise; + /** Set or clear one keyword (tag, `$seen`, `$flagged`) on a selection in one `Email/set`. */ setKeywordForEmails: (emailIds: string[], token: string, on: boolean) => Promise; undoLast: () => Promise; clearUndo: () => void; @@ -1539,8 +1540,9 @@ export const useEmailStore = create()( emails: get().emails.map((e) => touched.has(e.id) ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e, ), - // Untagging inside that tag's view keeps the rows until it's re-opened. - ...(!on && state.filters.keyword === token + // Reading inside Unread, unstarring inside Starred or untagging inside + // that tag's view keeps the rows until it's re-opened. + ...(leavesView(state.filters, token, on) ? { retainedIds: retain(get().retainedIds, ids) } : {}), }); @@ -1754,6 +1756,15 @@ function retain(current: string[], ids: string[]): string[] { return [...set]; } +// Whether setting `token` to `on` makes a row stop matching the open Unread, +// Starred or tag view (the rows are then retained, like markRead/toggleStar). +function leavesView(filters: EmailFilters, token: string, on: boolean): boolean { + if (!on && filters.keyword === token) return true; + if (token === '$seen') return filters.isUnread === on; + if (token === '$flagged') return filters.isStarred !== undefined && filters.isStarred !== on; + return false; +} + /** "Parent / Child" path of a folder for toasts, like the webmail (1.5.0). */ function mailboxPath(mailboxes: Mailbox[], mailboxId: string): string | undefined { const byId = new Map(mailboxes.map((m) => [m.id, m]));