mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: search suggestions with recent searches and contact autocomplete #845
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": "مرحبًا بك في صندوق بريدك",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "به صندوق پستی خود خوش آمدید",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ברוך הבא לתיבת הדואר שלך",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メールボックスへようこそ",
|
||||
|
||||
@@ -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": "웹메일에 오신 것을 환영해요!",
|
||||
|
||||
@@ -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ē",
|
||||
|
||||
@@ -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": "Таны шуудангийн хайрцагт тавтай морил",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Добро пожаловать в ваш почтовый ящик",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ласкаво просимо до вашої поштової скриньки",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user