mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: show only that folder's unread mail when its unread count is clicked
Clicking a folder's unread count turns on the unread filter and runs the search. Since the search scope stopped following the open folder (#788) its default is "all folders", and since #1082 that fans out over every shared account too, so the Inbox badge listed unread mail from every folder of every account the login reaches. The badge now scopes the search to its folder. That scope moves with the open folder, the way the search itself carries across folders (#553); picking a scope in the dropdown or clearing the filters stops it. Clicking the badge from a tag view or the Scheduled list now also leaves that view, as clicking the folder does.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<typeof vi.fn>).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('');
|
||||
});
|
||||
});
|
||||
|
||||
+30
-8
@@ -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<void>;
|
||||
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
||||
setSearchMailboxId: (mailboxId: string) => void;
|
||||
scopeSearchToOpenFolder: () => void;
|
||||
clearSearchFilters: () => void;
|
||||
toggleAdvancedSearch: () => void;
|
||||
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
|
||||
@@ -1535,6 +1540,7 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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 }),
|
||||
|
||||
Reference in New Issue
Block a user