ultisuite-client/components/gmail/contact-hover-card.tsx
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- 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.
2026-06-06 20:26:51 +02:00

271 lines
9.6 KiB
TypeScript

"use client"
import type { MouseEvent, ReactNode } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import {
cleanSenderName,
resolveSenderEmail,
} from "@/lib/sender-display"
import {
Calendar,
ExternalLink,
Mail,
MessageSquare,
UserPlus,
Video,
} from "lucide-react"
import { useComposeActions } from "@/lib/compose-context"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import {
findContactByEmail,
parseDisplayNameToNameParts,
} from "@/lib/contacts/find-contact"
import { useLongPress } from "@/hooks/use-long-press"
import { useCoarsePointer } from "@/hooks/use-touch-nav"
const HOVER_OPEN_DELAY_MS = 1000
export interface ContactHoverCardProps {
/** Champ expéditeur brut (liste, conversation, etc.) */
displayName: string
email?: string
children: ReactNode
className?: string
onTriggerClick?: (e: MouseEvent<HTMLSpanElement>) => void
align?: "start" | "center" | "end"
side?: "top" | "right" | "bottom" | "left"
}
export function ContactHoverCard({
displayName,
email: emailOverride,
children,
className,
onTriggerClick,
align = "start",
side = "bottom",
}: ContactHoverCardProps) {
const { openComposeWithInitial } = useComposeActions()
const { contacts } = useContactsList()
const openContactDetail = useContactsStore((s) => s.openContactDetail)
const openCreateContact = useContactsStore((s) => s.openCreateContact)
const [open, setOpen] = useState(false)
const coarsePointer = useCoarsePointer()
const triggerRef = useRef<HTMLSpanElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const allowHoverOpenRef = useRef(false)
const name = cleanSenderName(displayName)
const email = resolveSenderEmail(displayName, emailOverride)
const matchedContact = useMemo(
() => findContactByEmail(contacts, email),
[contacts, email],
)
const openContactsPanel = useCallback(() => {
setOpen(false)
if (matchedContact) {
openContactDetail(matchedContact.id)
return
}
const { firstName, lastName } = parseDisplayNameToNameParts(name)
openCreateContact({
firstName,
lastName,
emails: email ? [{ value: email, label: "Domicile" }] : undefined,
})
}, [matchedContact, name, email, openContactDetail, openCreateContact])
const openFromLongPress = useCallback(() => {
allowHoverOpenRef.current = true
setOpen(true)
queueMicrotask(() => {
allowHoverOpenRef.current = false
})
}, [])
const longPress = useLongPress(openFromLongPress, {
disabled: !coarsePointer,
delay: HOVER_OPEN_DELAY_MS,
})
const handleOpenChange = useCallback(
(next: boolean) => {
if (coarsePointer && next && !allowHoverOpenRef.current) return
setOpen(next)
},
[coarsePointer]
)
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
const opts: AddEventListenerOptions = { capture: true, passive: true }
window.addEventListener("scroll", close, opts)
window.addEventListener("wheel", close, opts)
window.addEventListener("touchmove", close, opts)
return () => {
window.removeEventListener("scroll", close, opts)
window.removeEventListener("wheel", close, opts)
window.removeEventListener("touchmove", close, opts)
}
}, [open])
useEffect(() => {
if (!open || !coarsePointer) return
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node
if (triggerRef.current?.contains(target)) return
if (contentRef.current?.contains(target)) return
setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown, { capture: true })
return () =>
document.removeEventListener("pointerdown", onPointerDown, { capture: true })
}, [open, coarsePointer])
return (
<HoverCard
open={open}
onOpenChange={handleOpenChange}
openDelay={coarsePointer ? 1_000_000 : HOVER_OPEN_DELAY_MS}
closeDelay={150}
>
<HoverCardTrigger asChild>
<span
ref={triggerRef}
role="presentation"
tabIndex={0}
className={cn(
"inline-block min-w-0 max-w-full cursor-default text-inherit align-middle outline-none focus-visible:ring-2 focus-visible:ring-[#1a73e8]/30 focus-visible:ring-offset-1 rounded-sm",
longPress.ackClassName,
className
)}
onClick={(e) => {
onTriggerClick?.(e)
}}
onPointerDown={coarsePointer ? longPress.onPointerDown : undefined}
onPointerUp={coarsePointer ? longPress.onPointerUp : undefined}
onPointerLeave={coarsePointer ? longPress.onPointerLeave : undefined}
onPointerCancel={coarsePointer ? longPress.onPointerCancel : undefined}
onClickCapture={coarsePointer ? longPress.onClickCapture : undefined}
>
{children}
</span>
</HoverCardTrigger>
<HoverCardContent
ref={contentRef}
data-contact-hover-card
side={side}
align={align}
sideOffset={8}
className={cn(
"min-w-[380px] w-max max-w-[min(440px,calc(100vw-24px))] rounded-2xl border border-border bg-popover p-0 text-popover-foreground shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 outline-hidden"
)}
>
<div className="p-4 pb-3">
<div className="relative flex items-start gap-3">
<ContactAvatar
contact={matchedContact}
name={name}
email={email}
size="md"
/>
<div className="min-w-0 flex-1 pr-8">
<p className="truncate text-base font-semibold leading-tight text-[#202124]">{name}</p>
<p className="truncate text-sm leading-tight text-[#5f6368]">{email}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-8 w-8 shrink-0 text-[#5f6368] hover:bg-[#f1f3f4]"
aria-label={
matchedContact ? "Voir le contact" : "Ajouter aux contacts"
}
onClick={(e) => {
e.stopPropagation()
openContactsPanel()
}}
>
<UserPlus className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="flex flex-nowrap items-center gap-2 px-4 pb-4">
<button
type="button"
className="inline-flex h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-full bg-[#d3e3fd] px-5 text-sm font-medium text-[#001d35] transition-colors hover:bg-[#c4d9fc]"
onClick={(e) => {
e.stopPropagation()
openComposeWithInitial({
to: [{ name, email }],
})
}}
>
<Mail className="h-[18px] w-[18px] shrink-0" strokeWidth={1.5} />
Envoyer un e-mail
</button>
<button
type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
aria-label="Message"
>
<MessageSquare className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button>
<button
type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
aria-label="Visioconférence"
>
<Video className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button>
<button
type="button"
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
aria-label="Planifier"
>
<Calendar className="h-[18px] w-[18px]" strokeWidth={1.5} />
<span className="absolute right-1 top-1 size-1.5 rounded-full bg-[#1a73e8]" aria-hidden />
</button>
</div>
<div className="border-t border-[#eceff1] px-3 pb-3 pt-2">
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#f1f3f4] px-3 py-2.5 text-sm font-medium text-[#1a73e8] transition-colors hover:bg-[#e8eaed]"
onClick={(e) => {
e.stopPropagation()
openContactsPanel()
}}
>
{matchedContact ? (
<>
Ouvrir la vue détaillée
<ExternalLink className="h-4 w-4 shrink-0" strokeWidth={1.5} />
</>
) : (
<>
Ajouter aux contacts
<UserPlus className="h-4 w-4 shrink-0" strokeWidth={1.5} />
</>
)}
</button>
</div>
</HoverCardContent>
</HoverCard>
)
}