Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views. - Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts. - Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface. - Added loading and error states in ContactsListView for better user feedback during data fetching. - Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list. - Updated package.json to include @formkit/auto-animate for improved UI animations.
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent,
|
|
} from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import {
|
|
ArrowLeft,
|
|
Search,
|
|
X,
|
|
Paperclip,
|
|
Clock,
|
|
User,
|
|
SlidersHorizontal,
|
|
} from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
|
import { cn } from "@/lib/utils"
|
|
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
|
import { scoreApiContact } from "@/lib/contacts/contact-match-score"
|
|
import { useActiveAccount } from "@/lib/stores/account-store"
|
|
import {
|
|
bestCompletion,
|
|
type SearchSuggestion,
|
|
type ContactSuggestion,
|
|
} from "@/lib/mail-search/search-engine"
|
|
import {
|
|
buildQuickSearchParams,
|
|
submitMailSearch,
|
|
} from "@/lib/mail-search/navigate"
|
|
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
import {
|
|
MAIL_MOBILE_SEARCH_SHEET_CLASS,
|
|
MAIL_SEARCH_CHIP_INACTIVE_CLASS,
|
|
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
|
|
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS,
|
|
} from "@/lib/mail-chrome-classes"
|
|
import { MobileAdvancedSearch } from "@/components/gmail/mail-search/mobile-advanced-search"
|
|
|
|
interface MobileSearchOverlayProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
initialQuery?: string
|
|
}
|
|
|
|
export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: MobileSearchOverlayProps) {
|
|
const router = useRouter()
|
|
const account = useActiveAccount()
|
|
|
|
const inputValue = useMailSearchStore((s) => s.inputValue)
|
|
const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
|
|
const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
|
|
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
|
|
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
|
|
|
|
const { data: searchContactResults } = useSearchContacts(inputValue)
|
|
const {
|
|
setInputValue,
|
|
setSelectedIndex,
|
|
toggleChipAttachment,
|
|
toggleChipLast7Days,
|
|
toggleChipFromMe,
|
|
resetChips,
|
|
reset,
|
|
} = useMailSearchStore.getState()
|
|
|
|
const [advancedMode, setAdvancedMode] = useState(false)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setInputValue(initialQuery)
|
|
setAdvancedMode(false)
|
|
setTimeout(() => inputRef.current?.focus(), 50)
|
|
} else {
|
|
reset()
|
|
setAdvancedMode(false)
|
|
}
|
|
}, [open, initialQuery, setInputValue, reset])
|
|
|
|
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
|
if (!inputValue.trim() || !searchContactResults?.length) return []
|
|
return searchContactResults.slice(0, 6).map<ContactSuggestion>((c) => ({
|
|
kind: "contact",
|
|
contact: {
|
|
id: c.uid,
|
|
firstName: c.full_name.split(" ")[0] ?? "",
|
|
lastName: c.full_name.split(" ").slice(1).join(" "),
|
|
emails: c.email ? [{ value: c.email, label: "primary" }] : [],
|
|
phones: [],
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
},
|
|
email: c.email ?? "",
|
|
displayName: c.full_name,
|
|
score: scoreApiContact(c, inputValue),
|
|
}))
|
|
}, [inputValue, searchContactResults])
|
|
|
|
const ghostText = useMemo(
|
|
() => bestCompletion(inputValue, suggestions),
|
|
[inputValue, suggestions]
|
|
)
|
|
|
|
const totalItems = suggestions.length + 1
|
|
|
|
const submitSearch = useCallback(
|
|
(overrideQuery?: string) => {
|
|
const q = overrideQuery ?? inputValue
|
|
const params = buildQuickSearchParams(q, {
|
|
chipAttachment,
|
|
chipLast7Days,
|
|
chipFromMe,
|
|
fromEmail: account?.email ?? "",
|
|
})
|
|
if (!Object.keys(params).length) return
|
|
submitMailSearch(router, params, { onAfter: onClose })
|
|
},
|
|
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
|
|
)
|
|
|
|
const selectSuggestion = useCallback(
|
|
(s: SearchSuggestion) => {
|
|
const params = buildQuickSearchParams(s.email, {
|
|
chipAttachment,
|
|
chipLast7Days,
|
|
chipFromMe,
|
|
fromEmail: account?.email ?? "",
|
|
})
|
|
submitMailSearch(router, params, { onAfter: onClose })
|
|
},
|
|
[chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
|
|
)
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault()
|
|
setSelectedIndex(selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0)
|
|
break
|
|
case "ArrowUp":
|
|
e.preventDefault()
|
|
setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : totalItems - 1)
|
|
break
|
|
case "Enter":
|
|
e.preventDefault()
|
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
|
selectSuggestion(suggestions[selectedIndex]!)
|
|
} else {
|
|
submitSearch()
|
|
}
|
|
break
|
|
case "Tab":
|
|
if (ghostText) {
|
|
e.preventDefault()
|
|
setInputValue(inputValue + ghostText)
|
|
}
|
|
break
|
|
case "Escape":
|
|
e.preventDefault()
|
|
onClose()
|
|
break
|
|
}
|
|
},
|
|
[selectedIndex, totalItems, suggestions, ghostText, inputValue, submitSearch, selectSuggestion, onClose]
|
|
)
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
|
<SheetContent
|
|
side="bottom"
|
|
hideClose
|
|
overlayClassName="z-[100] bg-black/40"
|
|
className={MAIL_MOBILE_SEARCH_SHEET_CLASS}
|
|
>
|
|
<SheetTitle className="sr-only">Rechercher dans les messages</SheetTitle>
|
|
{/* Header */}
|
|
<div
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-2 border-b bg-mail-surface-elevated px-2 py-2",
|
|
MAIL_SEARCH_SECTION_DIVIDER_CLASS
|
|
)}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-10 shrink-0 text-muted-foreground"
|
|
onClick={() => { if (advancedMode) setAdvancedMode(false); else onClose() }}
|
|
aria-label="Retour"
|
|
>
|
|
<ArrowLeft className="size-5" />
|
|
</Button>
|
|
<div className="relative flex min-w-0 flex-1 items-center">
|
|
{ghostText && !advancedMode && (
|
|
<div className="pointer-events-none absolute left-0 flex items-center text-sm text-muted-foreground" aria-hidden>
|
|
<span className="invisible">{inputValue}</span>
|
|
<span>{ghostText}</span>
|
|
</div>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => {
|
|
setInputValue(e.target.value)
|
|
setSelectedIndex(-1)
|
|
if (advancedMode) setAdvancedMode(false)
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Rechercher dans les messages"
|
|
className="h-10 w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
{inputValue && !advancedMode && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-10 shrink-0 text-muted-foreground"
|
|
onClick={() => {
|
|
setInputValue("")
|
|
inputRef.current?.focus()
|
|
}}
|
|
aria-label="Effacer"
|
|
>
|
|
<X className="size-5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-10 shrink-0 text-muted-foreground"
|
|
onClick={() => setAdvancedMode(!advancedMode)}
|
|
aria-label="Recherche avancée"
|
|
>
|
|
<SlidersHorizontal className="size-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{advancedMode ? (
|
|
<MobileAdvancedSearch
|
|
initialQuery={inputValue}
|
|
onSubmit={(url) => { router.push(url); onClose() }}
|
|
/>
|
|
) : (
|
|
<div className={cn("flex min-h-0 flex-1 flex-col", MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS)}>
|
|
{/* Filter chips */}
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 overflow-x-auto border-b px-4 py-2",
|
|
MAIL_SEARCH_SECTION_DIVIDER_CLASS
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleChipAttachment()}
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
|
|
chipAttachment
|
|
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
|
: MAIL_SEARCH_CHIP_INACTIVE_CLASS
|
|
)}
|
|
>
|
|
<Paperclip className="size-3.5" />
|
|
Contient une pièce jointe
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleChipLast7Days()}
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
|
|
chipLast7Days
|
|
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
|
: MAIL_SEARCH_CHIP_INACTIVE_CLASS
|
|
)}
|
|
>
|
|
<Clock className="size-3.5" />
|
|
7 derniers jours
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleChipFromMe()}
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
|
|
chipFromMe
|
|
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
|
: MAIL_SEARCH_CHIP_INACTIVE_CLASS
|
|
)}
|
|
>
|
|
<User className="size-3.5" />
|
|
De moi
|
|
</button>
|
|
</div>
|
|
|
|
{/* Suggestions */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
{inputValue.trim() && (
|
|
<>
|
|
{suggestions.map((s, i) => {
|
|
const isSelected = i === selectedIndex
|
|
if (s.kind === "contact") {
|
|
const initial = senderInitial(s.displayName)
|
|
const color = avatarColor(s.displayName)
|
|
return (
|
|
<button
|
|
key={`c-${s.contact.id}-${s.email}`}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
|
|
isSelected && "bg-mail-nav-hover"
|
|
)}
|
|
onClick={() => selectSuggestion(s)}
|
|
>
|
|
<div
|
|
className="flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
|
style={{ backgroundColor: color }}
|
|
>
|
|
{initial}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-medium text-foreground">
|
|
{s.displayName}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{s.email}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
return (
|
|
<button
|
|
key={`e-${s.email}`}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
|
|
isSelected && "bg-mail-nav-hover"
|
|
)}
|
|
onClick={() => selectSuggestion(s)}
|
|
>
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-mail-surface-muted text-muted-foreground">
|
|
<User className="size-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-foreground">
|
|
{s.email}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
|
|
{/* All results row */}
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
|
|
selectedIndex === suggestions.length && "bg-mail-nav-hover"
|
|
)}
|
|
onClick={() => submitSearch()}
|
|
>
|
|
<div className="flex size-9 shrink-0 items-center justify-center">
|
|
<Search className="size-5 text-muted-foreground" />
|
|
</div>
|
|
<span className="text-muted-foreground">
|
|
Tous les résultats pour «
|
|
<span className="font-medium text-foreground">
|
|
{inputValue}
|
|
</span>
|
|
»
|
|
</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|