mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: search spam and trash themselves while they are open
This commit is contained in:
@@ -4006,14 +4006,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</div>
|
||||
|
||||
{/* Folder selector. Scopes the search only - it does not
|
||||
navigate the mail list, so it defaults to "All folders
|
||||
except Spam and Trash" regardless of which folder is
|
||||
open and keeps whatever the user picked. Both
|
||||
folder-less scopes span the own AND the group/shared
|
||||
accounts' folders (#1082); "All folders" also searches
|
||||
Trash and Junk. The shared folders are listed under
|
||||
their owner. The unified views already search across
|
||||
every account's folders, so it is hidden there. */}
|
||||
navigate the mail list. It defaults to "All folders
|
||||
except Spam and Trash", or to Spam/Trash itself while
|
||||
that folder is open (defaultSearchScopeFor), and keeps
|
||||
whatever the user picked. Both folder-less scopes span
|
||||
the own AND the group/shared accounts' folders (#1082);
|
||||
"All folders" also searches Trash and Junk. The shared
|
||||
folders are listed under their owner. The unified
|
||||
views already search across every account's folders,
|
||||
so it is hidden there. */}
|
||||
{!isUnifiedView && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
|
||||
|
||||
@@ -16,6 +16,16 @@ export function isAllFoldersSearchScope(searchMailboxId: string): boolean {
|
||||
return searchMailboxId === '' || searchMailboxId === SEARCH_SCOPE_ALL_FOLDERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The search scope a folder starts with: Spam and Trash search themselves,
|
||||
* since that is what a search from inside them looks for; every other folder
|
||||
* searches all folders except Spam and Trash ("").
|
||||
*/
|
||||
export function defaultSearchScopeFor(mailboxes: Mailbox[], openMailboxId: string | null | undefined): string {
|
||||
const open = openMailboxId ? mailboxes.find((mb) => mb.id === openMailboxId) : undefined;
|
||||
return open && (open.role === 'trash' || open.role === 'junk') ? open.id : '';
|
||||
}
|
||||
|
||||
export interface SearchScopeFolderGroup {
|
||||
/** Owner JMAP account id; the React key. */
|
||||
ownerId: string;
|
||||
|
||||
@@ -206,7 +206,8 @@ describe('"All folders" search across the own and group accounts (#1082)', () =>
|
||||
|
||||
// The default scope, "All folders except Spam and Trash" (searchMailboxId
|
||||
// ""), leaves every account's Trash and Junk out; "All folders" (the
|
||||
// SEARCH_SCOPE_ALL_FOLDERS sentinel) searches them too.
|
||||
// SEARCH_SCOPE_ALL_FOLDERS sentinel) searches them too. While Spam or Trash
|
||||
// is open, the default scope is that folder itself.
|
||||
describe('Spam and Trash in the folder-less search scopes', () => {
|
||||
const ownTrash = { id: 'trash', name: 'Trash', role: 'trash', isShared: false } as Mailbox;
|
||||
const ownJunk = { id: 'junk', name: 'Junk', role: 'junk', isShared: false } as Mailbox;
|
||||
@@ -301,4 +302,75 @@ describe('Spam and Trash in the folder-less search scopes', () => {
|
||||
useEmailStore.getState().clearSearchFilters();
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('');
|
||||
});
|
||||
|
||||
it('searches Spam or Trash alone while that folder is open, and all but them after leaving', async () => {
|
||||
useEmailStore.getState().selectMailbox(ownTrash.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('trash');
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'fatura');
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0);
|
||||
|
||||
useEmailStore.getState().selectMailbox(ownJunk.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('junk');
|
||||
|
||||
useEmailStore.getState().selectMailbox(ownInbox.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('');
|
||||
});
|
||||
|
||||
it('searches an open group Trash in the group account', async () => {
|
||||
useEmailStore.getState().selectMailbox(groupTrash.id);
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'acesso');
|
||||
|
||||
expect(client.searchEmails).toHaveBeenCalledTimes(1);
|
||||
expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0);
|
||||
});
|
||||
|
||||
it('keeps a scope the user picked when entering or leaving Spam and Trash', () => {
|
||||
const { selectMailbox, setSearchMailboxId } = useEmailStore.getState();
|
||||
|
||||
setSearchMailboxId(SEARCH_SCOPE_ALL_FOLDERS);
|
||||
selectMailbox(ownTrash.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe(SEARCH_SCOPE_ALL_FOLDERS);
|
||||
selectMailbox(ownInbox.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe(SEARCH_SCOPE_ALL_FOLDERS);
|
||||
|
||||
setSearchMailboxId(groupInbox.id);
|
||||
selectMailbox(ownJunk.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe(groupInbox.id);
|
||||
|
||||
// "All folders except Spam and Trash" picked while inside Trash stays too.
|
||||
selectMailbox(ownTrash.id);
|
||||
setSearchMailboxId('');
|
||||
selectMailbox(ownInbox.id);
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('');
|
||||
});
|
||||
|
||||
it('resets to the open folder\'s default when the filters are cleared', () => {
|
||||
useEmailStore.getState().selectMailbox(ownTrash.id);
|
||||
useEmailStore.getState().setSearchMailboxId(SEARCH_SCOPE_ALL_FOLDERS);
|
||||
|
||||
useEmailStore.getState().clearSearchFilters();
|
||||
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('trash');
|
||||
});
|
||||
|
||||
it('scopes to another account\'s Spam by that account\'s own folder list', () => {
|
||||
useEmailStore.setState({
|
||||
accountMailboxes: {
|
||||
'account-b': [
|
||||
{ id: 'b-junk', name: 'Spam', role: 'junk', isShared: false } as Mailbox,
|
||||
// Same id as the primary account's Trash, but an ordinary folder here.
|
||||
{ id: 'trash', name: 'Old', role: null, isShared: false } as unknown as Mailbox,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
useEmailStore.getState().selectAccountMailbox('account-b', 'b-junk');
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('b-junk');
|
||||
|
||||
useEmailStore.getState().selectAccountMailbox('account-b', 'trash');
|
||||
expect(useEmailStore.getState().searchMailboxId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
+49
-23
@@ -11,7 +11,7 @@ import { resolveThreadRoute } from "@/lib/thread-routing";
|
||||
import { threadKeyFor, threadIdFromKey } from "@/lib/thread-utils";
|
||||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
||||
import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||||
import { isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { currentStoreEpoch } from "@/lib/store-epoch";
|
||||
import { keywordPointer } from "@/lib/jmap/patch-pointer";
|
||||
@@ -694,14 +694,17 @@ export function resolveUnstampedEmailAccountId(opts: {
|
||||
return mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
}
|
||||
|
||||
function resolveActionMailboxes(): Mailbox[] {
|
||||
const state = useEmailStore.getState();
|
||||
function mailboxesInView(state: Pick<EmailStore, 'viewingAccountId' | 'accountMailboxes' | 'mailboxes'>): Mailbox[] {
|
||||
if (state.viewingAccountId) {
|
||||
return state.accountMailboxes[state.viewingAccountId] ?? state.mailboxes;
|
||||
}
|
||||
return state.mailboxes;
|
||||
}
|
||||
|
||||
function resolveActionMailboxes(): Mailbox[] {
|
||||
return mailboxesInView(useEmailStore.getState());
|
||||
}
|
||||
|
||||
// List requests can finish after navigation. Never apply an old folder/tag
|
||||
// response (or error) to the view the user has since selected.
|
||||
function captureEmailListView(): () => boolean {
|
||||
@@ -1531,7 +1534,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// would query that foreign folder id against the wrong account and come
|
||||
// back empty, while the dropdown (which renders no matching option) reads
|
||||
// "All folders". Reset it so the scope matches what is shown. (#1082)
|
||||
searchMailboxId: "",
|
||||
// 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,
|
||||
),
|
||||
isLoadingMore: false,
|
||||
selectedEmail: null,
|
||||
selectedEmailIds: new Set(),
|
||||
@@ -1622,21 +1630,28 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
console.error('Failed to fetch tag counts:', error);
|
||||
}
|
||||
}),
|
||||
selectMailbox: (mailboxId) => set(state => ({
|
||||
selectedMailbox: mailboxId,
|
||||
isLoadingMore: false,
|
||||
selectedEmail: null,
|
||||
selectedEmailIds: new Set(),
|
||||
selectedKeyword: null,
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
threadEmailCounts: new Map(),
|
||||
isLoadingThread: null,
|
||||
viewToken: state.viewToken + 1,
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
crossView: null,
|
||||
})),
|
||||
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.
|
||||
const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox);
|
||||
return {
|
||||
selectedMailbox: mailboxId,
|
||||
...(scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}),
|
||||
isLoadingMore: false,
|
||||
selectedEmail: null,
|
||||
selectedEmailIds: new Set(),
|
||||
selectedKeyword: null,
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
threadEmailCounts: new Map(),
|
||||
isLoadingThread: null,
|
||||
viewToken: state.viewToken + 1,
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
crossView: null,
|
||||
};
|
||||
}),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setLoadingEmail: (loading) => set({ isLoadingEmail: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
@@ -1730,12 +1745,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
? { isLoading: false, mailboxSyncStates: fetched.states }
|
||||
: { mailboxSyncStates: fetched.states };
|
||||
if (!selectionValid) {
|
||||
// A search scope naming a folder that is gone (e.g. the Spam/Trash
|
||||
// the default scope followed) falls back to the default.
|
||||
const scope = get().searchMailboxId;
|
||||
const scopePatch = !isAllFoldersSearchScope(scope) && !mailboxes.some(m => m.id === scope)
|
||||
? { searchMailboxId: '' }
|
||||
: {};
|
||||
// Find inbox from PRIMARY account (not shared accounts)
|
||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
||||
if (inboxMailbox) {
|
||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, ...loadingPatch });
|
||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, ...scopePatch, ...loadingPatch });
|
||||
} else {
|
||||
set({ mailboxes, selectedMailbox: '', ...loadingPatch });
|
||||
set({ mailboxes, selectedMailbox: '', ...scopePatch, ...loadingPatch });
|
||||
}
|
||||
} else {
|
||||
set({ mailboxes, ...loadingPatch });
|
||||
@@ -3127,7 +3148,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
clearSearchFilters: () => {
|
||||
set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: "" });
|
||||
set((state) => ({
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||
searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleAdvancedSearch: () => {
|
||||
@@ -5517,9 +5541,11 @@ if (typeof window !== 'undefined') {
|
||||
Array.isArray(snap.mailboxes) &&
|
||||
snap.mailboxes.length > 0
|
||||
) {
|
||||
const selectedMailbox = typeof snap.selectedMailbox === 'string' ? snap.selectedMailbox : '';
|
||||
useEmailStore.setState({
|
||||
mailboxes: snap.mailboxes,
|
||||
selectedMailbox: typeof snap.selectedMailbox === 'string' ? snap.selectedMailbox : '',
|
||||
selectedMailbox,
|
||||
searchMailboxId: defaultSearchScopeFor(snap.mailboxes, selectedMailbox),
|
||||
...(Array.isArray(snap.emails) && snap.emails.length > 0
|
||||
? {
|
||||
emails: snap.emails,
|
||||
|
||||
Reference in New Issue
Block a user