From 99daa53c8c1bb66a485998b529757e4399e34c6b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:09:18 +0200 Subject: [PATCH] feat: search suggestions with recent searches and contact autocomplete #845 --- components/mail/mail-app.tsx | 49 +++-- .../search/__tests__/search-box.test.tsx | 196 ++++++++++++++++++ components/search/search-box.tsx | 179 ++++++++++++++++ components/search/search-suggestions.tsx | 194 +++++++++++++++++ lib/__tests__/search-suggestions.test.ts | 103 +++++++++ lib/search-suggestions.ts | 94 +++++++++ locales/ar/common.json | 7 +- locales/ca/common.json | 7 +- locales/cs/common.json | 7 +- locales/da/common.json | 7 +- locales/de/common.json | 7 +- locales/en/common.json | 7 +- locales/es/common.json | 7 +- locales/fa/common.json | 7 +- locales/fr/common.json | 7 +- locales/he/common.json | 7 +- locales/hu/common.json | 7 +- locales/it/common.json | 7 +- locales/ja/common.json | 7 +- locales/ko/common.json | 7 +- locales/lv/common.json | 7 +- locales/mn/common.json | 7 +- locales/nl/common.json | 7 +- locales/pl/common.json | 7 +- locales/pt/common.json | 7 +- locales/ro/common.json | 7 +- locales/ru/common.json | 7 +- locales/sk/common.json | 7 +- locales/tr/common.json | 7 +- locales/uk/common.json | 7 +- locales/zh/common.json | 7 +- stores/__tests__/search-history-store.test.ts | 48 +++++ stores/search-history-store.ts | 48 +++++ 33 files changed, 1036 insertions(+), 50 deletions(-) create mode 100644 components/search/__tests__/search-box.test.tsx create mode 100644 components/search/search-box.tsx create mode 100644 components/search/search-suggestions.tsx create mode 100644 lib/__tests__/search-suggestions.test.ts create mode 100644 lib/search-suggestions.ts create mode 100644 stores/__tests__/search-history-store.test.ts create mode 100644 stores/search-history-store.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index da2069dc4..a9007f805 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 = {}) { )} -
{ e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1"> - - 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 && ( - - )} - + + )} + + {showDropdown && ( + { + close(); + onSelectContact(contact, "to"); + }} + onRemoveRecent={removeRecentSearch} + /> + )} + + ); +} diff --git a/components/search/search-suggestions.tsx b/components/search/search-suggestions.tsx new file mode 100644 index 000000000..f134ec5da --- /dev/null +++ b/components/search/search-suggestions.tsx @@ -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 ( + + {splitHighlight(text, query).map((segment, i) => + segment.match ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + ); +} + +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 ( +
+ {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" && ( +
+ {t("suggestions_recent")} +
+ )) || + (i === firstContactIndex && ( +
+ {t("suggestions_people")} +
+ )); + + if (suggestion.kind === "recent") { + return ( +
+ {heading} +
{ + e.preventDefault(); + onSelect(suggestion); + }} + > + + + +
+
+ ); + } + + const contact: ContactSuggestion = { name: suggestion.name, email: suggestion.email }; + const fromLabel = t("suggestions_from", { address: suggestion.email }); + return ( +
+ {heading} +
{ + e.preventDefault(); + onSelect(suggestion); + }} + > + + + {suggestion.name ? ( + <> + + `} query={query} className="text-muted-foreground truncate" /> + + ) : ( + + )} + + + {t("from")} + + +
+
+ ); + })} +
+ ); +} diff --git a/lib/__tests__/search-suggestions.test.ts b/lib/__tests__/search-suggestions.test.ts new file mode 100644 index 000000000..b296f49d8 --- /dev/null +++ b/lib/__tests__/search-suggestions.test.ts @@ -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 }]); + }); +}); diff --git a/lib/search-suggestions.ts b/lib/search-suggestions.ts new file mode 100644 index 000000000..8bfa0dbbb --- /dev/null +++ b/lib/search-suggestions.ts @@ -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(); + 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; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 246b9e711..efd89f0bd 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "مرحبًا بك في صندوق بريدك", diff --git a/locales/ca/common.json b/locales/ca/common.json index f4a7cf9e2..7894d7cb8 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index f28c32646..3d5fbdbae 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index f65275a61..7a67d090d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index c6ab44d77..c75941dee 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 42f0e1207..81fdc62c1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 6c62a34c2..136e95fd8 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index abc268950..8a6d83856 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "به صندوق پستی خود خوش آمدید", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6b3acce0a..877d613bc 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 94248cc1e..f5d6f015f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "ברוך הבא לתיבת הדואר שלך", diff --git a/locales/hu/common.json b/locales/hu/common.json index 0b8058084..a6e144298 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index e858932fe..09dcc4a3e 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 73803c9b6..8e94d2221 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "メールボックスへようこそ", diff --git a/locales/ko/common.json b/locales/ko/common.json index a324b4020..4262d60f7 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "웹메일에 오신 것을 환영해요!", diff --git a/locales/lv/common.json b/locales/lv/common.json index 1665588f6..395cbac5c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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ē", diff --git a/locales/mn/common.json b/locales/mn/common.json index f71724f55..f85a83d82 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Таны шуудангийн хайрцагт тавтай морил", diff --git a/locales/nl/common.json b/locales/nl/common.json index 76409e786..b33ad8ee5 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 39d1bb7f2..f386a72a3 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 54175e67f..f3204d42d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index afbb31a84..fb59bba89 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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ă", diff --git a/locales/ru/common.json b/locales/ru/common.json index 781311b73..cef59794b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Добро пожаловать в ваш почтовый ящик", diff --git a/locales/sk/common.json b/locales/sk/common.json index 2c5b61ef4..3ee35961d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index 161585af1..8711ec5fa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index a0e84251e..465006e81 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Ласкаво просимо до вашої поштової скриньки", diff --git a/locales/zh/common.json b/locales/zh/common.json index a2c54c4c1..c077cb13b 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "欢迎使用邮箱", diff --git a/stores/__tests__/search-history-store.test.ts b/stores/__tests__/search-history-store.test.ts new file mode 100644 index 000000000..27871c830 --- /dev/null +++ b/stores/__tests__/search-history-store.test.ts @@ -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([]); + }); +}); diff --git a/stores/search-history-store.ts b/stores/search-history-store.ts new file mode 100644 index 000000000..0c0b82d93 --- /dev/null +++ b/stores/search-history-store.ts @@ -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()( + 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 }), + }, + ), +);