feat: search suggestions with recent searches and contact autocomplete #845

This commit is contained in:
Linus Rath
2026-08-25 20:09:18 +02:00
parent 55ef780342
commit 99daa53c8c
33 changed files with 1036 additions and 50 deletions
+24 -25
View File
@@ -63,6 +63,9 @@ import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
import type { ContactSuggestion } from "@/lib/search-suggestions";
import { useSearchHistoryStore } from "@/stores/search-history-store";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
@@ -81,7 +84,7 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { findDraftIdentityId, resolveComposeAccountEmail, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
@@ -2780,6 +2783,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
const handleSearch = async (query: string) => {
if (!client) return;
setSearchQuery(query);
useSearchHistoryStore.getState().addRecentSearch(query);
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else {
@@ -2787,6 +2791,16 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
}
};
// A contact picked from the search-bar suggestions (#845) becomes a
// from:/to: filter — the typed text was only ever the lookup key, so it is
// dropped rather than ANDed into the query as a full-text term.
const handleSelectContactSuggestion = async (contact: ContactSuggestion, field: ContactSearchField) => {
if (!client) return;
setSearchQuery("");
setSearchFilters({ [field]: contact.email });
await advancedSearch(client);
};
const handleClearSearch = async () => {
setSearchQuery("");
clearSearchFilters();
@@ -3513,30 +3527,15 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
<Square className="w-4 h-4" />
)}
</button>
<form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1">
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("sidebar.search_placeholder_hint")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("ps-9 h-9", searchQuery && "pe-8")}
data-search-input
data-tour="search-input"
disabled={isScheduledView}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/>
{searchQuery && (
<button
type="button"
onClick={handleClearSearch}
className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("sidebar.clear_search")}
>
<X className="w-4 h-4" />
</button>
)}
</form>
<SearchBox
value={searchQuery}
onChange={setSearchQuery}
onSubmit={handleSearch}
onClear={handleClearSearch}
onSelectContact={handleSelectContactSuggestion}
disabled={isScheduledView}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/>
<button
type="button"
onClick={toggleAdvancedSearch}
@@ -0,0 +1,196 @@
// React's onFocus/onBlur listen to focusin/focusout — RTL's fireEvent fires both.
import { render, screen, act, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { SearchBox } from '../search-box';
import { useSearchHistoryStore } from '@/stores/search-history-store';
vi.mock('@/components/ui/avatar', () => ({
Avatar: ({ name, email }: { name?: string; email?: string }) => (
React.createElement('span', { 'data-testid': 'avatar' }, name || email)
),
}));
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
recentRecipients: [],
directoryPrincipals: [],
getAutocomplete: (query: string) => {
const all = [
{ name: 'Mustafa Kemal', email: 'mustafa@example.com' },
{ name: 'Mustafa Sandal', email: 'sandal@example.com' },
{ name: 'Marketing', email: '', group: { id: 'g1', memberCount: 3 } },
{ name: 'Alice Doe', email: 'alice@example.com' },
];
const q = query.toLowerCase();
return all.filter((c) => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q));
},
};
const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
function Harness(props: Partial<React.ComponentProps<typeof SearchBox>> & { initial?: string }) {
const [value, setValue] = React.useState(props.initial ?? '');
return (
<SearchBox
value={value}
onChange={(v) => { setValue(v); props.onChange?.(v); }}
onSubmit={props.onSubmit ?? (() => {})}
onClear={props.onClear ?? (() => {})}
onSelectContact={props.onSelectContact ?? (() => {})}
disabled={props.disabled}
/>
);
}
function input() {
return screen.getByRole('combobox') as HTMLInputElement;
}
describe('SearchBox suggestions (#845)', () => {
beforeEach(() => {
useSearchHistoryStore.setState({ recentSearches: [] });
});
it('shows nothing on focus when there is no history and no query', () => {
render(<Harness />);
fireEvent.focus(input());
expect(screen.queryByRole('listbox')).toBeNull();
});
it('shows recent searches on focus with an empty query', () => {
useSearchHistoryStore.setState({ recentSearches: ['invoice', 'q3 report'] });
render(<Harness />);
fireEvent.focus(input());
const options = screen.getAllByRole('option');
expect(options.map((o) => o.textContent)).toEqual(['invoice', 'q3 report']);
expect(screen.getByText('suggestions_recent')).toBeInTheDocument();
});
it('lists matching contacts while typing, without groups, with the match highlighted', () => {
render(<Harness />);
fireEvent.change(input(), { target: { value: 'musta' } });
const options = screen.getAllByRole('option');
expect(options).toHaveLength(2);
expect(options[0]).toHaveTextContent('Mustafa Kemal');
expect(options[0]).toHaveTextContent('<mustafa@example.com>');
expect(options[1]).toHaveTextContent('Mustafa Sandal');
expect(screen.queryByText(/Marketing/)).toBeNull();
const marks = options[0].querySelectorAll('mark');
// "Musta" in the name and "musta" in the address are both emphasised.
expect(Array.from(marks).map((m) => m.textContent)).toEqual(['Musta', 'musta']);
expect(screen.getByText('suggestions_people')).toBeInTheDocument();
});
it('clicking a contact runs a from: search and closes the dropdown', () => {
const onSelectContact = vi.fn();
render(<Harness onSelectContact={onSelectContact} />);
fireEvent.change(input(), { target: { value: 'sandal' } });
fireEvent.mouseDown(screen.getByRole('option'));
expect(onSelectContact).toHaveBeenCalledWith({ name: 'Mustafa Sandal', email: 'sandal@example.com' }, 'from');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('the "to" action runs a to: search for the contact', () => {
const onSelectContact = vi.fn();
render(<Harness onSelectContact={onSelectContact} />);
fireEvent.change(input(), { target: { value: 'alice' } });
fireEvent.mouseDown(screen.getByRole('button', { name: 'suggestions_to' }));
expect(onSelectContact).toHaveBeenCalledWith({ name: 'Alice Doe', email: 'alice@example.com' }, 'to');
});
it('navigates with the arrow keys and picks with Enter', () => {
const onSelectContact = vi.fn();
const onSubmit = vi.fn();
useSearchHistoryStore.setState({ recentSearches: ['mustafa invoice'] });
render(<Harness onSelectContact={onSelectContact} onSubmit={onSubmit} />);
const el = input();
fireEvent.change(el, { target: { value: 'musta' } });
// recent, Mustafa Kemal, Mustafa Sandal
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(el).toHaveAttribute('aria-expanded', 'true');
expect(el).not.toHaveAttribute('aria-activedescendant');
fireEvent.keyDown(el, { key: 'ArrowDown' });
fireEvent.keyDown(el, { key: 'ArrowDown' });
const options = screen.getAllByRole('option');
expect(options[1]).toHaveAttribute('aria-selected', 'true');
expect(el).toHaveAttribute('aria-activedescendant', options[1].id);
// Does not run past the last entry.
fireEvent.keyDown(el, { key: 'ArrowDown' });
fireEvent.keyDown(el, { key: 'ArrowDown' });
expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(el, { key: 'ArrowUp' });
fireEvent.keyDown(el, { key: 'Enter' });
expect(onSelectContact).toHaveBeenCalledWith({ name: 'Mustafa Kemal', email: 'mustafa@example.com' }, 'from');
expect(onSubmit).not.toHaveBeenCalled();
});
it('Enter with a highlighted recent search submits that query', () => {
const onSubmit = vi.fn();
const onChange = vi.fn();
useSearchHistoryStore.setState({ recentSearches: ['mustafa invoice'] });
render(<Harness onSubmit={onSubmit} onChange={onChange} />);
const el = input();
fireEvent.change(el, { target: { value: 'musta' } });
fireEvent.keyDown(el, { key: 'ArrowDown' });
fireEvent.keyDown(el, { key: 'Enter' });
expect(onChange).toHaveBeenLastCalledWith('mustafa invoice');
expect(onSubmit).toHaveBeenCalledWith('mustafa invoice');
expect(el.value).toBe('mustafa invoice');
});
it('Enter with nothing highlighted submits the typed text', () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
const el = input();
fireEvent.change(el, { target: { value: 'musta' } });
expect(screen.getByRole('listbox')).toBeInTheDocument();
fireEvent.submit(el.closest('form')!);
expect(onSubmit).toHaveBeenCalledWith('musta');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('Escape closes the dropdown and ArrowDown reopens it', () => {
render(<Harness />);
const el = input();
fireEvent.change(el, { target: { value: 'musta' } });
expect(screen.getByRole('listbox')).toBeInTheDocument();
fireEvent.keyDown(el, { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
expect(el).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(el, { key: 'ArrowDown' });
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true');
});
it('blur closes the dropdown', () => {
render(<Harness />);
const el = input();
fireEvent.change(el, { target: { value: 'musta' } });
fireEvent.blur(el, { relatedTarget: null });
expect(screen.queryByRole('listbox')).toBeNull();
});
it('removing a recent search updates the list without closing it', () => {
useSearchHistoryStore.setState({ recentSearches: ['invoice', 'q3 report'] });
render(<Harness />);
fireEvent.focus(input());
const remove = screen.getAllByRole('button', { name: 'suggestions_remove_recent' });
act(() => { fireEvent.mouseDown(remove[0]); });
expect(useSearchHistoryStore.getState().recentSearches).toEqual(['q3 report']);
expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['q3 report']);
});
it('never opens while disabled', () => {
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
render(<Harness disabled />);
fireEvent.focus(input());
expect(screen.queryByRole('listbox')).toBeNull();
});
});
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { useContactStore } from "@/stores/contact-store";
import { useSearchHistoryStore } from "@/stores/search-history-store";
import { buildSearchSuggestions, type ContactSuggestion, type SearchSuggestion } from "@/lib/search-suggestions";
import { SearchSuggestions, suggestionOptionId } from "./search-suggestions";
export type ContactSearchField = "from" | "to";
interface SearchBoxProps {
value: string;
onChange: (value: string) => void;
/** Submit a free-text search (Enter, or picking a recent search). */
onSubmit: (query: string) => void;
onClear: () => void;
/** Run a sender / recipient search for a suggested contact. */
onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void;
disabled?: boolean;
title?: string;
}
/**
* The mail search bar (#845): a text input with a suggestion dropdown that
* offers recent searches and contact / sender matches while typing. The
* dropdown is keyboard-navigable (arrows + Enter, Escape to dismiss) and
* reuses the composer's contact autocomplete lookup so directory principals,
* address-book contacts and recent recipients all surface here too.
*/
export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, disabled = false, title }: SearchBoxProps) {
const t = useTranslations("sidebar");
const listId = `search-suggestions-${useId().replace(/:/g, "")}`;
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
// Subscribed only so the memo below recomputes as the lookup's inputs load.
const contacts = useContactStore((s) => s.contacts);
const recentRecipients = useContactStore((s) => s.recentRecipients);
const directoryPrincipals = useContactStore((s) => s.directoryPrincipals);
const recentSearches = useSearchHistoryStore((s) => s.recentSearches);
const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch);
const suggestions = useMemo<SearchSuggestion[]>(() => {
const query = value.trim();
const matches = query ? getAutocomplete(query).filter((r) => !r.group && r.email) : [];
return buildSearchSuggestions(value, recentSearches, matches);
// The three store slices are inputs of getAutocomplete (read via get()),
// not of this closure — list them so the memo recomputes when they load.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, recentSearches, getAutocomplete, contacts, recentRecipients, directoryPrincipals]);
const showDropdown = open && !disabled && suggestions.length > 0;
const activeIndex = showDropdown && selectedIndex < suggestions.length ? selectedIndex : -1;
const close = useCallback(() => {
setOpen(false);
setSelectedIndex(-1);
}, []);
const select = useCallback(
(suggestion: SearchSuggestion) => {
close();
if (suggestion.kind === "recent") {
onChange(suggestion.query);
onSubmit(suggestion.query);
} else {
onSelectContact({ name: suggestion.name, email: suggestion.email }, "from");
}
},
[close, onChange, onSubmit, onSelectContact],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showDropdown) {
if (e.key === "ArrowDown" && suggestions.length > 0 && !disabled) {
e.preventDefault();
setOpen(true);
setSelectedIndex(0);
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === "Enter" && activeIndex >= 0) {
e.preventDefault();
select(suggestions[activeIndex]);
} else if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "Tab") {
close();
}
};
return (
<div ref={containerRef} className="relative flex-1">
<form
onSubmit={(e) => {
e.preventDefault();
if (value.trim()) {
close();
onSubmit(value);
}
}}
className="relative"
>
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder_hint")}
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
setSelectedIndex(-1);
}}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onBlur={(e) => {
// Focus moving into the dropdown (e.g. its action buttons) keeps it open.
const next = e.relatedTarget as Node | null;
if (next && containerRef.current?.contains(next)) return;
close();
}}
onKeyDown={handleKeyDown}
className={cn("ps-9 h-9", value && "pe-8")}
role="combobox"
aria-expanded={showDropdown}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeIndex >= 0 ? suggestionOptionId(listId, activeIndex) : undefined}
autoComplete="off"
data-search-input
data-tour="search-input"
disabled={disabled}
title={title}
/>
{value && (
<button
type="button"
onClick={() => {
close();
onClear();
}}
className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("clear_search")}
>
<X className="w-4 h-4" />
</button>
)}
</form>
{showDropdown && (
<SearchSuggestions
id={listId}
query={value}
suggestions={suggestions}
selectedIndex={activeIndex}
onSelect={select}
onSelectContactTo={(contact) => {
close();
onSelectContact(contact, "to");
}}
onRemoveRecent={removeRecentSearch}
/>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { Clock, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { splitHighlight, type ContactSuggestion, type SearchSuggestion } from "@/lib/search-suggestions";
export function suggestionOptionId(listId: string, index: number): string {
return `${listId}-option-${index}`;
}
function HighlightedText({ text, query, className }: { text: string; query: string; className?: string }) {
return (
<span className={className}>
{splitHighlight(text, query).map((segment, i) =>
segment.match ? (
<mark key={i} className="bg-transparent text-inherit font-semibold">
{segment.text}
</mark>
) : (
<span key={i}>{segment.text}</span>
),
)}
</span>
);
}
interface SearchSuggestionsProps {
id: string;
query: string;
suggestions: SearchSuggestion[];
selectedIndex: number;
onSelect: (suggestion: SearchSuggestion) => void;
/** Run a `to:` search for the contact instead of the default `from:`. */
onSelectContactTo: (contact: ContactSuggestion) => void;
onRemoveRecent: (query: string) => void;
}
/**
* Dropdown under the mail search bar (#845): recent searches first, then
* contact / sender matches. Purely presentational — the owner tracks the
* highlighted index and handles keyboard navigation on the input, mirroring
* the composer's recipient autocomplete.
*/
export function SearchSuggestions({
id,
query,
suggestions,
selectedIndex,
onSelect,
onSelectContactTo,
onRemoveRecent,
}: SearchSuggestionsProps) {
const t = useTranslations("advanced_search");
// Keep the keyboard-highlighted row visible when the list overflows.
useEffect(() => {
if (selectedIndex < 0) return;
const el = document.getElementById(suggestionOptionId(id, selectedIndex));
if (el && typeof el.scrollIntoView === "function") {
el.scrollIntoView({ block: "nearest" });
}
}, [id, selectedIndex]);
if (suggestions.length === 0) return null;
const firstContactIndex = suggestions.findIndex((s) => s.kind === "contact");
const hasRecent = suggestions.some((s) => s.kind === "recent");
return (
<div
id={id}
role="listbox"
className="absolute top-full start-0 end-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-72 overflow-y-auto py-1"
>
{suggestions.map((suggestion, i) => {
const selected = i === selectedIndex;
const rowClass = cn(
"group w-full px-3 py-2 text-start text-sm flex items-center gap-2 cursor-pointer",
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
);
const heading =
(i === 0 && hasRecent && suggestion.kind === "recent" && (
<div className="px-3 pt-1 pb-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t("suggestions_recent")}
</div>
)) ||
(i === firstContactIndex && (
<div
className={cn(
"px-3 pt-1 pb-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground",
hasRecent && "mt-1 border-t border-border pt-2",
)}
>
{t("suggestions_people")}
</div>
));
if (suggestion.kind === "recent") {
return (
<div key={`recent-${suggestion.query}`}>
{heading}
<div
id={suggestionOptionId(id, i)}
role="option"
aria-selected={selected}
className={rowClass}
onMouseDown={(e) => {
e.preventDefault();
onSelect(suggestion);
}}
>
<Clock className="w-4 h-4 shrink-0 text-muted-foreground" aria-hidden />
<HighlightedText text={suggestion.query} query={query} className="flex-1 truncate" />
<button
type="button"
tabIndex={-1}
aria-label={t("suggestions_remove_recent")}
title={t("suggestions_remove_recent")}
className={cn(
"shrink-0 p-0.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted-foreground/15 transition-opacity",
selected ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus:opacity-100",
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onRemoveRecent(suggestion.query);
}}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
const contact: ContactSuggestion = { name: suggestion.name, email: suggestion.email };
const fromLabel = t("suggestions_from", { address: suggestion.email });
return (
<div key={`contact-${suggestion.email}`}>
{heading}
<div
id={suggestionOptionId(id, i)}
role="option"
aria-selected={selected}
aria-label={fromLabel}
title={fromLabel}
className={rowClass}
onMouseDown={(e) => {
e.preventDefault();
onSelect(suggestion);
}}
>
<Avatar name={suggestion.name} email={suggestion.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
<span className="flex-1 min-w-0 flex items-baseline gap-1.5 truncate">
{suggestion.name ? (
<>
<HighlightedText text={suggestion.name} query={query} className="truncate" />
<HighlightedText text={`<${suggestion.email}>`} query={query} className="text-muted-foreground truncate" />
</>
) : (
<HighlightedText text={suggestion.email} query={query} className="truncate" />
)}
</span>
<span className="shrink-0 flex items-center gap-1 text-[11px]">
<span className="px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">{t("from")}</span>
<button
type="button"
tabIndex={-1}
aria-label={t("suggestions_to", { address: suggestion.email })}
title={t("suggestions_to", { address: suggestion.email })}
className={cn(
"px-1.5 py-0.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-opacity",
selected ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus:opacity-100",
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelectContactTo(contact);
}}
>
{t("to")}
</button>
</span>
</div>
</div>
);
})}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import {
buildSearchSuggestions,
splitHighlight,
MAX_RECENT_IDLE,
MAX_RECENT_TYPING,
MAX_CONTACT_SUGGESTIONS,
} from '../search-suggestions';
const contacts = [
{ name: 'Mustafa Kemal', email: 'mustafa@example.com' },
{ name: 'Mustafa Sandal', email: 'sandal@example.com' },
];
describe('buildSearchSuggestions', () => {
it('shows only recent searches when the query is empty', () => {
const recent = ['invoice', 'mustafa', 'q3 report'];
expect(buildSearchSuggestions('', recent, contacts)).toEqual([
{ kind: 'recent', query: 'invoice' },
{ kind: 'recent', query: 'mustafa' },
{ kind: 'recent', query: 'q3 report' },
]);
});
it('caps idle recent searches', () => {
const recent = Array.from({ length: 12 }, (_, i) => `search ${i}`);
const result = buildSearchSuggestions(' ', recent, []);
expect(result).toHaveLength(MAX_RECENT_IDLE);
expect(result[0]).toEqual({ kind: 'recent', query: 'search 0' });
});
it('filters recent searches by substring, case-insensitively, while typing', () => {
const recent = ['Invoice March', 'mustafa', 'invoice april', 'q3 report'];
const result = buildSearchSuggestions('INV', recent, []);
expect(result).toEqual([
{ kind: 'recent', query: 'Invoice March' },
{ kind: 'recent', query: 'invoice april' },
]);
});
it('omits a recent search that exactly equals the typed query', () => {
const result = buildSearchSuggestions('mustafa', ['mustafa', 'mustafa kemal'], []);
expect(result).toEqual([{ kind: 'recent', query: 'mustafa kemal' }]);
});
it('limits recent matches while typing so contacts stay visible', () => {
const recent = Array.from({ length: 6 }, (_, i) => `invoice ${i}`);
const result = buildSearchSuggestions('invoice', recent, []);
expect(result).toHaveLength(MAX_RECENT_TYPING);
});
it('lists recent searches before contact matches', () => {
const result = buildSearchSuggestions('musta', ['mustafa invoice'], contacts);
expect(result.map((s) => s.kind)).toEqual(['recent', 'contact', 'contact']);
expect(result[1]).toEqual({ kind: 'contact', name: 'Mustafa Kemal', email: 'mustafa@example.com' });
});
it('drops group entries (no address) and duplicate addresses', () => {
const result = buildSearchSuggestions('m', [], [
{ name: 'Marketing group', email: '' },
{ name: 'Mustafa', email: 'mustafa@example.com' },
{ name: 'Mustafa (alt)', email: 'MUSTAFA@example.com' },
{ name: '', email: ' ' },
]);
expect(result).toEqual([{ kind: 'contact', name: 'Mustafa', email: 'mustafa@example.com' }]);
});
it('caps contact matches', () => {
const many = Array.from({ length: 9 }, (_, i) => ({ name: `Person ${i}`, email: `p${i}@example.com` }));
expect(buildSearchSuggestions('p', [], many)).toHaveLength(MAX_CONTACT_SUGGESTIONS);
});
});
describe('splitHighlight', () => {
it('returns the whole text unmatched for an empty query', () => {
expect(splitHighlight('Mustafa', '')).toEqual([{ text: 'Mustafa', match: false }]);
expect(splitHighlight('Mustafa', ' ')).toEqual([{ text: 'Mustafa', match: false }]);
});
it('marks every case-insensitive occurrence, preserving original casing', () => {
expect(splitHighlight('Anna Hannah', 'an')).toEqual([
{ text: 'An', match: true },
{ text: 'na H', match: false },
{ text: 'an', match: true },
{ text: 'nah', match: false },
]);
});
it('handles matches at the very start and end', () => {
expect(splitHighlight('mustafa@example.com', 'mustafa')).toEqual([
{ text: 'mustafa', match: true },
{ text: '@example.com', match: false },
]);
expect(splitHighlight('mail from bob', 'bob')).toEqual([
{ text: 'mail from ', match: false },
{ text: 'bob', match: true },
]);
});
it('returns an unmatched segment when there is no occurrence', () => {
expect(splitHighlight('Alice', 'zed')).toEqual([{ text: 'Alice', match: false }]);
});
});
+94
View File
@@ -0,0 +1,94 @@
// Pure helpers behind the search-bar suggestion dropdown (#845): merging
// recent searches with contact/sender matches, and splitting a label into
// highlighted / plain segments for the matched substring.
export interface ContactSuggestion {
name: string;
email: string;
}
export type SearchSuggestion =
| { kind: "recent"; query: string }
| { kind: "contact"; name: string; email: string };
/** Recent searches shown when the search bar is focused but empty. */
export const MAX_RECENT_IDLE = 5;
/** Recent searches shown alongside contact matches while typing. */
export const MAX_RECENT_TYPING = 3;
/** Contact / sender matches shown while typing. */
export const MAX_CONTACT_SUGGESTIONS = 5;
/**
* Build the flat, keyboard-navigable suggestion list for a query.
*
* - Empty query: the most recent searches only.
* - Non-empty query: recent searches containing the query (minus an exact
* match, which would just re-run what is already typed), followed by
* contact matches. Contact groups (no address) are dropped — a sender
* filter needs a concrete address — and duplicate addresses collapse.
*/
export function buildSearchSuggestions(
query: string,
recentSearches: readonly string[],
contacts: readonly ContactSuggestion[],
): SearchSuggestion[] {
const q = query.trim().toLowerCase();
const suggestions: SearchSuggestion[] = [];
if (!q) {
for (const recent of recentSearches.slice(0, MAX_RECENT_IDLE)) {
suggestions.push({ kind: "recent", query: recent });
}
return suggestions;
}
let recentCount = 0;
for (const recent of recentSearches) {
if (recentCount >= MAX_RECENT_TYPING) break;
const lower = recent.toLowerCase();
if (lower === q || !lower.includes(q)) continue;
suggestions.push({ kind: "recent", query: recent });
recentCount++;
}
const seen = new Set<string>();
for (const contact of contacts) {
if (seen.size >= MAX_CONTACT_SUGGESTIONS) break;
const email = contact.email.trim();
if (!email) continue;
const key = email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
suggestions.push({ kind: "contact", name: contact.name.trim(), email });
}
return suggestions;
}
export interface HighlightSegment {
text: string;
match: boolean;
}
/**
* Split `text` into segments, flagging every case-insensitive occurrence of
* `query` so the UI can emphasise the matched substring. An empty query yields
* the whole text as a single non-matching segment.
*/
export function splitHighlight(text: string, query: string): HighlightSegment[] {
const needle = query.trim().toLowerCase();
if (!needle || !text) return [{ text, match: false }];
const segments: HighlightSegment[] = [];
const haystack = text.toLowerCase();
let cursor = 0;
let idx = haystack.indexOf(needle, cursor);
while (idx !== -1) {
if (idx > cursor) segments.push({ text: text.slice(cursor, idx), match: false });
segments.push({ text: text.slice(idx, idx + needle.length), match: true });
cursor = idx + needle.length;
idx = haystack.indexOf(needle, cursor);
}
if (cursor < text.length) segments.push({ text: text.slice(cursor), match: false });
return segments;
}
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "استخدم المرشحات المتقدمة لبحث دقيق",
"advanced_filters_tooltip": "مرشحات البحث المتقدم",
"results_found": "{count, plural, =0 {لم يتم العثور على نتائج} one {تم العثور على # نتيجة} other {تم العثور على # نتيجة}}",
"results_found_more": "تم العثور على {count}+ نتيجة"
"results_found_more": "تم العثور على {count}+ نتيجة",
"suggestions_recent": "عمليات البحث الأخيرة",
"suggestions_people": "الأشخاص",
"suggestions_from": "البحث في البريد من {address}",
"suggestions_to": "البحث في البريد إلى {address}",
"suggestions_remove_recent": "إزالة من عمليات البحث الأخيرة"
},
"welcome": {
"title": "مرحبًا بك في صندوق بريدك",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "Utilitzeu els filtres avançats per a una cerca precisa",
"advanced_filters_tooltip": "Filtres de cerca avançada",
"results_found": "{count, plural, =0 {No s'ha trobat cap resultat} one {# resultat trobat} other {# resultats trobats}}",
"results_found_more": "{count}+ resultats trobats"
"results_found_more": "{count}+ resultats trobats",
"suggestions_recent": "Cerques recents",
"suggestions_people": "Persones",
"suggestions_from": "Cerca correu de {address}",
"suggestions_to": "Cerca correu per a {address}",
"suggestions_remove_recent": "Elimina de les cerques recents"
},
"welcome": {
"title": "Us donem la benvinguda a la bústia",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Použijte pokročilé filtry pro přesné vyhledávání",
"advanced_filters_tooltip": "Pokročilé filtry hledání",
"results_found": "{count, plural, =0 {Nenalezeny žádné výsledky} one {Nalezen 1 výsledek} few {Nalezeny # výsledky} other {Nalezeno # výsledků}}",
"results_found_more": "Nalezeno více než {count} výsledků"
"results_found_more": "Nalezeno více než {count} výsledků",
"suggestions_recent": "Nedávná hledání",
"suggestions_people": "Lidé",
"suggestions_from": "Hledat e-maily od {address}",
"suggestions_to": "Hledat e-maily pro {address}",
"suggestions_remove_recent": "Odebrat z nedávných hledání"
},
"welcome": {
"title": "Vítejte ve své poštovní schránce",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "Brug avancerede filtre til præcis søgning",
"advanced_filters_tooltip": "Avancerede søgefiltre",
"results_found": "{count, plural, =0 {Ingen resultater fundet} one {# resultat fundet} other {# resultater fundet}}",
"results_found_more": "{count}+ resultater fundet"
"results_found_more": "{count}+ resultater fundet",
"suggestions_recent": "Seneste søgninger",
"suggestions_people": "Personer",
"suggestions_from": "Søg efter mails fra {address}",
"suggestions_to": "Søg efter mails til {address}",
"suggestions_remove_recent": "Fjern fra seneste søgninger"
},
"welcome": {
"title": "Velkommen til din postkasse",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Verwenden Sie erweiterte Filter für eine präzise Suche",
"advanced_filters_tooltip": "Erweiterte Suchfilter",
"results_found": "{count, plural, =0 {Keine Ergebnisse gefunden} one {# Ergebnis gefunden} other {# Ergebnisse gefunden}}",
"results_found_more": "{count}+ Ergebnisse gefunden"
"results_found_more": "{count}+ Ergebnisse gefunden",
"suggestions_recent": "Letzte Suchen",
"suggestions_people": "Personen",
"suggestions_from": "E-Mails von {address} suchen",
"suggestions_to": "E-Mails an {address} suchen",
"suggestions_remove_recent": "Aus den letzten Suchen entfernen"
},
"welcome": {
"title": "Willkommen in Ihrem Postfach",
+6 -1
View File
@@ -3192,7 +3192,12 @@
"search_hint": "Use advanced filters for precise search",
"advanced_filters_tooltip": "Advanced search filters",
"results_found": "{count, plural, =0 {No results found} one {# result found} other {# results found}}",
"results_found_more": "{count}+ results found"
"results_found_more": "{count}+ results found",
"suggestions_recent": "Recent searches",
"suggestions_people": "People",
"suggestions_from": "Search mail from {address}",
"suggestions_to": "Search mail to {address}",
"suggestions_remove_recent": "Remove from recent searches"
},
"welcome": {
"title": "Welcome to your mailbox",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Usa los filtros avanzados para una búsqueda precisa",
"advanced_filters_tooltip": "Filtros de búsqueda avanzados",
"results_found": "{count, plural, =0 {No se encontraron resultados} one {# resultado encontrado} other {# resultados encontrados}}",
"results_found_more": "{count}+ resultados encontrados"
"results_found_more": "{count}+ resultados encontrados",
"suggestions_recent": "Búsquedas recientes",
"suggestions_people": "Personas",
"suggestions_from": "Buscar correos de {address}",
"suggestions_to": "Buscar correos para {address}",
"suggestions_remove_recent": "Quitar de las búsquedas recientes"
},
"welcome": {
"title": "Bienvenido a tu buzón",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "از فیلترهای پیشرفته برای جستجوی دقیق استفاده کنید",
"advanced_filters_tooltip": "فیلترهای جستجوی پیشرفته",
"results_found": "{count, plural, =0 {نتیجه‌ای یافت نشد} one {# نتیجه یافت شد} other {# نتیجه یافت شد}}",
"results_found_more": "+{count} نتیجه یافت شد"
"results_found_more": "+{count} نتیجه یافت شد",
"suggestions_recent": "جستجوهای اخیر",
"suggestions_people": "افراد",
"suggestions_from": "جستجوی ایمیل‌های از {address}",
"suggestions_to": "جستجوی ایمیل‌های به {address}",
"suggestions_remove_recent": "حذف از جستجوهای اخیر"
},
"welcome": {
"title": "به صندوق پستی خود خوش آمدید",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Utilisez les filtres avancés pour une recherche précise",
"advanced_filters_tooltip": "Filtres de recherche avancés",
"results_found": "{count, plural, =0 {Aucun résultat trouvé} one {# résultat trouvé} other {# résultats trouvés}}",
"results_found_more": "{count}+ résultats trouvés"
"results_found_more": "{count}+ résultats trouvés",
"suggestions_recent": "Recherches récentes",
"suggestions_people": "Personnes",
"suggestions_from": "Rechercher les e-mails de {address}",
"suggestions_to": "Rechercher les e-mails à {address}",
"suggestions_remove_recent": "Retirer des recherches récentes"
},
"welcome": {
"title": "Bienvenue dans votre boite mail",
+6 -1
View File
@@ -3076,7 +3076,12 @@
"search_hint": "השתמש במסננים מתקדמים לחיפוש מדויק",
"advanced_filters_tooltip": "מסנני חיפוש מתקדמים",
"results_found": "{count, plural, =0 {לא נמצאו תוצאות} one {נמצאה תוצאה אחת} other {נמצאו # תוצאות}}",
"results_found_more": "נמצאו תוצאות{count}+"
"results_found_more": "נמצאו תוצאות{count}+",
"suggestions_recent": "חיפושים אחרונים",
"suggestions_people": "אנשים",
"suggestions_from": "חיפוש הודעות מאת {address}",
"suggestions_to": "חיפוש הודעות אל {address}",
"suggestions_remove_recent": "הסרה מהחיפושים האחרונים"
},
"welcome": {
"title": "ברוך הבא לתיבת הדואר שלך",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "Használj speciális szűrőket a pontos kereséshez",
"advanced_filters_tooltip": "Speciális keresőszűrők",
"results_found": "{count, plural, =0 {Nincs találat} one {# találat} other {# találat}}",
"results_found_more": "{count}+ találat"
"results_found_more": "{count}+ találat",
"suggestions_recent": "Legutóbbi keresések",
"suggestions_people": "Személyek",
"suggestions_from": "Levelek keresése tőle: {address}",
"suggestions_to": "Levelek keresése neki: {address}",
"suggestions_remove_recent": "Eltávolítás a legutóbbi keresésekből"
},
"welcome": {
"title": "Üdvözlünk a postafiókodban",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Usa i filtri avanzati per una ricerca precisa",
"advanced_filters_tooltip": "Filtri di ricerca avanzati",
"results_found": "{count, plural, =0 {Nessun risultato trovato} one {# risultato trovato} other {# risultati trovati}}",
"results_found_more": "{count}+ risultati trovati"
"results_found_more": "{count}+ risultati trovati",
"suggestions_recent": "Ricerche recenti",
"suggestions_people": "Persone",
"suggestions_from": "Cerca email da {address}",
"suggestions_to": "Cerca email a {address}",
"suggestions_remove_recent": "Rimuovi dalle ricerche recenti"
},
"welcome": {
"title": "Benvenuto nella tua casella di posta",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "詳細フィルターで正確に検索",
"advanced_filters_tooltip": "詳細検索フィルター",
"results_found": "{count, plural, =0 {結果が見つかりません} other {#件の結果}}",
"results_found_more": "{count}+件の結果"
"results_found_more": "{count}+件の結果",
"suggestions_recent": "最近の検索",
"suggestions_people": "連絡先",
"suggestions_from": "{address} からのメールを検索",
"suggestions_to": "{address} 宛のメールを検索",
"suggestions_remove_recent": "最近の検索から削除"
},
"welcome": {
"title": "メールボックスへようこそ",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "고급 필터로 더 정확하게 검색해 보세요",
"advanced_filters_tooltip": "상세 검색 필터",
"results_found": "{count}개의 검색 결과",
"results_found_more": "{count}개 이상의 검색 결과"
"results_found_more": "{count}개 이상의 검색 결과",
"suggestions_recent": "최근 검색",
"suggestions_people": "사람",
"suggestions_from": "{address}에서 온 메일 검색",
"suggestions_to": "{address}에게 보낸 메일 검색",
"suggestions_remove_recent": "최근 검색에서 삭제"
},
"welcome": {
"title": "웹메일에 오신 것을 환영해요!",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Izmantojiet filtrus precīzākai meklēšanai",
"advanced_filters_tooltip": "Izvērstie meklēšanas filtri",
"results_found": "{count, plural, =0 {Nekas netika atrasts} one {# rezultāts} other {# rezultāti}}",
"results_found_more": "Atrasti {count}+ rezultāti"
"results_found_more": "Atrasti {count}+ rezultāti",
"suggestions_recent": "Nesenie meklējumi",
"suggestions_people": "Personas",
"suggestions_from": "Meklēt e-pastus no {address}",
"suggestions_to": "Meklēt e-pastus adresātam {address}",
"suggestions_remove_recent": "Noņemt no nesenajiem meklējumiem"
},
"welcome": {
"title": "Laipni lūdzam jūsu pastkastē",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "Нарийвчилсан хайлт хийхийн тулд дэвшилтэт шүүлтүүр ашиглах",
"advanced_filters_tooltip": "Дэвшилтэт хайлтын шүүлтүүрүүд",
"results_found": "{count, plural, =0 {Үр дүн олдсонгүй} one {# үр дүн олдлоо} other {# үр дүн олдлоо}}",
"results_found_more": "{count}+ илэрц олдлоо"
"results_found_more": "{count}+ илэрц олдлоо",
"suggestions_recent": "Сүүлийн хайлтууд",
"suggestions_people": "Хүмүүс",
"suggestions_from": "{address}-аас ирсэн захидал хайх",
"suggestions_to": "{address} руу илгээсэн захидал хайх",
"suggestions_remove_recent": "Сүүлийн хайлтуудаас хасах"
},
"welcome": {
"title": "Таны шуудангийн хайрцагт тавтай морил",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Gebruik geavanceerde filters voor een nauwkeurige zoekopdracht",
"advanced_filters_tooltip": "Geavanceerde zoekfilters",
"results_found": "{count, plural, =0 {Geen resultaten gevonden} one {# resultaat gevonden} other {# resultaten gevonden}}",
"results_found_more": "{count}+ resultaten gevonden"
"results_found_more": "{count}+ resultaten gevonden",
"suggestions_recent": "Recente zoekopdrachten",
"suggestions_people": "Personen",
"suggestions_from": "E-mails van {address} zoeken",
"suggestions_to": "E-mails aan {address} zoeken",
"suggestions_remove_recent": "Verwijderen uit recente zoekopdrachten"
},
"welcome": {
"title": "Welkom bij uw mailbox",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Użyj zaawansowanych filtrów, aby precyzyjnie wyszukiwać",
"advanced_filters_tooltip": "Zaawansowane filtry wyszukiwania",
"results_found": "{count, plural, =0 {Nie znaleziono wyników} one {Znaleziono # wynik} other {Znaleziono # wyników}}",
"results_found_more": "Znaleziono {count}+ wyników"
"results_found_more": "Znaleziono {count}+ wyników",
"suggestions_recent": "Ostatnie wyszukiwania",
"suggestions_people": "Osoby",
"suggestions_from": "Szukaj wiadomości od {address}",
"suggestions_to": "Szukaj wiadomości do {address}",
"suggestions_remove_recent": "Usuń z ostatnich wyszukiwań"
},
"welcome": {
"title": "Witaj w swojej skrzynce pocztowej",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Use os filtros avançados para uma pesquisa precisa",
"advanced_filters_tooltip": "Filtros de pesquisa avançados",
"results_found": "{count, plural, =0 {Nenhum resultado encontrado} one {# resultado encontrado} other {# resultados encontrados}}",
"results_found_more": "{count}+ resultados encontrados"
"results_found_more": "{count}+ resultados encontrados",
"suggestions_recent": "Pesquisas recentes",
"suggestions_people": "Pessoas",
"suggestions_from": "Pesquisar e-mails de {address}",
"suggestions_to": "Pesquisar e-mails para {address}",
"suggestions_remove_recent": "Remover das pesquisas recentes"
},
"welcome": {
"title": "Bem-vindo à sua caixa de entrada",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "Folosiți filtre avansate pentru o căutare precisă",
"advanced_filters_tooltip": "Filtre de căutare avansate",
"results_found": "{count, plural, =0 {Nu s-au găsit rezultate} one {# rezultat găsit} few {# rezultate găsite} other {# de rezultate găsite}}",
"results_found_more": "{count}+ rezultate găsite"
"results_found_more": "{count}+ rezultate găsite",
"suggestions_recent": "Căutări recente",
"suggestions_people": "Persoane",
"suggestions_from": "Caută e-mailuri de la {address}",
"suggestions_to": "Caută e-mailuri către {address}",
"suggestions_remove_recent": "Elimină din căutările recente"
},
"welcome": {
"title": "Bine ați venit în căsuța dvs. poștală",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Используйте расширенные фильтры для точного поиска",
"advanced_filters_tooltip": "Расширенные фильтры поиска",
"results_found": "{count, plural, =0 {Ничего не найдено} one {# результат} other {# результатов}}",
"results_found_more": "Найдено {count}+ результатов"
"results_found_more": "Найдено {count}+ результатов",
"suggestions_recent": "Недавние запросы",
"suggestions_people": "Люди",
"suggestions_from": "Искать письма от {address}",
"suggestions_to": "Искать письма для {address}",
"suggestions_remove_recent": "Удалить из недавних запросов"
},
"welcome": {
"title": "Добро пожаловать в ваш почтовый ящик",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "Použite pokročilé filtre na presné vyhľadávanie",
"advanced_filters_tooltip": "Pokročilé filtre hľadania",
"results_found": "{count, plural, =0 {Neboli nájdené žiadne výsledky} one {# výsledok nájdený} other {# výsledkov nájdených}}",
"results_found_more": "{count}+ výsledkov nájdených"
"results_found_more": "{count}+ výsledkov nájdených",
"suggestions_recent": "Nedávne vyhľadávania",
"suggestions_people": "Ľudia",
"suggestions_from": "Hľadať e-maily od {address}",
"suggestions_to": "Hľadať e-maily pre {address}",
"suggestions_remove_recent": "Odstrániť z nedávnych vyhľadávaní"
},
"welcome": {
"title": "Vitajte vo svojej poštovej schránke",
+6 -1
View File
@@ -3171,7 +3171,12 @@
"search_hint": "Kesin arama için gelişmiş filtreleri kullanın",
"advanced_filters_tooltip": "Gelişmiş arama filtreleri",
"results_found": "{count, plural, =0 {Sonuç bulunamadı} one {# sonuç bulundu} other {# sonuç bulundu}}",
"results_found_more": "{count}+ sonuç bulundu"
"results_found_more": "{count}+ sonuç bulundu",
"suggestions_recent": "Son aramalar",
"suggestions_people": "Kişiler",
"suggestions_from": "{address} adresinden gelen e-postaları ara",
"suggestions_to": "{address} adresine gönderilen e-postaları ara",
"suggestions_remove_recent": "Son aramalardan kaldır"
},
"welcome": {
"title": "Posta kutunuza hoş geldiniz",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "Використовуйте розширені фільтри для точного пошуку",
"advanced_filters_tooltip": "Розширені фільтри пошуку",
"results_found": "{count, plural, =0 {Результатів не знайдено} one {# результат знайдено} few {# результати знайдено} many {# результатів знайдено} other {# результатів знайдено}}",
"results_found_more": "Знайдено {count}+ результатів"
"results_found_more": "Знайдено {count}+ результатів",
"suggestions_recent": "Останні пошуки",
"suggestions_people": "Люди",
"suggestions_from": "Шукати листи від {address}",
"suggestions_to": "Шукати листи для {address}",
"suggestions_remove_recent": "Видалити з останніх пошуків"
},
"welcome": {
"title": "Ласкаво просимо до вашої поштової скриньки",
+6 -1
View File
@@ -3148,7 +3148,12 @@
"search_hint": "使用高级过滤器进行精确搜索",
"advanced_filters_tooltip": "高级搜索过滤器",
"results_found": "{count, plural, =0 {未找到结果} one {找到 # 条结果} other {找到 # 条结果}}",
"results_found_more": "找到 {count}+ 个结果"
"results_found_more": "找到 {count}+ 个结果",
"suggestions_recent": "最近搜索",
"suggestions_people": "联系人",
"suggestions_from": "搜索来自 {address} 的邮件",
"suggestions_to": "搜索发给 {address} 的邮件",
"suggestions_remove_recent": "从最近搜索中移除"
},
"welcome": {
"title": "欢迎使用邮箱",
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSearchHistoryStore, MAX_RECENT_SEARCHES } from '../search-history-store';
describe('search-history-store', () => {
beforeEach(() => {
useSearchHistoryStore.setState({ recentSearches: [] });
});
it('adds trimmed queries most-recent first', () => {
const { addRecentSearch } = useSearchHistoryStore.getState();
addRecentSearch(' invoice ');
addRecentSearch('mustafa');
expect(useSearchHistoryStore.getState().recentSearches).toEqual(['mustafa', 'invoice']);
});
it('ignores empty or whitespace-only queries', () => {
const { addRecentSearch } = useSearchHistoryStore.getState();
addRecentSearch('');
addRecentSearch(' ');
expect(useSearchHistoryStore.getState().recentSearches).toEqual([]);
});
it('moves a repeated query to the front instead of duplicating it (case-insensitive)', () => {
const { addRecentSearch } = useSearchHistoryStore.getState();
addRecentSearch('invoice');
addRecentSearch('mustafa');
addRecentSearch('Invoice');
expect(useSearchHistoryStore.getState().recentSearches).toEqual(['Invoice', 'mustafa']);
});
it('caps the history length', () => {
const { addRecentSearch } = useSearchHistoryStore.getState();
for (let i = 0; i < MAX_RECENT_SEARCHES + 5; i++) addRecentSearch(`query ${i}`);
const { recentSearches } = useSearchHistoryStore.getState();
expect(recentSearches).toHaveLength(MAX_RECENT_SEARCHES);
expect(recentSearches[0]).toBe(`query ${MAX_RECENT_SEARCHES + 4}`);
});
it('removes a single entry and clears everything', () => {
const { addRecentSearch, removeRecentSearch, clearRecentSearches } = useSearchHistoryStore.getState();
addRecentSearch('a');
addRecentSearch('b');
removeRecentSearch('a');
expect(useSearchHistoryStore.getState().recentSearches).toEqual(['b']);
clearRecentSearches();
expect(useSearchHistoryStore.getState().recentSearches).toEqual([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
"use client";
// Recent mail searches, surfaced as suggestions under the search bar (#845).
// Only the raw text queries are kept (never filter panel state), most recent
// first, deduplicated, and capped so the dropdown stays scannable.
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const MAX_RECENT_SEARCHES = 10;
interface SearchHistoryState {
recentSearches: string[];
addRecentSearch: (query: string) => void;
removeRecentSearch: (query: string) => void;
clearRecentSearches: () => void;
}
export const useSearchHistoryStore = create<SearchHistoryState>()(
persist(
(set) => ({
recentSearches: [],
addRecentSearch: (query) => {
const trimmed = query.trim();
if (!trimmed) return;
set((state) => ({
recentSearches: [
trimmed,
...state.recentSearches.filter((q) => q.toLowerCase() !== trimmed.toLowerCase()),
].slice(0, MAX_RECENT_SEARCHES),
}));
},
removeRecentSearch: (query) => {
set((state) => ({
recentSearches: state.recentSearches.filter((q) => q !== query),
}));
},
clearRecentSearches: () => set({ recentSearches: [] }),
}),
{
name: "search-history-storage",
partialize: (state) => ({ recentSearches: state.recentSearches }),
},
),
);