mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: mark read and star a list selection in one request
Bulk mark read/unread and star/unstar from the list selection ran the single-message action once per message, so a large selection sent one `Email/set` per message against Stalwart's limit of four concurrent requests. They now go through `setKeywordForEmails`, which sends one `keywords/$seen` or `keywords/$flagged` patch for the whole selection (chunked by `maxObjectsInSet`). It also keeps rows that leave an open Unread or Starred view until the view is re-opened, as the single actions do.
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -329,6 +329,7 @@ export interface EmailState {
|
||||
archiveEmailsBatch: (emailIds: string[]) => Promise<void>;
|
||||
moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise<void>;
|
||||
deleteEmailsBatch: (emailIds: string[], trashMailboxId: string, currentMailboxId: string) => Promise<void>;
|
||||
/** Set or clear one keyword (tag, `$seen`, `$flagged`) on a selection in one `Email/set`. */
|
||||
setKeywordForEmails: (emailIds: string[], token: string, on: boolean) => Promise<void>;
|
||||
undoLast: () => Promise<void>;
|
||||
clearUndo: () => void;
|
||||
@@ -1539,8 +1540,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
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]));
|
||||
|
||||
Reference in New Issue
Block a user