diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 58d9c20a3..82715bf56 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -379,6 +379,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isAdvancedSearchOpen, setSearchFilters, setSearchMailboxId, + scopeSearchToOpenFolder, clearSearchFilters, toggleAdvancedSearch, advancedSearch, @@ -2714,10 +2715,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; const handleUnreadFilterClick = async (mailboxId: string) => { - const isTogglingOff = selectedMailbox === mailboxId && searchFilters.isUnread === true; + const isTogglingOff = selectedMailbox === mailboxId && !selectedKeyword && searchFilters.isUnread === true; - // Select the mailbox if not already selected - if (selectedMailbox !== mailboxId) { + // Open the mailbox unless it is already the one listed. A tag view keeps + // selectedMailbox, so it counts as another view. + if (selectedMailbox !== mailboxId || selectedKeyword) { + if (isUnifiedView) exitUnifiedView(); + setScheduledView(false); selectMailbox(mailboxId); selectEmail(null); } @@ -2740,8 +2744,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { await fetchEmails(client, mailboxId); } } else { - // Enable unread filter + // Enable unread filter, in this folder only clearSearchFilters(); + scopeSearchToOpenFolder(); setSearchFilters({ isUnread: true }); if (client) { await advancedSearch(client); diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index 4ae3c56b8..2d114d18f 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -152,3 +152,97 @@ describe('search folder scope (#788)', () => { expect(useEmailStore.getState().searchMailboxId).toBe(''); }); }); + +describe('unread badge scope', () => { + let client: IJMAPClient; + + beforeEach(() => { + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'account-a', + getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never, + } as never); + useSettingsStore.setState({ emailsPerPage: 50 } as never); + useEmailStore.setState({ + isUnifiedView: false, + unifiedRole: null, + crossView: null, + viewingAccountId: null, + selectedKeyword: null, + selectedMailbox: 'sent', + searchMailboxId: '', + searchScopeFollowsFolder: false, + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchAbortController: null, + emails: [], + mailboxes: [ + makeMailbox({ id: 'inbox', role: 'inbox' }), + makeMailbox({ id: 'sent', name: 'Sent Items', role: 'sent' }), + makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }), + ], + accountMailboxes: {}, + }); + }); + + // What mail-app's handleUnreadFilterClick does with the store. + async function clickUnreadBadge(mailboxId: string) { + const store = useEmailStore.getState(); + store.selectMailbox(mailboxId); + store.clearSearchFilters(); + store.scopeSearchToOpenFolder(); + store.setSearchFilters({ isUnread: true }); + await useEmailStore.getState().advancedSearch(client); + } + + const searchCalls = () => (client.advancedSearchEmails as ReturnType).mock.calls; + + it('lists the unread mail of that folder, not of every folder and account', async () => { + await clickUnreadBadge('inbox'); + + expect(searchCalls()).toHaveLength(1); + const [filter, accountId] = searchCalls()[0]; + expect(filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$seen' }, { inMailbox: 'inbox' }], + }); + expect(accountId).toBeUndefined(); + }); + + it('lists a shared folder in its owner account', async () => { + await clickUnreadBadge('owner-x:x-inbox'); + + expect(searchCalls()).toHaveLength(1); + const [filter, accountId] = searchCalls()[0]; + expect(JSON.stringify(filter)).toContain('"inMailbox":"x-inbox"'); + expect(accountId).toBe('owner-x'); + }); + + it('moves to the next folder the user opens instead of listing the old one there', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe('sent'); + + await useEmailStore.getState().advancedSearch(client); + const [filter] = searchCalls()[1]; + expect(JSON.stringify(filter)).toContain('"inMailbox":"sent"'); + expect(JSON.stringify(filter)).not.toContain('"inMailbox":"inbox"'); + }); + + it('stops following once the user picks a scope', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().setSearchMailboxId('owner-x:x-inbox'); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe('owner-x:x-inbox'); + }); + + it('stops following once the filters are cleared', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().clearSearchFilters(); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 9541cbebd..a59e7c2be 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -136,6 +136,10 @@ interface EmailStore { // folder does not silently narrow the search to it, and so an explicit // choice survives navigating the mail list (#788). searchMailboxId: string; + // Set when a folder's unread count scoped the search to that folder: the + // scope then moves with the open folder, the way the search itself does + // (#553). A scope picked in the dropdown or a filter reset clears it. + searchScopeFollowsFolder: boolean; isAdvancedSearchOpen: boolean; searchAbortController: AbortController | null; /** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */ @@ -281,6 +285,7 @@ interface EmailStore { advancedSearch: (client: IJMAPClient) => Promise; setSearchFilters: (filters: Partial) => void; setSearchMailboxId: (mailboxId: string) => void; + scopeSearchToOpenFolder: () => void; clearSearchFilters: () => void; toggleAdvancedSearch: () => void; toggleStar: (client: IJMAPClient, emailId: string) => Promise; @@ -1535,6 +1540,7 @@ export const useEmailStore = create((set, get) => ({ // Advanced search state searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: "", + searchScopeFollowsFolder: false, isAdvancedSearchOpen: false, searchAbortController: null, externalSearchResults: [], @@ -1582,11 +1588,14 @@ export const useEmailStore = create((set, get) => ({ // back empty, while the dropdown (which renders no matching option) reads // "All folders". Reset it so the scope matches what is shown. (#1082) // Only the new account's own list names its Spam/Trash: another - // account's mailbox ids can collide with them. - searchMailboxId: defaultSearchScopeFor( - accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes, - mailboxId, - ), + // account's mailbox ids can collide with them. A scope that follows + // the open folder moves to the new one. + searchMailboxId: state.searchScopeFollowsFolder + ? mailboxId + : defaultSearchScopeFor( + accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes, + mailboxId, + ), isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -1689,11 +1698,15 @@ export const useEmailStore = create((set, get) => ({ selectMailbox: (mailboxId) => set(state => { const mailboxes = mailboxesInView(state); // The folder's default search scope follows it (Spam and Trash search - // themselves); a scope the user picked in the dropdown stays. + // themselves); a scope the user picked in the dropdown stays. The + // unread badge's scope follows any real folder. const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox); + const scopeFollows = state.searchScopeFollowsFolder && mailboxes.some(m => m.id === mailboxId); return { selectedMailbox: mailboxId, - ...(scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), + ...(scopeFollows + ? { searchMailboxId: mailboxId } + : scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -3200,13 +3213,21 @@ export const useEmailStore = create((set, get) => ({ }, setSearchMailboxId: (mailboxId) => { - set({ searchMailboxId: mailboxId }); + set({ searchMailboxId: mailboxId, searchScopeFollowsFolder: false }); + }, + + // The unread badge filters its own folder. The default scope searches + // every folder of every account (#788, #1082), so it has to name the + // folder, and keep naming whichever folder is open. + scopeSearchToOpenFolder: () => { + set((state) => ({ searchMailboxId: state.selectedMailbox, searchScopeFollowsFolder: true })); }, clearSearchFilters: () => { set((state) => ({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox), + searchScopeFollowsFolder: false, })); }, @@ -5189,6 +5210,7 @@ export const useEmailStore = create((set, get) => ({ searchQuery: isScheduledView ? "" : state.searchQuery, searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters, searchMailboxId: isScheduledView ? "" : state.searchMailboxId, + searchScopeFollowsFolder: isScheduledView ? false : state.searchScopeFollowsFolder, }; }), clearPendingUndoSend: () => set({ pendingUndoSend: null }),