225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
"use client"
|
|
|
|
import type { MouseEvent, ReactNode } from "react"
|
|
import { useCallback, useEffect, 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 {
|
|
avatarColor,
|
|
cleanSenderName,
|
|
resolveSenderEmail,
|
|
senderInitial,
|
|
} from "@/lib/sender-display"
|
|
import {
|
|
Calendar,
|
|
ExternalLink,
|
|
Mail,
|
|
MessageSquare,
|
|
UserPlus,
|
|
Video,
|
|
} from "lucide-react"
|
|
import { useComposeActions } from "@/lib/compose-context"
|
|
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 [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 color = avatarColor(name)
|
|
|
|
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 min-w-0 max-w-full cursor-default text-inherit 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}
|
|
side={side}
|
|
align={align}
|
|
sideOffset={8}
|
|
className={cn(
|
|
"min-w-[380px] w-max max-w-[min(440px,calc(100vw-24px))] rounded-2xl border border-[#e8eaed] bg-white p-0 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">
|
|
<div
|
|
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-lg font-medium text-white"
|
|
style={{ backgroundColor: color }}
|
|
>
|
|
{senderInitial(name)}
|
|
</div>
|
|
<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="Ajouter aux contacts"
|
|
>
|
|
<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-[#dadce0] bg-white text-[#5f6368] transition-colors hover:bg-[#f1f3f4]"
|
|
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-[#dadce0] bg-white text-[#5f6368] transition-colors hover:bg-[#f1f3f4]"
|
|
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-[#dadce0] bg-white text-[#5f6368] transition-colors hover:bg-[#f1f3f4]"
|
|
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]"
|
|
>
|
|
Ouvrir la vue détaillée
|
|
<ExternalLink className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
|
</button>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
)
|
|
}
|