fix: keep search everything reachable once mail search results load

This commit is contained in:
Linus Rath
2026-10-31 20:18:15 +01:00
parent 93de202b38
commit d20532af91
3 changed files with 80 additions and 7 deletions
@@ -0,0 +1,34 @@
// The mail search's "Search everything" row was drawn over by the search and
// folder chips once results loaded, and was gone once the field lost focus.
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { searchEverythingPlacement } from '../search-everything-entry';
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
describe('Search everything in the mail search', () => {
it('sits in the panel while the field has focus', () => {
expect(searchEverythingPlacement({ searchFocused: true, hasActiveSearchOrFilter: true })).toBe('panel');
expect(searchEverythingPlacement({ searchFocused: true, hasActiveSearchOrFilter: false })).toBe('panel');
});
it('stays reachable as a chip once the field loses focus with a search on', () => {
expect(searchEverythingPlacement({ searchFocused: false, hasActiveSearchOrFilter: true })).toBe('chips');
expect(searchEverythingPlacement({ searchFocused: false, hasActiveSearchOrFilter: false })).toBeNull();
});
it('has a row that keeps its height, not flex: 1 in the panel column', () => {
const row = /<Pressable\s+(?:\/\/[^\n]*\n\s*)*style=\{([^}]+)\}\s+onPress=\{openSearchEverything\}/.exec(src);
expect(row).not.toBeNull();
expect(row![1]).not.toContain('recentSearchMain');
const style = /searchEverythingRow: \{([^}]*)\}/.exec(src);
expect(style?.[1]).toMatch(/minHeight: 44/);
expect(style?.[1]).not.toMatch(/flex: 1/);
});
it('is rendered where the placement says', () => {
expect(src).toMatch(/searchEverythingAt === 'panel' && \(/);
expect(src).toMatch(/searchEverythingAt === 'chips' \? \(\s*<FilterChip[\s\S]{0,200}onPress=\{openSearchEverything\}/);
});
});
+17
View File
@@ -0,0 +1,17 @@
// Where the mail search offers "Search everything" (the global search across
// mail, contacts, events and files). It was only a row in the panel under
// the focused field: once results loaded, the search and folder chips were
// drawn over it (the row took flex: 1 in a column, so its height wasn't
// counted), and when the field lost focus it was gone. The panel row keeps
// its own height now, and while a search or filter is on and the field is
// not focused, a chip at the front of the chip row stands in for it.
export type SearchEverythingPlacement = 'panel' | 'chips' | null;
export function searchEverythingPlacement(opts: {
searchFocused: boolean;
hasActiveSearchOrFilter: boolean;
}): SearchEverythingPlacement {
if (opts.searchFocused) return 'panel';
return opts.hasActiveSearchOrFilter ? 'chips' : null;
}
+29 -7
View File
@@ -89,6 +89,7 @@ import { resolveFolderRef } from '../lib/folder-ref';
import type { Attachment, Email } from '../api/types';
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { searchEverythingPlacement } from '../lib/search-everything-entry';
// The server refuses keywords per message (a read-only shared folder;
// Stalwart 1.0: over 128 bytes, or more than 100 on one message), and the
@@ -1075,6 +1076,11 @@ export default function EmailListScreen({
// "Search everything": mail, contacts, events and files of every account
// (webmail global search palette).
const [globalSearchOpen, setGlobalSearchOpen] = React.useState(false);
const openSearchEverything = () => {
setSearchFocused(false);
searchInputRef.current?.blur();
setGlobalSearchOpen(true);
};
React.useEffect(() => {
// Keep local input in sync when the store is cleared externally
// (e.g. clearing the search from the chips row).
@@ -1143,6 +1149,8 @@ export default function EmailListScreen({
(filters.keyword ? 1 : 0) +
(filters.folder && filters.folder !== 'current' ? 1 : 0);
const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0;
// In the panel under the focused field, else a chip while a search is on.
const searchEverythingAt = searchEverythingPlacement({ searchFocused, hasActiveSearchOrFilter });
// Back first leaves the selection, then the search and filters; only then
// does it leave the app (it used to exit with a search still open).
@@ -1567,15 +1575,13 @@ export default function EmailListScreen({
{/* Search bar (always visible; inside the header in a short window) */}
{!shortHeight && renderSearchBar(false)}
{searchFocused && (
{searchEverythingAt === 'panel' && (
<View style={styles.recentSearches}>
<Pressable
style={[styles.recentSearchRow, styles.recentSearchMain]}
onPress={() => {
setSearchFocused(false);
searchInputRef.current?.blur();
setGlobalSearchOpen(true);
}}
// Not recentSearchMain: flex 1 in this column left the row's
// height out, and the chips below were drawn over it.
style={styles.searchEverythingRow}
onPress={openSearchEverything}
accessibilityRole="button"
testID="search-everything"
>
@@ -1629,6 +1635,13 @@ export default function EmailListScreen({
{hasActiveSearchOrFilter && (
<View style={styles.filterChipsRow}>
{searchEverythingAt === 'chips' ? (
<FilterChip
icon={<Globe size={12} color={c.textSecondary} />}
label={t('global_search.title', 'Search everything')}
onPress={openSearchEverything}
/>
) : null}
{storeSearchQuery ? (
<FilterChip
icon={<Search size={12} color={c.textSecondary} />}
@@ -2489,6 +2502,7 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
},
recentSearches: {
flexShrink: 0,
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
backgroundColor: c.popover,
@@ -2511,6 +2525,14 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
},
recentSearchMain: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
searchEverythingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
minHeight: 44,
flexShrink: 0,
},
recentSearchText: { ...typography.body, color: c.text, flexShrink: 1 },
emptyFolderBanner: {
flexDirection: 'row',