Feature: recipient autocomplete from Sent, with on-demand server search
Compose recipient fields only suggested existing contacts and directory users, so people you had emailed before but never saved as a contact never came up. This adds an Outlook-Web-style suggestion flow. On startup the Sent folder is read once (metadata only) to build a cache of addresses you have written to; those are merged into the autocomplete after contacts and directory principals, deduped, contacts winning. When the recipient is not in the cache, the dropdown offers a "search the server" row that queries the Sent folder on demand. That lookup fetches only the to/cc fields (no subject, body or attachments) and returns the matching addresses, deduped. New strings are added to all 20 locales.
This commit is contained in:
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -820,6 +820,10 @@ export function EmailComposer({
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const searchRecipients = useContactStore((s) => s.searchRecipients);
|
||||
// Whether a Sent mailbox is known so the on-demand server search is worth
|
||||
// offering (falls back to hiding the "search the server" row otherwise).
|
||||
const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
@@ -895,6 +899,10 @@ export function EmailComposer({
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
// Current trimmed query behind the open dropdown, plus the in-flight flag for
|
||||
// the on-demand Sent-folder lookup ("search the server" row).
|
||||
const [autoQuery, setAutoQuery] = useState('');
|
||||
const [isSearchingServer, setIsSearchingServer] = useState(false);
|
||||
const autocompleteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const toInputRef = useRef<HTMLInputElement>(null);
|
||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -942,8 +950,10 @@ export function EmailComposer({
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
setAutoQuery('');
|
||||
return;
|
||||
}
|
||||
setAutoQuery(query);
|
||||
|
||||
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||
const localResults = getAutocomplete(query);
|
||||
@@ -951,10 +961,39 @@ export function EmailComposer({
|
||||
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
|
||||
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
|
||||
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
|
||||
setActiveAutoField(merged.length > 0 ? field : null);
|
||||
// Keep the dropdown open even without local hits when a server search is
|
||||
// available, so the "search the server" row stays reachable (OWA-style).
|
||||
setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
|
||||
setAutoSelectedIndex(-1);
|
||||
}, 200);
|
||||
}, [getAutocomplete]);
|
||||
}, [getAutocomplete, canSearchServer]);
|
||||
|
||||
// On-demand: search the Sent folder server-side for recipients matching the
|
||||
// current query and merge fresh hits into the open dropdown (deduped by email).
|
||||
const handleServerSearch = useCallback(async () => {
|
||||
const query = autoQuery.trim();
|
||||
if (!composerClient || !query || isSearchingServer) return;
|
||||
setIsSearchingServer(true);
|
||||
try {
|
||||
const serverResults = await searchRecipients(composerClient, query);
|
||||
setAutocompleteResults((prev) => {
|
||||
const seen = new Set(prev.map((r) => r.email.toLowerCase()));
|
||||
const merged = [...prev];
|
||||
for (const r of serverResults) {
|
||||
const key = r.email.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(r);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: a failed lookup just leaves the local suggestions in place.
|
||||
} finally {
|
||||
setIsSearchingServer(false);
|
||||
}
|
||||
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
|
||||
|
||||
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
|
||||
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
|
||||
@@ -2147,6 +2186,10 @@ export function EmailComposer({
|
||||
autoSelectedIndex={autoSelectedIndex}
|
||||
dropdownRef={toDropdownRef}
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
canSearchServer={canSearchServer}
|
||||
onServerSearch={handleServerSearch}
|
||||
isSearchingServer={isSearchingServer}
|
||||
serverSearchQuery={autoQuery}
|
||||
validationError={validationErrors.to}
|
||||
validationMessage={t('validation.recipient_required')}
|
||||
onTab={focusSubject}
|
||||
@@ -2224,6 +2267,10 @@ export function EmailComposer({
|
||||
autoSelectedIndex={autoSelectedIndex}
|
||||
dropdownRef={ccDropdownRef}
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
canSearchServer={canSearchServer}
|
||||
onServerSearch={handleServerSearch}
|
||||
isSearchingServer={isSearchingServer}
|
||||
serverSearchQuery={autoQuery}
|
||||
onMoveChip={handleMoveChip}
|
||||
/>
|
||||
</div>
|
||||
@@ -2249,6 +2296,10 @@ export function EmailComposer({
|
||||
autoSelectedIndex={autoSelectedIndex}
|
||||
dropdownRef={bccDropdownRef}
|
||||
onInsertAutocomplete={insertAutocomplete}
|
||||
canSearchServer={canSearchServer}
|
||||
onServerSearch={handleServerSearch}
|
||||
isSearchingServer={isSearchingServer}
|
||||
serverSearchQuery={autoQuery}
|
||||
onMoveChip={handleMoveChip}
|
||||
/>
|
||||
</div>
|
||||
@@ -2670,7 +2721,10 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
results: Array<{ name: string; email: string }>;
|
||||
selectedIndex: number;
|
||||
onSelect: (suggestion: { name: string; email: string }) => void;
|
||||
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
|
||||
onSearchServer?: () => void;
|
||||
isSearchingServer?: boolean;
|
||||
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
|
||||
const t = useTranslations('email_composer');
|
||||
return (
|
||||
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
@@ -2696,6 +2750,27 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{onSearchServer && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSearchingServer}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
|
||||
results.length > 0 && "border-t border-border"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isSearchingServer) onSearchServer();
|
||||
}}
|
||||
>
|
||||
{isSearchingServer
|
||||
? <Loader2 className="w-4 h-4 shrink-0 animate-spin" />
|
||||
: <Search className="w-4 h-4 shrink-0" />}
|
||||
<span className="truncate">
|
||||
{isSearchingServer ? t('autocomplete_searching') : t('autocomplete_search_server')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2716,6 +2791,10 @@ function RecipientChipInput({
|
||||
autoSelectedIndex,
|
||||
dropdownRef,
|
||||
onInsertAutocomplete,
|
||||
canSearchServer,
|
||||
onServerSearch,
|
||||
isSearchingServer,
|
||||
serverSearchQuery,
|
||||
validationError,
|
||||
validationMessage,
|
||||
onTab,
|
||||
@@ -2736,6 +2815,10 @@ function RecipientChipInput({
|
||||
autoSelectedIndex: number;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
|
||||
canSearchServer: boolean;
|
||||
onServerSearch: () => void;
|
||||
isSearchingServer: boolean;
|
||||
serverSearchQuery: string;
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
@@ -3037,13 +3120,16 @@ function RecipientChipInput({
|
||||
{validationError && validationMessage && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
|
||||
)}
|
||||
{activeAutoField === field && autocompleteResults.length > 0 && (
|
||||
{activeAutoField === field &&
|
||||
(autocompleteResults.length > 0 || (canSearchServer && serverSearchQuery.length > 0)) && (
|
||||
<AutocompleteDropdown
|
||||
ref={dropdownRef}
|
||||
id={`autocomplete-${field}`}
|
||||
results={autocompleteResults}
|
||||
selectedIndex={autoSelectedIndex}
|
||||
onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)}
|
||||
onSearchServer={canSearchServer && serverSearchQuery.length > 0 ? onServerSearch : undefined}
|
||||
isSearchingServer={isSearchingServer}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
|
||||
Reference in New Issue
Block a user