Add Contact Avatar Features and Improve UI Components
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.
This commit is contained in:
R3D347HR4Y 2026-06-06 20:26:51 +02:00
parent a4b548ca08
commit 07d57f13a8
73 changed files with 7296 additions and 398 deletions

View File

@ -9,11 +9,10 @@ import {
} 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 {
avatarColor,
cleanSenderName,
resolveSenderEmail,
senderInitial,
} from "@/lib/sender-display"
import {
Calendar,
@ -67,7 +66,6 @@ export function ContactHoverCard({
const name = cleanSenderName(displayName)
const email = resolveSenderEmail(displayName, emailOverride)
const color = avatarColor(name)
const matchedContact = useMemo(
() => findContactByEmail(contacts, email),
@ -178,12 +176,12 @@ export function ContactHoverCard({
>
<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-bold text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}
</div>
<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>

View File

@ -1,23 +1,313 @@
"use client"
import { useMemo, useState } from "react"
import { Check, Loader2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
useAcceptEnrichmentSuggestion,
useRejectEnrichmentSuggestion,
useVisibleEnrichmentSuggestions,
} from "@/lib/api/hooks/use-contact-discovery"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import {
FIELD_LABELS,
profileDisplayName,
} from "@/lib/contacts/discovery-utils"
import type { ApiEnrichmentSuggestion } from "@/lib/contacts/discovery-types"
import type { ApiContact } from "@/lib/api/types"
import { contactApiPath } from "@/lib/contacts/contact-api-path"
import type { FullContact } from "@/lib/contacts/types"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_DISCOVERY_FIELD_ROW_CLASS,
CONTACTS_PAGE_CARD_CLASS,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import {
DiscoveryCardsMasonry,
DiscoveryCardsMasonryItem,
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
import {
DiscoveryFieldChips,
type ChipFieldItem,
} from "@/components/gmail/contacts-page/discovery-field-chips"
import { cn } from "@/lib/utils"
const ACTION_BTN_CLASS = "h-9 rounded-full px-4 text-xs sm:text-sm"
export function AddCoordinatesView() {
const { suggestions, isLoading } = useVisibleEnrichmentSuggestions()
const acceptSuggestion = useAcceptEnrichmentSuggestion()
const rejectSuggestion = useRejectEnrichmentSuggestion()
const { contacts } = useContactsList()
const updateContact = useUpdateContact()
const [acceptingGroupKey, setAcceptingGroupKey] = useState<string | null>(null)
const grouped = useMemo(() => {
const map = new Map<string, ApiEnrichmentSuggestion[]>()
for (const s of suggestions) {
const key = s.target_contact_uid ?? s.profile_id ?? s.id
const list = map.get(key) ?? []
list.push(s)
map.set(key, list)
}
return [...map.entries()]
}, [suggestions])
async function handleAccept(suggestion: ApiEnrichmentSuggestion) {
if (suggestion.suggestion_type === "enrich_contact" && suggestion.target_contact_uid) {
const contact = contacts.find((c) => c.id === suggestion.target_contact_uid)
if (contact) {
const patch = buildContactPatch(contact, suggestion)
await updateContact.mutateAsync({
path: contactApiPath(contact),
etag: contact.etag,
contact: patch,
})
}
}
await acceptSuggestion.mutateAsync(suggestion.id)
}
async function handleAcceptAll(groupKey: string, group: ApiEnrichmentSuggestion[]) {
setAcceptingGroupKey(groupKey)
try {
const first = group[0]
if (first.suggestion_type === "enrich_contact" && first.target_contact_uid) {
const contact = contacts.find((c) => c.id === first.target_contact_uid)
if (contact) {
const patch = buildMergedContactPatch(contact, group)
await updateContact.mutateAsync({
path: contactApiPath(contact),
etag: contact.etag,
contact: patch,
})
}
}
for (const s of group) {
await acceptSuggestion.mutateAsync(s.id)
}
} finally {
setAcceptingGroupKey(null)
}
}
const busy = acceptSuggestion.isPending || updateContact.isPending
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
Ajouter des coordonnées (0)
Ajouter des coordonnées ({suggestions.length})
</h3>
</div>
{isLoading && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>Chargement</p>
)}
{!isLoading && suggestions.length === 0 && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucune suggestion disponible
Aucune suggestion disponible. Analysez vos e-mails depuis « Autres contacts » pour détecter des coordonnées dans les signatures.
</p>
)}
<DiscoveryCardsMasonry>
{grouped.map(([key, group]) => (
<DiscoveryCardsMasonryItem key={key}>
<SuggestionGroup
suggestions={group}
contacts={contacts}
onAccept={handleAccept}
onAcceptAll={() => handleAcceptAll(key, group)}
onReject={(id) => rejectSuggestion.mutate(id)}
busy={busy}
acceptingAll={acceptingGroupKey === key}
/>
</DiscoveryCardsMasonryItem>
))}
</DiscoveryCardsMasonry>
</div>
)
}
function SuggestionGroup({
suggestions,
contacts,
onAccept,
onAcceptAll,
onReject,
busy,
acceptingAll,
}: {
suggestions: ApiEnrichmentSuggestion[]
contacts: FullContact[]
onAccept: (s: ApiEnrichmentSuggestion) => void
onAcceptAll: () => void
onReject: (id: string) => void
busy: boolean
acceptingAll: boolean
}) {
const first = suggestions[0]
const profile = first.profile
const existing = first.target_contact_uid
? contacts.find((c) => c.id === first.target_contact_uid)
: null
const title = existing
? `${existing.firstName} ${existing.lastName}`.trim() || existing.emails[0]?.value
: profile
? profileDisplayName(profile)
: "Contact suggéré"
const chipItems: ChipFieldItem[] = suggestions.map((s) => ({
id: s.id,
fieldKey: s.field_path,
value: s.suggested_value,
}))
return (
<div className={CONTACTS_PAGE_CARD_CLASS}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className={cn("text-sm font-medium leading-tight", CONTACTS_HEADING_TEXT)}>{title}</p>
{first.suggestion_type === "enrich_contact" && (
<p className={cn("text-[11px]", CONTACTS_MUTED_TEXT)}>
Enrichissement · {suggestions.length} champ{suggestions.length > 1 ? "s" : ""}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onAcceptAll}
disabled={busy}
className={ACTION_BTN_CLASS}
>
{acceptingAll ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Check className="mr-1.5 h-3.5 w-3.5" />
)}
Tout ajouter
</Button>
</div>
</div>
<DiscoveryFieldChips
items={chipItems}
dismissible
onRemove={(id) => onReject(id)}
/>
<details className="mt-2">
<summary className={cn("cursor-pointer text-[11px]", CONTACTS_MUTED_TEXT)}>
Gérer champ par champ
</summary>
<ul className="mt-2 space-y-1">
{suggestions.map((s) => (
<li
key={s.id}
className={CONTACTS_DISCOVERY_FIELD_ROW_CLASS}
>
<span className={cn("min-w-0 truncate text-xs", CONTACTS_HEADING_TEXT)}>
<span className={CONTACTS_MUTED_TEXT}>
{FIELD_LABELS[s.field_path] ?? s.field_path} :
</span>{" "}
{s.suggested_value}
</span>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => onReject(s.id)}
className="rounded-full p-1 text-muted-foreground hover:bg-accent"
aria-label="Ignorer"
>
<X className="h-3.5 w-3.5" />
</button>
<Button
size="sm"
onClick={() => onAccept(s)}
disabled={busy}
className="h-7 rounded-full px-2.5 text-[11px]"
>
<Check className="mr-1 h-3 w-3" />
Accepter
</Button>
</div>
</li>
))}
</ul>
</details>
</div>
)
}
function buildContactPatch(
contact: FullContact,
suggestion: ApiEnrichmentSuggestion,
): Partial<ApiContact> {
const { field_path, suggested_value } = suggestion
switch (field_path) {
case "full_name":
return { full_name: suggested_value }
case "company":
return { org: suggested_value }
case "job_title":
return { raw_vcard: appendVCardField(contact, "TITLE", suggested_value) }
case "phones":
return { phone: suggested_value }
case "emails":
return { email: suggested_value }
case "social_profiles": {
const type = (suggestion.suggested_label || "other").toLowerCase()
return {
raw_vcard: appendVCardSocialProfile(contact, type, suggested_value),
}
}
default:
return { raw_vcard: appendVCardField(contact, field_path.toUpperCase(), suggested_value) }
}
}
function buildMergedContactPatch(
contact: FullContact,
suggestions: ApiEnrichmentSuggestion[],
): Partial<ApiContact> {
const patch: Partial<ApiContact> = {}
let vcardExtra = ""
for (const s of suggestions) {
const part = buildContactPatch(contact, s)
if (part.full_name) patch.full_name = part.full_name
if (part.org) patch.org = part.org
if (part.phone) patch.phone = part.phone
if (part.email) patch.email = part.email
if (part.raw_vcard && part.raw_vcard !== patch.raw_vcard) {
vcardExtra += part.raw_vcard.replace(/^BEGIN:VCARD[\s\S]*?VERSION:3\.0\n/, "").replace(/\nEND:VCARD$/, "\n")
}
}
if (vcardExtra) {
const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n`
patch.raw_vcard = `${base}${vcardExtra}END:VCARD`
}
return patch
}
function appendVCardField(contact: FullContact, prop: string, value: string): string {
const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n`
return `${base}${prop}:${value}\nEND:VCARD`
}
function appendVCardSocialProfile(contact: FullContact, type: string, value: string): string {
const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n`
const socialType = type === "x" ? "twitter" : type
return `${base}X-SOCIALPROFILE;TYPE=${socialType}:${value}\nEND:VCARD`
}

View File

@ -0,0 +1,148 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { flushSync } from "react-dom"
import {
useAddDiscoveredContact,
useBlockedDiscoveredContacts,
useRejectDiscoveredProfile,
} from "@/lib/api/hooks/use-contact-discovery"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { profileDisplayName } from "@/lib/contacts/discovery-utils"
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
import type { FullContact } from "@/lib/contacts/types"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
} from "@/lib/contacts-chrome-classes"
import {
DiscoveryCardsMasonry,
DiscoveryCardsMasonryItem,
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
import { SuggestedContactCard } from "@/components/gmail/contacts-page/suggested-contact-card"
import { cn } from "@/lib/utils"
interface BlockedContactsViewProps {
searchQuery: string
}
function profileToGroup(profile: ApiDiscoveredProfile): ApiDiscoveredProfileGroup {
return {
group_key: profile.id,
profile_ids: [profile.id],
display_name: profileDisplayName(profile),
primary_email: profile.primary_email,
message_count: profile.message_count,
profile,
profiles: [profile],
}
}
export function BlockedContactsView({ searchQuery }: BlockedContactsViewProps) {
const { bookId } = useContactsList()
const { data: profiles = [], isLoading } = useBlockedDiscoveredContacts()
const addDiscoveredContact = useAddDiscoveredContact()
const rejectProfile = useRejectDiscoveredProfile()
const unblockSender = useBlockedSendersStore((s) => s.unblockSender)
const [removedProfileIds, setRemovedProfileIds] = useState<Set<string>>(() => new Set())
const markProfileRemoved = useCallback((profileId: string) => {
setRemovedProfileIds((prev) => {
if (prev.has(profileId)) return prev
const next = new Set(prev)
next.add(profileId)
return next
})
}, [])
const restoreProfile = useCallback((profileId: string) => {
setRemovedProfileIds((prev) => {
if (!prev.has(profileId)) return prev
const next = new Set(prev)
next.delete(profileId)
return next
})
}, [])
const filtered = useMemo(() => {
const visible = profiles.filter((p) => !removedProfileIds.has(p.id))
const q = searchQuery.trim().toLowerCase()
if (!q) return visible
return visible.filter((p) => {
const name = profileDisplayName(p).toLowerCase()
return name.includes(q) || p.primary_email.toLowerCase().includes(q)
})
}, [profiles, searchQuery, removedProfileIds])
function handleAdd(profile: ApiDiscoveredProfile, buildContact: () => FullContact) {
if (!bookId) return
flushSync(() => markProfileRemoved(profile.id))
unblockSender(profile.primary_email)
requestAnimationFrame(() => {
addDiscoveredContact.mutate(
{
bookId,
profileId: profile.id,
contact: fullContactToApiContact(buildContact()),
},
{ onError: () => restoreProfile(profile.id) },
)
})
}
function handleRemove(profile: ApiDiscoveredProfile) {
flushSync(() => markProfileRemoved(profile.id))
unblockSender(profile.primary_email)
requestAnimationFrame(() => {
rejectProfile.mutate(profile.id, { onError: () => restoreProfile(profile.id) })
})
}
const addingProfileId =
addDiscoveredContact.isPending && typeof addDiscoveredContact.variables?.profileId === "string"
? addDiscoveredContact.variables.profileId
: null
return (
<div className="px-6 py-6 text-foreground">
<h2 className={cn("mb-2 text-base font-medium", CONTACTS_HEADING_TEXT)}>Bloqués</h2>
<p className={cn("mb-6 text-sm", CONTACTS_MUTED_TEXT)}>
Ces expéditeurs sont traités comme indésirables : pas de suggestion, contenus distants
bloqués dans les e-mails. Vous pouvez les ajouter à votre carnet ou les supprimer
définitivement.
</p>
<h3 className={cn("mb-4", CONTACTS_PAGE_SECTION_TITLE_CLASS)}>
{filtered.length} expéditeur{filtered.length !== 1 ? "s" : ""}
</h3>
{isLoading && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>Chargement</p>
)}
{!isLoading && filtered.length === 0 && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucun expéditeur bloqué.
</p>
)}
<DiscoveryCardsMasonry>
{filtered.map((p) => (
<DiscoveryCardsMasonryItem key={p.id}>
<SuggestedContactCard
group={profileToGroup(p)}
mode="blocked"
addBusy={addingProfileId === p.id}
busy={rejectProfile.isPending && rejectProfile.variables === p.id}
onAdd={(buildContact) => handleAdd(p, buildContact)}
onRemove={() => handleRemove(p)}
/>
</DiscoveryCardsMasonryItem>
))}
</DiscoveryCardsMasonry>
</div>
)
}

View File

@ -42,17 +42,17 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { toast } from "sonner"
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { contactApiPath } from "@/lib/contacts/contact-api-path"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { ContactAvatarPicker } from "@/components/gmail/contacts/contact-avatar-picker"
import type { FullContact } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store"
import { cn } from "@/lib/utils"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS,
CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS,
CONTACTS_PAGE_ICON_BTN_CLASS,
CONTACTS_PAGE_SAVE_BTN_CLASS,
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
@ -103,6 +103,7 @@ const contactFormSchema = z.object({
birthday: z.object({ day: z.any().optional(), month: z.any().optional(), year: z.any().optional() }).optional(),
notes: z.string().optional().default(""),
labels: z.array(z.string()).optional().default([]),
avatarUrl: z.string().optional(),
})
type ContactFormValues = z.infer<typeof contactFormSchema>
@ -145,6 +146,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
addresses: [],
birthday: { day: undefined, month: undefined, year: undefined },
notes: "", labels: [],
avatarUrl: undefined,
},
})
@ -175,6 +177,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
birthday: existingContact.birthday ?? { day: undefined, month: undefined, year: undefined },
notes: existingContact.notes ?? "",
labels: existingContact.labels ?? [],
avatarUrl: existingContact.avatarUrl,
})
}
}, [existingContact, reset])
@ -182,6 +185,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
const firstName = watch("firstName")
const lastName = watch("lastName")
const watchedEmails = watch("emails")
const avatarUrl = watch("avatarUrl")
const currentLabels = watch("labels") ?? []
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
@ -210,6 +214,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
birthday: data.birthday?.day || data.birthday?.month || data.birthday?.year ? data.birthday : undefined,
notes: data.notes || undefined,
labels: data.labels?.length ? data.labels : undefined,
avatarUrl: data.avatarUrl || undefined,
}
if (mode === "create") {
@ -230,24 +235,43 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
onSuccess: (created) => {
onSaved(created?.uid ?? tempId)
},
onError: (err) => {
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer le contact"
toast.error(msg)
},
},
)
} else if (contactId) {
} else if (contactId && existingContact) {
const fullContact: FullContact = {
id: contactId,
path: existingContact.path,
etag: existingContact.etag,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: Date.now(),
createdAt: existingContact.createdAt,
updatedAt: Date.now(),
}
updateContactMutation.mutate({
path: contactId,
if (!existingContact.etag) {
toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.")
return
}
updateContactMutation.mutate(
{
path: contactApiPath(fullContact),
etag: existingContact.etag,
contact: fullContactToApiContact(fullContact),
})
onSaved(contactId)
},
{
onSuccess: () => onSaved(contactId),
onError: (err) => {
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications"
toast.error(msg)
},
},
)
}
}
@ -274,28 +298,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
{/* Avatar */}
<div className="mb-6 flex flex-col items-center">
{displayName ? (
<div className="relative">
<div
className="flex h-28 w-28 items-center justify-center rounded-full text-4xl font-medium text-white"
style={{ backgroundColor: avatarColor(displayName) }}
>
{senderInitial(displayName)}
</div>
<div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
<Plus className="h-4 w-4" />
</div>
</div>
) : (
<div className="relative">
<div className={CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS}>
<User className="h-12 w-12" />
</div>
<div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
<Plus className="h-4 w-4" />
</div>
</div>
)}
<ContactAvatarPicker
variant="page"
avatarUrl={avatarUrl}
displayName={displayName}
email={watchedEmails?.find((e) => e.value?.trim())?.value}
onChange={(next) => setValue("avatarUrl", next, { shouldDirty: true })}
/>
</div>
{/* Labels */}

View File

@ -1,9 +1,11 @@
"use client"
import { useState } from "react"
import {
ArrowLeft,
Download,
Pencil,
Sparkles,
Star,
Trash2,
Mail,
@ -19,8 +21,8 @@ import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store"
import { downloadContactVCard } from "@/lib/contacts/export-contacts"
import {
@ -34,6 +36,9 @@ import {
CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
import { useLLMSettings } from "@/lib/api/hooks/use-contact-discovery"
import { isLLMConfigured } from "@/lib/contacts/llm-settings-utils"
import { ContactImproveDialog } from "@/components/gmail/contacts-page/contact-improve-dialog"
const FRENCH_MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
@ -59,7 +64,10 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const deleteContactMutation = useDeleteContact()
const labelRows = useNavStore((s) => s.labelRows)
const { data: llmSettings } = useLLMSettings()
const [improveOpen, setImproveOpen] = useState(false)
const contact = contacts.find((c) => c.id === contactId)
const llmReady = isLLMConfigured(llmSettings)
if (!contact) {
return (
@ -71,8 +79,6 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
const color = avatarColor(name)
const initial = senderInitial(name)
const primaryEmail = contact.emails[0]?.value
function handleDelete() {
@ -93,6 +99,22 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="mr-1 hidden rounded-full sm:inline-flex"
onClick={() => setImproveOpen(true)}
disabled={!llmReady}
title={
llmReady
? undefined
: "Configurez un fournisseur LLM dans les réglages contacts"
}
>
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
Amélioration IA
</Button>
<Button variant="ghost" size="icon" className={CONTACTS_PAGE_ICON_BTN_CLASS}>
<Star className="h-5 w-5" />
</Button>
@ -125,16 +147,7 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
</div>
<div className="flex items-center gap-6 pb-6">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-24 w-24 rounded-full object-cover" />
) : (
<div
className="flex h-24 w-24 items-center justify-center rounded-full text-3xl font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
)}
<ContactAvatar contact={contact} name={name} size="xl" />
<div>
<h1 className={cn("text-3xl", CONTACTS_HEADING_TEXT)}>{name}</h1>
{contact.company && (
@ -159,6 +172,25 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
</div>
</div>
<div className="mb-4 sm:hidden">
<Button
type="button"
variant="outline"
size="sm"
className="w-full rounded-full"
onClick={() => setImproveOpen(true)}
disabled={!llmReady}
title={
llmReady
? undefined
: "Configurez un fournisseur LLM dans les réglages contacts"
}
>
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
Amélioration IA
</Button>
</div>
{primaryEmail && (
<div className={cn("flex items-center gap-2 py-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
<button type="button" className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}>
@ -238,6 +270,12 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
</DetailRow>
)}
</div>
<ContactImproveDialog
contact={contact}
open={improveOpen}
onOpenChange={setImproveOpen}
/>
</div>
)
}

View File

@ -0,0 +1,139 @@
"use client"
import { useEffect, useState } from "react"
import { Loader2, Sparkles } from "lucide-react"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { useImproveContact } from "@/lib/api/hooks/use-improve-contact"
import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import type { ApiEnrichedContactData } from "@/lib/contacts/discovery-types"
import {
fullContactToImprovePayload,
improvedDataToDraftFields,
mergeImprovedContact,
} from "@/lib/contacts/improve-contact"
import { contactApiPath } from "@/lib/contacts/contact-api-path"
import type { FullContact } from "@/lib/contacts/types"
import { DiscoveryFieldChips } from "@/components/gmail/contacts-page/discovery-field-chips"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ContactImproveDialogProps {
contact: FullContact
open: boolean
onOpenChange: (open: boolean) => void
}
export function ContactImproveDialog({ contact, open, onOpenChange }: ContactImproveDialogProps) {
const improveMutation = useImproveContact()
const updateMutation = useUpdateContact()
const [improved, setImproved] = useState<ApiEnrichedContactData | null>(null)
useEffect(() => {
if (!open) {
setImproved(null)
improveMutation.reset()
return
}
const payload = fullContactToImprovePayload(contact)
improveMutation.mutate(payload, {
onSuccess: (data) => setImproved(data),
onError: (err) => {
const msg = err instanceof Error ? err.message : "Échec de l'amélioration IA"
toast.error(msg)
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- relancer à l'ouverture pour ce contact
}, [open, contact.id])
const loading = improveMutation.isPending
const chipItems = improved ? improvedDataToDraftFields(improved) : []
function handleApply() {
if (!improved) return
const merged = mergeImprovedContact(contact, improved)
const path = contactApiPath(contact)
if (!contact.etag) {
toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.")
return
}
updateMutation.mutate(
{ path, etag: contact.etag, contact: fullContactToApiContact(merged) },
{
onSuccess: () => {
toast.success("Contact mis à jour")
onOpenChange(false)
},
onError: (err) => {
const msg =
err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications"
toast.error(msg)
},
},
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-500" />
Amélioration IA
</DialogTitle>
</DialogHeader>
{loading && (
<div className={cn("flex items-center justify-center gap-2 py-8 text-sm", CONTACTS_MUTED_TEXT)}>
<Loader2 className="h-4 w-4 animate-spin" />
Analyse et nettoyage en cours
</div>
)}
{!loading && improveMutation.isError && (
<p className={cn("py-4 text-sm", CONTACTS_MUTED_TEXT)}>
L&apos;amélioration a échoué. Vérifiez la configuration LLM dans les réglages.
</p>
)}
{!loading && improved && (
<div className="space-y-3">
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>
Aperçu des informations nettoyées et réorganisées. Aucune donnée n&apos;est
enregistrée tant que vous n&apos;appliquez pas les changements.
</p>
<DiscoveryFieldChips items={chipItems} editable={false} />
</div>
)}
<DialogFooter className="gap-3 sm:gap-3">
<Button type="button" variant="link" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
type="button"
disabled={!improved || loading || updateMutation.isPending}
onClick={handleApply}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{updateMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : null}
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,162 @@
"use client"
import { useLayoutEffect, useRef, type ComponentType, type ReactNode } from "react"
import { Check, Minus, Plus } from "lucide-react"
import { Icon } from "@iconify/react"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
import { LabelPickerLeadingVisual } from "@/components/gmail/email-label-picker-block"
export type ContactLabelPresence = "none" | "some" | "all"
export type ContactLabelPickerItemComponent = ComponentType<{
children: ReactNode
onSelect?: (event: Event) => void
className?: string
}>
function LabelPickerCheckboxVisual({
checked,
}: {
checked: boolean | "indeterminate"
}) {
return (
<span
aria-hidden
className={cn(
"pointer-events-none inline-flex size-4 shrink-0 items-center justify-center rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-transparent",
checked === true && "border-[#0b57d0] bg-[#0b57d0] text-white",
checked === "indeterminate" && "border-[#0b57d0] bg-[#0b57d0] text-white",
)}
>
{checked === true ? (
<Check className="size-3 stroke-[2.5] text-white" />
) : checked === "indeterminate" ? (
<Minus className="size-3 stroke-[2.5] text-white" />
) : null}
</span>
)
}
export function ContactLabelPickerBlock({
query,
onQueryChange,
labelRows,
resolveLabelVisual,
Item,
getLabelPresence,
onToggleLabel,
onCreateLabel,
listClassName,
searchAutoFocus = true,
}: {
query: string
onQueryChange: (v: string) => void
labelRows: LabelRowItem[]
resolveLabelVisual: (labelId: string) => LabelPickerVisual
Item: ContactLabelPickerItemComponent
getLabelPresence: (labelId: string) => ContactLabelPresence
onToggleLabel: (labelId: string) => void
onCreateLabel: (labelText: string) => void
listClassName?: string
searchAutoFocus?: boolean
}) {
const searchInputRef = useRef<HTMLInputElement>(null)
useLayoutEffect(() => {
if (!searchAutoFocus) return
let inner = 0
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => {
searchInputRef.current?.focus({ preventScroll: true })
})
})
return () => {
cancelAnimationFrame(outer)
if (inner) cancelAnimationFrame(inner)
}
}, [searchAutoFocus])
const q = query.trim().toLowerCase()
const available = labelRows.filter((r) => r.enabled !== false)
const filtered = available.filter(
(row) => q.length === 0 || row.label.toLowerCase().includes(q),
)
const trimmed = query.trim()
const hasExact = available.some(
(row) => row.label.toLowerCase() === trimmed.toLowerCase(),
)
const canCreate = trimmed.length > 0 && !hasExact
return (
<>
<div
className="shrink-0 border-b border-[#eceff1] p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Rechercher ou créer un libellé…"
aria-label="Rechercher ou créer un libellé"
className="h-8 border-[#dadce0] text-sm shadow-none"
autoComplete="off"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
<div className={cn("min-h-0 overflow-y-auto py-1", listClassName ?? "max-h-52")}>
{canCreate ? (
<Item
onSelect={(e) => {
e.preventDefault()
onCreateLabel(trimmed)
}}
>
<Plus className="size-[18px] shrink-0 text-[#0b57d0]" strokeWidth={1.5} />
<span className="min-w-0 flex-1 text-[#0b57d0]">
Créer le libellé « {trimmed} »
</span>
</Item>
) : null}
{filtered.map((row) => {
const presence = getLabelPresence(row.id)
const boxChecked: boolean | "indeterminate" =
presence === "all" ? true : presence === "some" ? "indeterminate" : false
return (
<Item
key={row.id}
onSelect={(e) => {
e.preventDefault()
onToggleLabel(row.id)
}}
>
<LabelPickerCheckboxVisual checked={boxChecked} />
{row.icon ? (
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<Icon
icon={row.icon}
className="size-[18px] shrink-0 text-[#5f6368]"
aria-hidden
/>
</span>
) : (
<LabelPickerLeadingVisual visual={resolveLabelVisual(row.id)} />
)}
<span className="min-w-0 flex-1 truncate">{row.label}</span>
</Item>
)
})}
{filtered.length === 0 && !canCreate ? (
<div className="px-3 py-2 text-sm text-[#5f6368]">
Aucun libellé correspondant
</div>
) : null}
</div>
</>
)
}

View File

@ -9,6 +9,9 @@ import { ContactsTable } from "./contacts-table"
import { ContactDetailPage } from "./contact-detail-page"
import { ContactCreatePage } from "./contact-create-page"
import { MergeDuplicatesView } from "./merge-duplicates-view"
import { OtherContactsView } from "./other-contacts-view"
import { IgnoredContactsView } from "./ignored-contacts-view"
import { BlockedContactsView } from "./blocked-contacts-view"
import { TrashView } from "./trash-view"
import { BulkCreateDialog } from "./bulk-create-dialog"
import { ImportDialog } from "./import-dialog"
@ -18,6 +21,8 @@ export type ContactsPageView =
| "contacts"
| "frequent"
| "other"
| "ignored"
| "blocked"
| "merge"
| "import"
| "trash"
@ -139,7 +144,6 @@ export function ContactsAppShell() {
<main className="min-h-0 flex-1 overflow-y-auto">
{(currentView === "contacts" ||
currentView === "frequent" ||
currentView === "other" ||
currentView === "label") && (
<ContactsTable
view={currentView}
@ -148,6 +152,15 @@ export function ContactsAppShell() {
onOpenContact={openContact}
/>
)}
{currentView === "other" && (
<OtherContactsView searchQuery={searchQuery} />
)}
{currentView === "ignored" && (
<IgnoredContactsView searchQuery={searchQuery} />
)}
{currentView === "blocked" && (
<BlockedContactsView searchQuery={searchQuery} />
)}
{currentView === "detail" && activeContactId && (
<ContactDetailPage
contactId={activeContactId}

View File

@ -0,0 +1,191 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { FullContact } from "@/lib/contacts/types"
import {
CONTACT_BULK_EDIT_FIELDS,
collectBulkFieldSuggestions,
getContactBulkFieldValue,
type ContactBulkEditField,
} from "@/lib/contacts/bulk-edit-fields"
import {
CONTACTS_FIELD_CLASS,
CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ContactsBulkEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
contacts: FullContact[]
onApply: (field: ContactBulkEditField, value: string) => void
isApplying?: boolean
}
export function ContactsBulkEditDialog({
open,
onOpenChange,
contacts,
onApply,
isApplying = false,
}: ContactsBulkEditDialogProps) {
const [field, setField] = useState<ContactBulkEditField>("company")
const [value, setValue] = useState("")
const suggestions = useMemo(
() => collectBulkFieldSuggestions(contacts, field),
[contacts, field],
)
const mixedValues = useMemo(() => {
const values = new Set(
contacts.map((c) => getContactBulkFieldValue(c, field)).filter(Boolean),
)
return values.size > 1
}, [contacts, field])
useEffect(() => {
if (!open) {
setField("company")
setValue("")
return
}
if (suggestions.length === 1) {
setValue(suggestions[0]!)
} else {
setValue("")
}
}, [open, field, suggestions])
const isNotes = field === "notes"
const canApply = contacts.length > 0
function handleApply() {
if (!canApply) return
onApply(field, value)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Édition de masse</DialogTitle>
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>
{contacts.length} contact{contacts.length > 1 ? "s" : ""} sélectionné
{contacts.length > 1 ? "s" : ""}
</p>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Propriété
</label>
<Select
value={field}
onValueChange={(v) => setField(v as ContactBulkEditField)}
>
<SelectTrigger className={CONTACTS_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTACT_BULK_EDIT_FIELDS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Nouvelle valeur
</label>
{isNotes ? (
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
mixedValues ? "Valeurs mixtes dans la sélection…" : "Notes…"
}
className={cn(CONTACTS_FIELD_CLASS, "min-h-24 resize-y")}
/>
) : (
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
mixedValues
? "Valeurs mixtes dans la sélection…"
: `Nouvelle valeur…`
}
className={CONTACTS_FIELD_CLASS}
/>
)}
</div>
{suggestions.length > 0 && (
<div className="space-y-2">
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
Valeurs présentes dans la sélection
</p>
<div className="flex flex-wrap gap-1.5">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => setValue(suggestion)}
className={cn(
"max-w-full truncate rounded-full border border-border px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted",
value === suggestion && "border-primary bg-primary/10",
)}
>
{suggestion}
</button>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button
type="button"
className={CONTACTS_PRIMARY_BTN_CLASS}
disabled={!canApply || isApplying}
onClick={handleApply}
>
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,136 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { mergeManyContacts, pickContactMergePrimary } from "@/lib/contacts/merge-contacts"
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ContactsBulkMergeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
contacts: FullContact[]
onMerge: (primaryId: string) => void
isMerging?: boolean
}
function contactListLabel(contact: FullContact): string {
return (
fullContactDisplayName(contact) ||
contact.emails[0]?.value ||
contact.phones[0]?.value ||
"Contact sans nom"
)
}
export function ContactsBulkMergeDialog({
open,
onOpenChange,
contacts,
onMerge,
isMerging = false,
}: ContactsBulkMergeDialogProps) {
const defaultPrimary = useMemo(() => pickContactMergePrimary(contacts), [contacts])
const [primaryId, setPrimaryId] = useState(defaultPrimary?.id ?? "")
useEffect(() => {
if (!open) return
setPrimaryId(defaultPrimary?.id ?? contacts[0]?.id ?? "")
}, [open, defaultPrimary, contacts])
const preview = useMemo(
() => (primaryId ? mergeManyContacts(contacts, primaryId) : null),
[contacts, primaryId],
)
const canMerge = contacts.length >= 2 && !!primaryId && !!preview?.primary.etag
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Fusionner les contacts</DialogTitle>
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>
{contacts.length} contacts sélectionnés 1 contact. Les autres seront
supprimés après fusion.
</p>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">Contact principal</p>
<RadioGroup value={primaryId} onValueChange={setPrimaryId} className="gap-2">
{contacts.map((contact) => {
const label = contactListLabel(contact)
return (
<label
key={contact.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border border-border px-3 py-2 transition-colors hover:bg-muted/50",
primaryId === contact.id && "border-primary bg-primary/5",
)}
>
<RadioGroupItem value={contact.id} id={`merge-primary-${contact.id}`} />
<ContactAvatar contact={contact} size="xs" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{label}</span>
{contact.emails[0]?.value && (
<span className="block truncate text-xs text-muted-foreground">
{contact.emails[0].value}
</span>
)}
</span>
</label>
)
})}
</RadioGroup>
{preview && (
<div className={cn("rounded-lg border border-border px-3 py-2 text-sm", CONTACTS_MUTED_TEXT)}>
<p className="font-medium text-foreground">Résultat fusionné</p>
<ul className="mt-1.5 space-y-0.5">
<li>{preview.merged.emails.length} e-mail{preview.merged.emails.length > 1 ? "s" : ""}</li>
<li>{preview.merged.phones.length} téléphone{preview.merged.phones.length > 1 ? "s" : ""}</li>
{(preview.merged.labels?.length ?? 0) > 0 && (
<li>{preview.merged.labels!.length} libellé{(preview.merged.labels!.length > 1 ? "s" : "")}</li>
)}
</ul>
</div>
)}
{!preview?.primary.etag && (
<p className="text-sm text-destructive">
Version du contact principal inconnue. Rechargez la liste avant de fusionner.
</p>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
type="button"
className={CONTACTS_PRIMARY_BTN_CLASS}
disabled={!canMerge || isMerging}
onClick={() => onMerge(primaryId)}
>
Fusionner
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -61,7 +61,10 @@ export function ContactsHeader({
</div>
</div>
<HeaderAccountActions className="shrink-0 pl-1 sm:pl-4" />
<HeaderAccountActions
className="shrink-0 pl-1 sm:pl-4"
settingsHref="/mail/settings/accounts"
/>
</header>
)
}

View File

@ -1,11 +1,13 @@
"use client"
import { useMemo, useState } from "react"
import { useDeferredValue, useMemo, useState } from "react"
import {
Users,
Clock,
UserPlus,
Ban,
EyeOff,
Merge,
Upload,
Trash2,
@ -35,6 +37,7 @@ import {
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useDiscoveryCounts, useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
import { useNavStore } from "@/lib/stores/nav-store"
import type { ContactsPageView } from "./contacts-app-shell"
@ -67,11 +70,19 @@ export function ContactsSidebar({
onSelectLabel,
}: ContactsSidebarProps) {
const { contacts } = useContactsList()
const deferredContacts = useDeferredValue(contacts)
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
const mergeSuggestionCount = useMemo(
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)).length,
[contacts, ignoredMergePairs]
)
const { data: discoveryCounts } = useDiscoveryCounts()
const mergeSuggestionCount = useMemo(() => {
if (deferredContacts.length < 2) return 0
return findDuplicatePairs(deferredContacts, new Set(ignoredMergePairs)).length
}, [deferredContacts, ignoredMergePairs])
const { suggestions: coordinateSuggestions } = useVisibleEnrichmentSuggestions()
const otherContactsCount = discoveryCounts?.other_contacts ?? 0
const ignoredCount = discoveryCounts?.ignored ?? 0
const blockedCount = discoveryCounts?.blocked ?? 0
const coordinatesCount = coordinateSuggestions.length
const mergeBadgeCount = mergeSuggestionCount + coordinatesCount
const labelRows = useNavStore((s) => s.labelRows)
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
const [labelInput, setLabelInput] = useState("")
@ -189,9 +200,24 @@ export function ContactsSidebar({
<NavItem
icon={<UserPlus className="h-5 w-5" />}
label="Autres contacts"
badge={otherContactsCount > 0 ? otherContactsCount : undefined}
active={currentView === "other"}
onClick={() => onNavigate("other")}
/>
<NavItem
icon={<Ban className="h-5 w-5" />}
label="Bloqués"
count={blockedCount > 0 ? blockedCount : undefined}
active={currentView === "blocked"}
onClick={() => onNavigate("blocked")}
/>
<NavItem
icon={<EyeOff className="h-5 w-5" />}
label="Ignorés"
count={ignoredCount > 0 ? ignoredCount : undefined}
active={currentView === "ignored"}
onClick={() => onNavigate("ignored")}
/>
<div className="my-2 border-t border-border" />
@ -200,7 +226,7 @@ export function ContactsSidebar({
<NavItem
icon={<Merge className="h-5 w-5" />}
label="Fusionner et corriger"
badge={mergeSuggestionCount > 0 ? mergeSuggestionCount : undefined}
badge={mergeBadgeCount > 0 ? mergeBadgeCount : undefined}
active={currentView === "merge"}
onClick={() => onNavigate("merge")}
/>

View File

@ -1,24 +1,28 @@
"use client"
import { useEffect, useMemo, useState, type CSSProperties } from "react"
import { Printer, Download, MoreVertical, Trash2 } from "lucide-react"
import { useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from "react"
import { Printer, Download, MoreVertical, Trash2, Tag, Ban, Pencil, GitMerge } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
import { useDeleteContact, useMergeManyContacts } from "@/lib/api/hooks/use-contact-mutations"
import { useNavStore } from "@/lib/stores/nav-store"
import { searchContacts } from "@/lib/contacts/fuzzy-search"
import { printContacts } from "@/lib/contacts/print-contacts"
import { downloadContactsCsv, downloadContactsVCard } from "@/lib/contacts/export-contacts"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import type { FullContact } from "@/lib/contacts/types"
import {
contactsTableGridStyle,
@ -33,9 +37,18 @@ import {
CONTACTS_MUTED_TEXT,
CONTACTS_TABLE_HEADER_CLASS,
CONTACTS_TABLE_ROW_CLASS,
CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS,
CONTACTS_TABLE_TOOLBAR_CLASS,
CONTACTS_TABLE_STICKY_HEAD_CLASS,
} from "@/lib/contacts-chrome-classes"
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state"
import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block"
import { ContactsBulkEditDialog } from "@/components/gmail/contacts-page/contacts-bulk-edit-dialog"
import { ContactsBulkMergeDialog } from "@/components/gmail/contacts-page/contacts-bulk-merge-dialog"
import { useContactBulkActions } from "@/components/gmail/contacts-page/use-contact-bulk-actions"
import { toast } from "sonner"
const DATA_COLUMNS: Exclude<ContactsTableColumn, "checkbox">[] = [
"name",
@ -45,6 +58,9 @@ const DATA_COLUMNS: Exclude<ContactsTableColumn, "checkbox">[] = [
"labels",
]
const CONTACTS_ROW_CHECKBOX_HIT_CLASS =
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-muted/60 -m-1"
interface ContactsTableProps {
view: ContactsPageView
searchQuery: string
@ -55,10 +71,24 @@ interface ContactsTableProps {
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
const { visibleColumns, columnLabels } = useContactsTableColumns()
const gridStyle = contactsTableGridStyle(visibleColumns)
const { contacts } = useContactsList()
const { contacts, bookId, isLoading, isError, error, refetch } = useContactsList()
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const deleteContactMutation = useDeleteContact()
const mergeManyContactsMutation = useMergeManyContacts()
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const [labelPickerQuery, setLabelPickerQuery] = useState("")
const [bulkEditOpen, setBulkEditOpen] = useState(false)
const [bulkMergeOpen, setBulkMergeOpen] = useState(false)
const lastSelectionAnchorIdRef = useRef<string | null>(null)
const {
getLabelPresence,
toggleLabelOnContacts,
createAndApplyLabel,
blockContacts,
applyBulkField,
resolveLabelVisualById,
isUpdating,
} = useContactBulkActions()
const filteredContacts = useMemo(() => {
let list = contacts
@ -91,6 +121,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
[filteredContacts]
)
const filteredContactIds = useMemo(
() => filteredContacts.map((c) => c.id),
[filteredContacts],
)
const selectedContacts = useMemo(
() => filteredContacts.filter((c) => selectedIds.has(c.id)),
[filteredContacts, selectedIds]
@ -105,6 +140,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
useEffect(() => {
setSelectedIds(new Set())
lastSelectionAnchorIdRef.current = null
}, [view, activeLabelId])
useEffect(() => {
@ -115,6 +151,10 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
}, [filteredIds])
const labelRows = useNavStore((s) => s.labelRows)
const availableLabelRows = useMemo(
() => labelRows.filter((r) => r.enabled !== false),
[labelRows],
)
const activeLabelName = activeLabelId
? labelRows.find((l) => l.id === activeLabelId)?.label
: null
@ -134,13 +174,69 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
else next.delete(id)
return next
})
lastSelectionAnchorIdRef.current = id
}
function toggleContactInSelection(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
lastSelectionAnchorIdRef.current = id
}
function selectRangeInclusive(fromId: string, toId: string) {
const i0 = filteredContactIds.indexOf(fromId)
const i1 = filteredContactIds.indexOf(toId)
if (i0 === -1 || i1 === -1) return
const lo = Math.min(i0, i1)
const hi = Math.max(i0, i1)
setSelectedIds(new Set(filteredContactIds.slice(lo, hi + 1)))
}
function handleShiftSelection(id: string) {
const anchor = lastSelectionAnchorIdRef.current
if (anchor == null) {
setSelectedIds(new Set([id]))
} else {
selectRangeInclusive(anchor, id)
}
lastSelectionAnchorIdRef.current = id
}
function handleContactRowClick(
id: string,
e: Pick<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey" | "preventDefault">,
) {
if (e.shiftKey) {
e.preventDefault()
handleShiftSelection(id)
return
}
if (e.metaKey || e.ctrlKey) {
e.preventDefault()
toggleContactInSelection(id)
return
}
onOpenContact(id)
}
function handleContactCheckboxClickCapture(id: string, e: MouseEvent) {
if (!e.shiftKey) return
e.preventDefault()
e.stopPropagation()
handleShiftSelection(id)
}
function toggleSelectAll(checked: boolean) {
if (checked) {
setSelectedIds(new Set(filteredContacts.map((c) => c.id)))
lastSelectionAnchorIdRef.current = filteredContacts[0]?.id ?? null
} else {
setSelectedIds(new Set())
lastSelectionAnchorIdRef.current = null
}
}
@ -153,6 +249,36 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
setSelectedIds(new Set())
}
function handleMergeSelected(primaryId: string) {
if (!bookId) {
toast.error("Carnet de contacts introuvable")
return
}
if (selectedContacts.length < 2) return
mergeManyContactsMutation.mutate(
{
bookId,
contacts: selectedContacts,
primaryId,
},
{
onSuccess: () => {
toast.success("Contacts fusionnés")
setBulkMergeOpen(false)
setSelectedIds(new Set())
},
onError: (err) => {
const msg =
err instanceof Error && err.message
? err.message
: "Impossible de fusionner ces contacts"
toast.error(msg)
},
},
)
}
function handleExportVcf() {
if (selectionCount === 0) return
const filename =
@ -173,16 +299,93 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
return (
<div className="px-3 py-4 sm:px-6">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<h1 className={cn("truncate text-xl font-normal sm:text-2xl", CONTACTS_HEADING_TEXT)}>{viewTitle}</h1>
<div className={CONTACTS_TABLE_STICKY_HEAD_CLASS}>
<div className={CONTACTS_TABLE_TOOLBAR_CLASS}>
<h1 className={cn("min-w-0 flex-1 truncate text-xl font-normal sm:text-2xl", CONTACTS_HEADING_TEXT)}>
{viewTitle}
</h1>
<div className="flex shrink-0 items-center gap-1">
{selectionCount > 0 && (
<p className={cn("mt-0.5 text-sm", CONTACTS_MUTED_TEXT)}>
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
</p>
<span
aria-live="polite"
className={cn(
"mr-1 hidden text-sm whitespace-nowrap sm:inline",
CONTACTS_MUTED_TEXT,
)}
</div>
<div className="flex items-center gap-1">
>
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
</span>
)}
{selectionCount > 0 && (
<>
<Button
type="button"
variant="ghost"
size="sm"
className={cn("hidden h-9 rounded-full px-3 sm:inline-flex", CONTACTS_ICON_BTN_CLASS)}
onClick={() => setBulkEditOpen(true)}
>
<Pencil className="mr-1.5 h-4 w-4" />
Édition de masse
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={cn("hidden h-9 rounded-full px-3 sm:inline-flex", CONTACTS_ICON_BTN_CLASS)}
disabled={selectionCount < 2}
onClick={() => setBulkMergeOpen(true)}
>
<GitMerge className="mr-1.5 h-4 w-4" />
Fusionner
</Button>
<DropdownMenu
onOpenChange={(open) => {
if (!open) setLabelPickerQuery("")
}}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn("h-9 w-9 rounded-full", CONTACTS_ICON_BTN_CLASS)}
aria-label="Ajouter ou retirer des libellés"
>
<Tag className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
)}
>
<ContactLabelPickerBlock
query={labelPickerQuery}
onQueryChange={setLabelPickerQuery}
labelRows={availableLabelRows}
resolveLabelVisual={resolveLabelVisualById}
Item={DropdownMenuItem}
getLabelPresence={(labelId) =>
getLabelPresence(selectedContacts, labelId)
}
onToggleLabel={(labelId) =>
toggleLabelOnContacts(selectedContacts, labelId)
}
onCreateLabel={(labelText) => {
createAndApplyLabel(selectedContacts, labelText)
setLabelPickerQuery("")
}}
/>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<Button
type="button"
variant="ghost"
@ -241,7 +444,57 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
<MoreVertical className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-48", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
<DropdownMenuContent align="end" className={cn("w-56 overflow-visible", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
{selectionCount > 0 && (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
<Tag className="mr-2 h-4 w-4 text-muted-foreground" />
Ajouter / Retirer des libellés
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className={cn(
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
)}
>
<ContactLabelPickerBlock
query={labelPickerQuery}
onQueryChange={setLabelPickerQuery}
labelRows={availableLabelRows}
resolveLabelVisual={resolveLabelVisualById}
Item={DropdownMenuItem}
getLabelPresence={(labelId) =>
getLabelPresence(selectedContacts, labelId)
}
onToggleLabel={(labelId) =>
toggleLabelOnContacts(selectedContacts, labelId)
}
onCreateLabel={(labelText) => {
createAndApplyLabel(selectedContacts, labelText)
setLabelPickerQuery("")
}}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={() => setBulkEditOpen(true)}>
<Pencil className="mr-2 h-4 w-4 text-muted-foreground" />
Édition de masse
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setBulkMergeOpen(true)}
disabled={selectionCount < 2}
>
<GitMerge className="mr-2 h-4 w-4 text-muted-foreground" />
Fusionner
</DropdownMenuItem>
<DropdownMenuItem onClick={() => blockContacts(selectedContacts)}>
<Ban className="mr-2 h-4 w-4 text-muted-foreground" />
Bloquer
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => toggleSelectAll(true)}
disabled={filteredContacts.length === 0}
@ -264,7 +517,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
style={gridStyle}
>
{isContactsColumnVisible(visibleColumns, "checkbox") && (
<span className="flex items-center justify-center">
<span className={CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS}>
<Checkbox
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
@ -274,10 +527,13 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
)}
{DATA_COLUMNS.map((column) =>
isContactsColumnVisible(visibleColumns, column) ? (
<span key={column}>{columnLabels[column]}</span>
<span key={column} className="flex min-h-8 items-center">
{columnLabels[column]}
</span>
) : null
)}
</div>
</div>
{filteredContacts.map((contact) => (
<ContactTableRow
@ -287,15 +543,41 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
gridStyle={gridStyle}
selected={selectedIds.has(contact.id)}
onToggleSelect={(checked) => toggleContact(contact.id, checked)}
onOpen={() => onOpenContact(contact.id)}
onRowClick={(e) => handleContactRowClick(contact.id, e)}
onCheckboxClickCapture={(e) => handleContactCheckboxClickCapture(contact.id, e)}
/>
))}
{filteredContacts.length === 0 && (
{(isLoading || isError) && (
<ContactsLoadState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
/>
)}
{!isLoading && !isError && filteredContacts.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
Aucun contact trouvé
</div>
)}
<ContactsBulkEditDialog
open={bulkEditOpen}
onOpenChange={setBulkEditOpen}
contacts={selectedContacts}
onApply={(field, value) => applyBulkField(selectedContacts, field, value)}
isApplying={isUpdating}
/>
<ContactsBulkMergeDialog
open={bulkMergeOpen}
onOpenChange={setBulkMergeOpen}
contacts={selectedContacts}
onMerge={handleMergeSelected}
isMerging={mergeManyContactsMutation.isPending}
/>
</div>
)
}
@ -311,42 +593,43 @@ function ContactTableRow({
gridStyle,
selected,
onToggleSelect,
onOpen,
onRowClick,
onCheckboxClickCapture,
}: {
contact: FullContact
visibleColumns: ContactsTableColumn[]
gridStyle: CSSProperties
selected: boolean
onToggleSelect: (checked: boolean) => void
onOpen: () => void
onRowClick: (e: Pick<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey" | "preventDefault">) => void
onCheckboxClickCapture: (e: MouseEvent<HTMLSpanElement>) => void
}) {
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
const color = avatarColor(name)
const initial = senderInitial(name)
const labelRows = useNavStore((s) => s.labelRows)
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onClick={onRowClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key !== "Enter" && e.key !== " ") return
e.preventDefault()
onOpen()
}
onRowClick(e)
}}
className={cn(
CONTACTS_TABLE_ROW_CLASS,
selected && "bg-mail-nav-selected"
"cursor-pointer",
selected && "bg-mail-nav-selected",
)}
style={gridStyle}
>
{isContactsColumnVisible(visibleColumns, "checkbox") && (
<span
className="flex items-center justify-center"
className={CONTACTS_ROW_CHECKBOX_HIT_CLASS}
onClick={(e) => e.stopPropagation()}
onClickCapture={onCheckboxClickCapture}
onKeyDown={(e) => e.stopPropagation()}
>
<Checkbox
@ -359,16 +642,7 @@ function ContactTableRow({
{isContactsColumnVisible(visibleColumns, "name") && (
<span className="flex min-w-0 items-center gap-2 sm:gap-3">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 shrink-0 rounded-full object-cover" />
) : (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</span>
)}
<ContactAvatar contact={contact} name={name} size="xs" />
<span className="min-w-0 flex-1">
<span className="block truncate text-foreground">{name}</span>
{!isContactsColumnVisible(visibleColumns, "email") && contact.emails[0]?.value && (

View File

@ -0,0 +1,103 @@
"use client"
import {
Children,
isValidElement,
useMemo,
type ReactNode,
type RefObject,
} from "react"
import { useAutoAnimate } from "@formkit/auto-animate/react"
import {
CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_ENTER_CLASS,
CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS,
CONTACTS_DISCOVERY_CARD_MASONRY_SENTINEL_CLASS,
} from "@/lib/contacts-chrome-classes"
import { useEnteringItemKeys } from "@/components/gmail/contacts-page/use-entering-item-keys"
import { cn } from "@/lib/utils"
interface DiscoveryCardsMasonryProps {
children: ReactNode
className?: string
footer?: ReactNode
animateEnter?: boolean
}
function childKey(child: ReactNode, index: number): string {
if (isValidElement(child) && child.key != null) {
return String(child.key)
}
return String(index)
}
/** Une seule liste DOM — grille responsive 1 col / 2 cols lg+. */
export function DiscoveryCardsMasonry({
children,
className,
footer,
animateEnter = true,
}: DiscoveryCardsMasonryProps) {
const items = Children.toArray(children)
const enteringKeys = useEnteringItemKeys(items)
const [gridRef] = useAutoAnimate({ duration: 140, easing: "ease-out" })
const renderedItems = useMemo(
() =>
items.map((child, index) => {
const key = childKey(child, index)
return (
<div
key={key}
className={cn(
"min-w-0",
animateEnter && enteringKeys.has(key) && CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_ENTER_CLASS,
)}
>
{child}
</div>
)
}),
[items, enteringKeys, animateEnter],
)
return (
<div className={cn(CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS, className)}>
<div
ref={gridRef}
className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2"
>
{renderedItems}
</div>
{footer}
</div>
)
}
export function DiscoveryCardsMasonryItem({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return <div className={cn("min-w-0", className)}>{children}</div>
}
export function DiscoveryCardsMasonrySentinel({
children,
className,
sentinelRef,
}: {
children?: ReactNode
className?: string
sentinelRef?: RefObject<HTMLDivElement | null>
}) {
return (
<div
ref={sentinelRef}
className={cn(CONTACTS_DISCOVERY_CARD_MASONRY_SENTINEL_CLASS, className)}
>
{children}
</div>
)
}

View File

@ -0,0 +1,220 @@
"use client"
import { useMemo, useState } from "react"
import { Pencil, X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { FIELD_LABELS } from "@/lib/contacts/discovery-utils"
import {
CONTACTS_DISCOVERY_CHIP_CLASS,
CONTACTS_DISCOVERY_GRID_CELL_CLASS,
CONTACTS_MUTED_TEXT,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
export interface ChipFieldItem {
id: string
fieldKey: string
value: string
removed?: boolean
}
const FIELD_GROUP_ORDER = [
"first_name",
"last_name",
"full_name",
"company",
"department",
"job_title",
"emails",
"phones",
"addresses",
"website",
"social_profiles",
"notes",
] as const
function fieldSortIndex(fieldKey: string): number {
const i = FIELD_GROUP_ORDER.indexOf(fieldKey as (typeof FIELD_GROUP_ORDER)[number])
return i === -1 ? 999 : i
}
function sortFieldKeys(keys: string[]): string[] {
return [...keys].sort(
(a, b) => fieldSortIndex(a) - fieldSortIndex(b) || a.localeCompare(b, "fr"),
)
}
function chipAccessibleLabel(fieldLabel: string, value: string): string {
const v = value.trim()
return v ? `${fieldLabel} : ${v}` : fieldLabel
}
function chipValueDedupeKey(fieldKey: string, value: string): string {
const v = value.trim()
if (fieldKey === "emails") return `emails:${v.toLowerCase()}`
if (fieldKey === "phones") {
const digits = v.replace(/\D/g, "")
return digits.length >= 6 ? `phones:${digits}` : `phones:${v.toLowerCase()}`
}
if (fieldKey === "addresses") return `addresses:${v.toLowerCase()}`
return `${fieldKey}:${v.toLowerCase()}`
}
export function groupChipFields(items: ChipFieldItem[], denseGrid = false) {
const map = new Map<string, ChipFieldItem[]>()
const seenValues = new Map<string, Set<string>>()
for (const item of items) {
if (item.removed) continue
const perField = seenValues.get(item.fieldKey) ?? new Set<string>()
const dedupeKey = chipValueDedupeKey(item.fieldKey, item.value)
if (perField.has(dedupeKey)) continue
perField.add(dedupeKey)
seenValues.set(item.fieldKey, perField)
const list = map.get(item.fieldKey) ?? []
list.push(item)
map.set(item.fieldKey, list)
}
const toGroup = (fieldKey: string) => ({
fieldKey,
label: FIELD_LABELS[fieldKey] ?? fieldKey,
items: map.get(fieldKey) ?? [],
})
const keys = sortFieldKeys([...map.keys()])
if (!denseGrid) {
return keys.map(toGroup)
}
const sparse: ReturnType<typeof toGroup>[] = []
const dense: ReturnType<typeof toGroup>[] = []
for (const fieldKey of keys) {
const group = toGroup(fieldKey)
if (group.items.length > 2) {
dense.push(group)
} else {
sparse.push(group)
}
}
return [...sparse, ...dense]
}
interface DiscoveryFieldChipsProps {
items: ChipFieldItem[]
/** Compact grid: 1 col < md, 2 cols mdlg, 3 cols xl+; dense fields span full row */
denseGrid?: boolean
/** Pencil edit + remove */
editable?: boolean
/** X only (e.g. reject suggestion) */
dismissible?: boolean
onRemove?: (id: string) => void
onValueChange?: (id: string, value: string) => void
className?: string
}
export function DiscoveryFieldChips({
items,
denseGrid = false,
editable = false,
dismissible = false,
onRemove,
onValueChange,
className,
}: DiscoveryFieldChipsProps) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState("")
const groups = useMemo(() => groupChipFields(items, denseGrid), [items, denseGrid])
if (groups.length === 0) return null
function startEdit(item: ChipFieldItem) {
setEditingId(item.id)
setEditValue(item.value)
}
function commitEdit(id: string) {
const v = editValue.trim()
if (!v) {
onRemove?.(id)
} else {
onValueChange?.(id, v)
}
setEditingId(null)
}
return (
<div
className={cn(
"mt-3",
denseGrid
? "grid grid-cols-1 gap-x-3 gap-y-2 md:grid-cols-2 xl:grid-cols-3"
: "space-y-2.5",
className,
)}
>
{groups.map((group) => (
<div
key={group.fieldKey}
className={cn(
denseGrid && CONTACTS_DISCOVERY_GRID_CELL_CLASS,
denseGrid && group.items.length > 2 && "md:col-span-2 xl:col-span-3",
)}
>
<p className={cn("mb-0.5 text-[11px] font-medium", CONTACTS_MUTED_TEXT)}>
{group.label}
</p>
<div className="flex flex-wrap gap-1">
{group.items.map((item) => {
const accessibleLabel = chipAccessibleLabel(group.label, item.value)
return editingId === item.id ? (
<Input
key={item.id}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit(item.id)
if (e.key === "Escape") setEditingId(null)
}}
onBlur={() => commitEdit(item.id)}
className="h-7 min-w-40 max-w-full flex-1 rounded-full px-3 text-xs"
aria-label={`Modifier ${accessibleLabel}`}
autoFocus
/>
) : (
<span
key={item.id}
className={CONTACTS_DISCOVERY_CHIP_CLASS}
title={accessibleLabel}
>
<span className="min-w-0 truncate" aria-label={accessibleLabel}>
{item.value}
</span>
{editable && (
<button
type="button"
onClick={() => startEdit(item)}
className="rounded-full p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={`Modifier ${accessibleLabel}`}
>
<Pencil className="h-3 w-3" />
</button>
)}
{(editable || dismissible) && (
<button
type="button"
onClick={() => onRemove?.(item.id)}
className="rounded-full p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={`Retirer ${accessibleLabel}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
)
})}
</div>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,143 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { flushSync } from "react-dom"
import {
useAddDiscoveredContact,
useIgnoredDiscoveredContacts,
useRejectDiscoveredProfile,
} from "@/lib/api/hooks/use-contact-discovery"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { profileDisplayName } from "@/lib/contacts/discovery-utils"
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
import type { FullContact } from "@/lib/contacts/types"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
} from "@/lib/contacts-chrome-classes"
import {
DiscoveryCardsMasonry,
DiscoveryCardsMasonryItem,
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
import { SuggestedContactCard } from "@/components/gmail/contacts-page/suggested-contact-card"
import { cn } from "@/lib/utils"
interface IgnoredContactsViewProps {
searchQuery: string
}
function profileToGroup(profile: ApiDiscoveredProfile): ApiDiscoveredProfileGroup {
return {
group_key: profile.id,
profile_ids: [profile.id],
display_name: profileDisplayName(profile),
primary_email: profile.primary_email,
message_count: profile.message_count,
profile,
profiles: [profile],
}
}
export function IgnoredContactsView({ searchQuery }: IgnoredContactsViewProps) {
const { bookId } = useContactsList()
const { data: profiles = [], isLoading } = useIgnoredDiscoveredContacts()
const addDiscoveredContact = useAddDiscoveredContact()
const rejectProfile = useRejectDiscoveredProfile()
const [removedProfileIds, setRemovedProfileIds] = useState<Set<string>>(() => new Set())
const markProfileRemoved = useCallback((profileId: string) => {
setRemovedProfileIds((prev) => {
if (prev.has(profileId)) return prev
const next = new Set(prev)
next.add(profileId)
return next
})
}, [])
const restoreProfile = useCallback((profileId: string) => {
setRemovedProfileIds((prev) => {
if (!prev.has(profileId)) return prev
const next = new Set(prev)
next.delete(profileId)
return next
})
}, [])
const filtered = useMemo(() => {
const visible = profiles.filter((p) => !removedProfileIds.has(p.id))
const q = searchQuery.trim().toLowerCase()
if (!q) return visible
return visible.filter((p) => {
const name = profileDisplayName(p).toLowerCase()
return name.includes(q) || p.primary_email.toLowerCase().includes(q)
})
}, [profiles, searchQuery, removedProfileIds])
function handleAdd(profile: ApiDiscoveredProfile, buildContact: () => FullContact) {
if (!bookId) return
flushSync(() => markProfileRemoved(profile.id))
requestAnimationFrame(() => {
addDiscoveredContact.mutate(
{
bookId,
profileId: profile.id,
contact: fullContactToApiContact(buildContact()),
},
{ onError: () => restoreProfile(profile.id) },
)
})
}
function handleRemove(profileId: string) {
flushSync(() => markProfileRemoved(profileId))
requestAnimationFrame(() => {
rejectProfile.mutate(profileId, { onError: () => restoreProfile(profileId) })
})
}
const addingProfileId =
addDiscoveredContact.isPending && typeof addDiscoveredContact.variables?.profileId === "string"
? addDiscoveredContact.variables.profileId
: null
return (
<div className="px-6 py-6 text-foreground">
<h2 className={cn("mb-2 text-base font-medium", CONTACTS_HEADING_TEXT)}>Ignorés</h2>
<p className={cn("mb-6 text-sm", CONTACTS_MUTED_TEXT)}>
Ces expéditeurs ne sont pas dans vos contacts et ne seront plus suggérés. Vous pouvez les
ajouter à votre carnet ou les supprimer définitivement.
</p>
<h3 className={cn("mb-4", CONTACTS_PAGE_SECTION_TITLE_CLASS)}>
{filtered.length} contact{filtered.length !== 1 ? "s" : ""}
</h3>
{isLoading && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>Chargement</p>
)}
{!isLoading && filtered.length === 0 && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucun contact ignoré.
</p>
)}
<DiscoveryCardsMasonry>
{filtered.map((p) => (
<DiscoveryCardsMasonryItem key={p.id}>
<SuggestedContactCard
group={profileToGroup(p)}
mode="ignored"
addBusy={addingProfileId === p.id}
busy={rejectProfile.isPending && rejectProfile.variables === p.id}
onAdd={(buildContact) => handleAdd(p, buildContact)}
onRemove={() => handleRemove(p.id)}
/>
</DiscoveryCardsMasonryItem>
))}
</DiscoveryCardsMasonry>
</div>
)
}

View File

@ -1,14 +1,20 @@
"use client"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useMergeDuplicates } from "@/lib/api/hooks/use-contact-mutations"
import { findDuplicatePairs, type DuplicateMatchReason } from "@/lib/contacts/duplicate-detection"
import { useMergeContactPair } from "@/lib/api/hooks/use-contact-mutations"
import { findDuplicatePairs, mergePairKey, type DuplicateMatchReason } from "@/lib/contacts/duplicate-detection"
import { useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
import { fullContactDisplayName, type MergeSuggestion } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { AddCoordinatesView } from "./add-coordinates-view"
import {
DiscoveryCardsMasonry,
DiscoveryCardsMasonryItem,
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
@ -36,29 +42,80 @@ export function MergeDuplicatesView() {
const { contacts, bookId } = useContactsList()
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
const mergeDuplicatesMutation = useMergeDuplicates()
const mergeContactPairMutation = useMergeContactPair()
const mergeSuggestions = useMemo(
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)),
[contacts, ignoredMergePairs]
)
const { suggestions: coordinateSuggestions } = useVisibleEnrichmentSuggestions()
const [mergingAll, setMergingAll] = useState(false)
const [mergingPairKey, setMergingPairKey] = useState<string | null>(null)
function handleMerge(_suggestion: MergeSuggestion) {
mergeDuplicatesMutation.mutate({ bookId })
function handleMerge(suggestion: MergeSuggestion) {
if (!bookId) {
toast.error("Carnet de contacts introuvable")
return
}
if (!suggestion.contactA.etag) {
toast.error("Impossible de fusionner : version du contact inconnue. Rechargez la liste.")
return
}
const pairKey = mergePairKey(suggestion.contactA.id, suggestion.contactB.id)
setMergingPairKey(pairKey)
mergeContactPairMutation.mutate(
{
bookId,
contactA: suggestion.contactA,
contactB: suggestion.contactB,
},
{
onSuccess: () => toast.success("Contacts fusionnés"),
onError: (err) => {
const msg =
err instanceof Error && err.message
? err.message
: "Impossible de fusionner ces contacts"
toast.error(msg)
},
onSettled: () => setMergingPairKey(null),
},
)
}
function handleIgnore(suggestion: MergeSuggestion) {
ignoreMergePair(suggestion.contactA.id, suggestion.contactB.id)
}
function handleMergeAll() {
async function handleMergeAll() {
if (!bookId) {
toast.error("Carnet de contacts introuvable")
return
}
if (mergeSuggestions.length === 0) return
setMergingAll(true)
mergeDuplicatesMutation.mutate(
{ bookId },
{ onSettled: () => setMergingAll(false) },
)
try {
for (const suggestion of mergeSuggestions) {
if (!suggestion.contactA.etag) continue
await mergeContactPairMutation.mutateAsync({
bookId,
contactA: suggestion.contactA,
contactB: suggestion.contactB,
})
}
toast.success("Doublons fusionnés")
} catch (err) {
const msg =
err instanceof Error && err.message
? err.message
: "Impossible de fusionner tous les doublons"
toast.error(msg)
} finally {
setMergingAll(false)
}
}
return (
@ -94,6 +151,9 @@ export function MergeDuplicatesView() {
className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
>
Ajouter des coordonnées
{coordinateSuggestions.length > 0 && (
<span className="ml-2 text-xs">({coordinateSuggestions.length})</span>
)}
</button>
</div>
@ -120,16 +180,23 @@ export function MergeDuplicatesView() {
</p>
)}
<div className="space-y-4">
{mergeSuggestions.map((suggestion) => (
<DiscoveryCardsMasonry>
{mergeSuggestions.map((suggestion) => {
const pairKey = mergePairKey(suggestion.contactA.id, suggestion.contactB.id)
const isMerging = mergingPairKey === pairKey
return (
<DiscoveryCardsMasonryItem key={pairKey}>
<MergeSuggestionCard
key={`${suggestion.contactA.id}:${suggestion.contactB.id}`}
suggestion={suggestion}
merging={isMerging}
onMerge={() => handleMerge(suggestion)}
onIgnore={() => handleIgnore(suggestion)}
/>
))}
</div>
</DiscoveryCardsMasonryItem>
)
})}
</DiscoveryCardsMasonry>
</div>
)}
@ -140,34 +207,41 @@ export function MergeDuplicatesView() {
function MergeSuggestionCard({
suggestion,
merging,
onMerge,
onIgnore,
}: {
suggestion: MergeSuggestion
merging: boolean
onMerge: () => void
onIgnore: () => void
}) {
const { contactA, contactB, reason } = suggestion
return (
<div className={CONTACTS_PAGE_CARD_CLASS}>
<div className={cn(CONTACTS_PAGE_CARD_CLASS, "overflow-hidden")}>
<p className={cn("mb-3 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
{REASON_LABELS[reason]}
</p>
<div className="flex items-start gap-6">
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
<ContactMiniCard contact={contactA} />
<ContactMiniCard contact={contactB} />
</div>
<div className="mt-4 flex items-center justify-end gap-3">
<div className="mt-4 flex flex-wrap items-center justify-end gap-3">
<button
type="button"
onClick={onIgnore}
disabled={merging}
className={CONTACTS_PAGE_LINK_BTN_CLASS}
>
Ignorer
</button>
<Button onClick={onMerge} className={CONTACTS_PRIMARY_BTN_CLASS}>
Fusionner
<Button
onClick={onMerge}
disabled={merging}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{merging ? "Fusion…" : "Fusionner"}
</Button>
</div>
</div>
@ -177,28 +251,30 @@ function MergeSuggestionCard({
function ContactMiniCard({ contact }: { contact: import("@/lib/contacts/types").FullContact }) {
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || "?"
const color = avatarColor(name)
const initial = senderInitial(name)
return (
<div className="flex flex-1 items-start gap-3">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" />
) : (
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: color }}
<div className="flex min-w-0 items-start gap-3 overflow-hidden">
<ContactAvatar contact={contact} name={name} size="sm" />
<div className="min-w-0 flex-1 overflow-hidden">
<p
className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}
title={name}
>
{initial}
</div>
)}
<div className="min-w-0">
<p className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}>{name}</p>
{name}
</p>
{contact.emails[0] && (
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{contact.emails[0].value}</p>
<p
className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}
title={contact.emails[0].value}
>
{contact.emails[0].value}
</p>
)}
{contact.phones[0] && (
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>
<p
className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}
title={`${contact.phones[0].value} (${contact.phones[0].label})`}
>
{contact.phones[0].value} ({contact.phones[0].label})
</p>
)}

View File

@ -0,0 +1,315 @@
"use client"
import { useEffect, useMemo, useRef, useCallback, useState, useDeferredValue } from "react"
import { Loader2, RefreshCw, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import {
useActiveDiscoveryScan,
useAddDiscoveredContact,
useBlockDiscoveredProfile,
useCancelDiscoveryScan,
useDiscoveryCounts,
useEnrichDiscoveredProfile,
useIgnoreDiscoveredProfile,
extractDiscoveryGroupEmails,
flattenOtherContactPages,
useOtherDiscoveredContacts,
useStartDiscoveryScan,
scanProgressLabel,
} from "@/lib/api/hooks/use-contact-discovery"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import {
isSuggestableDiscoveryGroup,
} from "@/lib/contacts/discovery-utils"
import {
discoveryGroupReactKey,
normalizeDiscoveryGroup,
} from "@/lib/contacts/discovery-grouping"
import type { ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
import type { FullContact } from "@/lib/contacts/types"
import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_INFO_BANNER_CLASS,
CONTACTS_PAGE_INFO_BANNER_ICON_CLASS,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import {
DiscoveryCardsMasonry,
DiscoveryCardsMasonryItem,
DiscoveryCardsMasonrySentinel,
} from "@/components/gmail/contacts-page/discovery-cards-masonry"
import { SuggestedContactCard } from "@/components/gmail/contacts-page/suggested-contact-card"
import { useDiscoveryScrollLoad } from "@/components/gmail/contacts-page/use-discovery-scroll-load"
import { cn } from "@/lib/utils"
interface OtherContactsViewProps {
searchQuery: string
}
export function OtherContactsView({ searchQuery }: OtherContactsViewProps) {
const deferredSearchQuery = useDeferredValue(searchQuery.trim())
const { bookId } = useContactsList()
const { data: discoveryCounts } = useDiscoveryCounts()
const { data: activeScan } = useActiveDiscoveryScan()
const isRunning =
activeScan != null &&
(activeScan.status === "running" || activeScan.status === "pending")
const {
data,
isLoading,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useOtherDiscoveredContacts(!isRunning, deferredSearchQuery)
const groups = useMemo(() => flattenOtherContactPages(data), [data])
const loadMoreRef = useRef<HTMLDivElement>(null)
const startScan = useStartDiscoveryScan()
const cancelScan = useCancelDiscoveryScan()
const addDiscoveredContact = useAddDiscoveredContact()
const ignoreProfile = useIgnoreDiscoveredProfile()
const blockProfile = useBlockDiscoveredProfile()
const enrichProfile = useEnrichDiscoveredProfile()
const blockSenders = useBlockedSendersStore((s) => s.blockSenders)
const completedHandled = useRef(false)
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(() => new Set())
const dismissCard = useCallback((cardKey: string) => {
setDismissedKeys((prev) => {
if (prev.has(cardKey)) return prev
const next = new Set(prev)
next.add(cardKey)
return next
})
}, [])
const restoreCard = useCallback((cardKey: string) => {
setDismissedKeys((prev) => {
if (!prev.has(cardKey)) return prev
const next = new Set(prev)
next.delete(cardKey)
return next
})
}, [])
useEffect(() => {
if (completedHandled.current && !isRunning) return
if (!isRunning && activeScan == null) {
completedHandled.current = true
void refetch()
}
}, [isRunning, activeScan, refetch])
useEffect(() => {
if (isRunning) completedHandled.current = false
}, [isRunning])
const filtered = useMemo(() => {
return groups
.map((g) => normalizeDiscoveryGroup(g))
.filter((g): g is ApiDiscoveredProfileGroup => g != null)
.filter(isSuggestableDiscoveryGroup)
.filter((g) => !dismissedKeys.has(discoveryGroupReactKey(g)))
}, [groups, dismissedKeys])
const isSearchPending = searchQuery.trim() !== deferredSearchQuery
const serverSuggestionCount =
deferredSearchQuery
? (data?.pages[0]?.total ?? filtered.length)
: (discoveryCounts?.other_contacts ?? data?.pages[0]?.total ?? filtered.length)
const suggestionCountLabel = deferredSearchQuery
? String(filtered.length)
: hasNextPage
? `${filtered.length} / ${serverSuggestionCount}`
: String(filtered.length)
const loadMore = useCallback(() => {
if (!hasNextPage || isFetchingNextPage) return
void fetchNextPage()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
useDiscoveryScrollLoad({
sentinelRef: loadMoreRef,
hasNextPage: Boolean(hasNextPage),
isFetchingNextPage,
onLoadMore: loadMore,
maxAutoLoads: 1,
})
function handleScan() {
if (isRunning) return
startScan.mutate(bookId)
}
function handleAdd(group: ApiDiscoveredProfileGroup, buildContact: () => FullContact) {
const profileId = group.profile?.id ?? group.profile_ids[0]
if (!profileId || !bookId) return
const cardKey = discoveryGroupReactKey(group)
dismissCard(cardKey)
addDiscoveredContact.mutate(
{
bookId,
profileId,
contact: fullContactToApiContact(buildContact()),
},
{ onError: () => restoreCard(cardKey) },
)
}
function handleIgnore(group: ApiDiscoveredProfileGroup, profileId: string) {
const cardKey = discoveryGroupReactKey(group)
dismissCard(cardKey)
ignoreProfile.mutate(profileId, { onError: () => restoreCard(cardKey) })
}
function handleBlock(group: ApiDiscoveredProfileGroup, profileId: string) {
const cardKey = discoveryGroupReactKey(group)
const emails = extractDiscoveryGroupEmails(group)
if (emails.length) blockSenders(emails)
dismissCard(cardKey)
blockProfile.mutate(profileId, {
onSuccess: (res) => {
if (res.emails?.length) blockSenders(res.emails)
},
onError: () => restoreCard(cardKey),
})
}
const enrichingProfileId =
enrichProfile.isPending && typeof enrichProfile.variables === "string"
? enrichProfile.variables
: null
const progress = activeScan?.progress_percent ?? 0
const scanLabel = activeScan ? scanProgressLabel(activeScan) : null
return (
<div className="px-6 py-6 text-foreground">
<div className={cn(CONTACTS_PAGE_INFO_BANNER_CLASS, "flex flex-col gap-4")}>
<div className="flex w-full items-center gap-4">
<div className={CONTACTS_PAGE_INFO_BANNER_ICON_CLASS}>
<span className="text-2xl">📬</span>
</div>
<h2 className={cn("min-w-0 flex-1 text-base font-medium", CONTACTS_HEADING_TEXT)}>
Contacts détectés dans vos e-mails
</h2>
<div className="flex shrink-0 flex-col items-stretch gap-2">
<Button
onClick={handleScan}
disabled={isRunning || startScan.isPending}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{isRunning || startScan.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{isRunning ? (
"Analyse en cours…"
) : (
<>
<span className="sm:hidden">Analyser</span>
<span className="hidden sm:inline">Analyser toutes les boîtes</span>
</>
)}
</Button>
{isRunning && (
<Button
variant="outline"
size="sm"
onClick={() => cancelScan.mutate()}
disabled={cancelScan.isPending}
>
{cancelScan.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<X className="mr-2 h-4 w-4" />
)}
Annuler
</Button>
)}
</div>
</div>
<p className={cn("w-full text-sm", CONTACTS_MUTED_TEXT)}>
Analyse complète de toutes vos boîtes mail. Les listes de diffusion, e-mails jetables et expéditeurs spam sont exclus.
</p>
{activeScan?.status === "failed" && activeScan.error_message && (
<p className="w-full text-xs text-destructive">{activeScan.error_message}</p>
)}
{isRunning && scanLabel && activeScan && (
<div className="w-full space-y-2">
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{scanLabel}</p>
<Progress value={Math.min(100, progress)} className="h-2 w-full" />
</div>
)}
</div>
<div className="mb-4 flex items-center justify-between">
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
Suggestions ({suggestionCountLabel})
</h3>
</div>
{isLoading && !isRunning && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
{isSearchPending ? "Recherche…" : "Chargement…"}
</p>
)}
{!isLoading && filtered.length === 0 && !isRunning && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
{deferredSearchQuery
? "Aucun contact ne correspond à votre recherche."
: "Aucun contact détecté. Lancez une analyse pour scanner tous vos messages."}
</p>
)}
<DiscoveryCardsMasonry
footer={
(hasNextPage || isFetchingNextPage) ? (
<DiscoveryCardsMasonrySentinel sentinelRef={loadMoreRef}>
{isFetchingNextPage ? (
<>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>Chargement</p>
</>
) : (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
{deferredSearchQuery
? `${filtered.length} sur ${serverSuggestionCount} résultats`
: `${filtered.length} sur ${serverSuggestionCount} affichés`}
</p>
)}
</DiscoveryCardsMasonrySentinel>
) : null
}
>
{filtered.map((group) => {
const profileId = group.profile?.id ?? group.profile_ids[0]
if (!profileId) return null
const reactKey = discoveryGroupReactKey(group)
return (
<DiscoveryCardsMasonryItem key={reactKey}>
<SuggestedContactCard
group={group}
mode="suggest"
busy={profileId === enrichingProfileId}
onAdd={(buildContact) => handleAdd(group, buildContact)}
onEnrich={() => enrichProfile.mutate(profileId)}
onIgnore={() => handleIgnore(group, profileId)}
onBlock={() => handleBlock(group, profileId)}
/>
</DiscoveryCardsMasonryItem>
)
})}
</DiscoveryCardsMasonry>
</div>
)
}

View File

@ -0,0 +1,293 @@
"use client"
import { memo, useEffect, useMemo, useState } from "react"
import { Ban, Loader2, Sparkles, Trash2, UserPlus } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types"
import {
applyDraftToContact,
buildDraftFields,
type DraftField,
} from "@/lib/contacts/discovery-draft"
import { profileDisplayName } from "@/lib/contacts/discovery-utils"
import type { FullContact } from "@/lib/contacts/types"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_DISCOVERY_INNER_PANEL_CLASS,
CONTACTS_PAGE_CARD_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { DiscoveryFieldChips } from "@/components/gmail/contacts-page/discovery-field-chips"
import { cn } from "@/lib/utils"
export type SuggestedContactCardMode = "suggest" | "ignored" | "blocked"
const ACTION_BTN_CLASS =
"h-9 min-w-0 w-full rounded-full px-3 text-sm font-medium sm:w-auto sm:flex-none"
const BLOCK_BTN_CLASS = cn(
"mx-auto h-9 w-auto min-w-0 px-3 text-sm font-medium text-destructive underline-offset-4",
"bg-transparent shadow-none hover:bg-transparent hover:text-destructive/90 hover:underline",
"dark:bg-transparent dark:hover:bg-transparent sm:mx-0 sm:flex-none",
"[&_svg]:text-destructive",
)
const ACTION_BTN_ICON_CLASS = "mr-1.5 h-3.5 w-3.5 shrink-0"
const IGNORE_BTN_CLASS =
"h-8 shrink-0 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground"
interface SuggestedContactCardProps {
group: ApiDiscoveredProfileGroup
mode?: SuggestedContactCardMode
busy?: boolean
/** Spinner sur « Ajouter » uniquement (sinon busy désactive sans spinner sur les autres boutons). */
addBusy?: boolean
/** Le parent retire la carte avant d'appeler buildContact (feedback instantané). */
onAdd?: (buildContact: () => FullContact) => void
onEnrich?: () => void
onIgnore?: () => void
onBlock?: () => void
onRemove?: () => void
}
function formatMailboxes(profile: ApiDiscoveredProfile): string {
const accounts = profile.detected_in_accounts ?? []
if (accounts.length === 0) return ""
return accounts
.map((a) => {
const label = a.account_name || a.account_email
return `${label} (${a.message_count} msg)`
})
.join(", ")
}
function toChipItems(fields: DraftField[]) {
return fields.map((f) => ({
id: f.id,
fieldKey: f.fieldKey,
value: f.value,
removed: f.removed,
}))
}
export const SuggestedContactCard = memo(function SuggestedContactCard({
group,
mode = "suggest",
busy = false,
addBusy = false,
onAdd,
onEnrich,
onIgnore,
onBlock,
onRemove,
}: SuggestedContactCardProps) {
const profile = group.profile ?? group.profiles?.[0]
const [fields, setFields] = useState<DraftField[]>(() => buildDraftFields(group))
useEffect(() => {
setFields(buildDraftFields(group))
}, [
group.group_key,
group.profile?.id,
group.profile_ids?.[0],
group.profile?.enrichment_status,
group.profile?.enriched_data,
])
const chipItems = useMemo(() => toChipItems(fields), [fields])
if (!profile) return null
const name = group.display_name || profileDisplayName(profile)
const mailboxes = formatMailboxes(profile)
const extraEmails = (group.profiles?.length ?? 1) > 1
const hasSignatures = (profile.signatures?.length ?? 0) > 0
const isEnriching = profile.enrichment_status === "enriching"
const showEnrichIA = hasSignatures && profile.enrichment_status !== "enriched"
function removeField(id: string) {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, removed: true } : f)))
}
function updateField(id: string, value: string) {
setFields((prev) =>
prev.map((f) => (f.id === id ? { ...f, value, removed: false } : f)),
)
}
function buildContactFromDraft() {
return applyDraftToContact(profile, fields)
}
return (
<div className={CONTACTS_PAGE_CARD_CLASS}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-3">
<ContactAvatar name={name} email={profile.primary_email} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<p className={cn("truncate text-sm font-medium leading-tight", CONTACTS_HEADING_TEXT)}>
{name}
</p>
{isEnriching && (
<Loader2
className="h-3.5 w-3.5 shrink-0 animate-spin text-amber-500 dark:text-amber-400"
aria-label="Enrichissement IA en cours"
/>
)}
{profile.enrichment_status === "enriched" && (
<Sparkles
className="h-3.5 w-3.5 shrink-0 text-amber-500 dark:text-amber-400"
aria-label="Coordonnées enrichies par IA"
/>
)}
{mode === "blocked" && (
<span className={cn("shrink-0 text-[10px] font-medium", CONTACTS_MUTED_TEXT)}>
· bloqué
</span>
)}
{mode === "ignored" && (
<span className={cn("shrink-0 text-[10px] font-medium", CONTACTS_MUTED_TEXT)}>
· ignoré
</span>
)}
</div>
{extraEmails ? (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
{(group.profile_ids?.length ?? 0)} adresses e-mail regroupées
</p>
) : (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{group.primary_email}</p>
)}
{mailboxes && (
<p className={cn("mt-0.5 line-clamp-2 text-xs", CONTACTS_MUTED_TEXT)}>
{mailboxes}
</p>
)}
</div>
</div>
{mode === "suggest" && onIgnore && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onIgnore}
disabled={busy}
className={IGNORE_BTN_CLASS}
>
Ignorer
</Button>
)}
</div>
<DiscoveryFieldChips
items={chipItems}
denseGrid={mode === "suggest"}
editable={mode === "suggest" || mode === "ignored" || mode === "blocked"}
onRemove={removeField}
onValueChange={updateField}
/>
{profile.signatures && profile.signatures.length > 0 && mode === "suggest" && (
<details className="mt-2">
<summary className={cn("cursor-pointer text-[11px]", CONTACTS_MUTED_TEXT)}>
{profile.signatures.length} signature{profile.signatures.length > 1 ? "s" : ""}
</summary>
<pre
className={cn(
"mt-1 max-h-24 overflow-auto text-[11px] whitespace-pre-wrap",
CONTACTS_DISCOVERY_INNER_PANEL_CLASS,
)}
>
{profile.signatures[0].signature_text}
</pre>
</details>
)}
{mode === "suggest" && (
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
{showEnrichIA && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onEnrich}
disabled={isEnriching || busy}
className={ACTION_BTN_CLASS}
aria-label="Enrichissement IA à partir des signatures"
>
{isEnriching ? (
<Loader2 className={cn(ACTION_BTN_ICON_CLASS, "animate-spin")} />
) : (
<Sparkles className={ACTION_BTN_ICON_CLASS} />
)}
Enrichissement IA
</Button>
)}
<Button
type="button"
variant="link"
size="sm"
onClick={onBlock}
disabled={busy || !onBlock}
className={BLOCK_BTN_CLASS}
>
<Ban className={ACTION_BTN_ICON_CLASS} />
Bloquer
</Button>
<Button
type="button"
size="sm"
onClick={() => onAdd?.(buildContactFromDraft)}
disabled={busy || !onAdd}
className={cn(CONTACTS_PRIMARY_BTN_CLASS, ACTION_BTN_CLASS)}
>
{addBusy ? (
<Loader2 className={cn(ACTION_BTN_ICON_CLASS, "animate-spin")} />
) : (
<UserPlus className={ACTION_BTN_ICON_CLASS} />
)}
Ajouter aux contacts
</Button>
</div>
)}
{(mode === "ignored" || mode === "blocked") && (
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
{onRemove && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onRemove}
disabled={busy}
className={ACTION_BTN_CLASS}
>
<Trash2 className={ACTION_BTN_ICON_CLASS} />
Supprimer
</Button>
)}
{onAdd && (
<Button
type="button"
size="sm"
onClick={() => onAdd(buildContactFromDraft)}
disabled={busy}
className={cn(CONTACTS_PRIMARY_BTN_CLASS, ACTION_BTN_CLASS)}
>
{addBusy ? (
<Loader2 className={cn(ACTION_BTN_ICON_CLASS, "animate-spin")} />
) : (
<UserPlus className={ACTION_BTN_ICON_CLASS} />
)}
Ajouter aux contacts
</Button>
)}
</div>
)}
</div>
)
})

View File

@ -11,7 +11,7 @@ import {
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
@ -76,8 +76,6 @@ export function TrashView() {
const { contact, deletedAt, reason } = entry
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || "?"
const color = avatarColor(name)
const initial = senderInitial(name)
return (
<div
@ -88,16 +86,7 @@ export function TrashView() {
)}
>
<span className="flex items-center gap-3">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 rounded-full object-cover" />
) : (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</span>
)}
<ContactAvatar contact={contact} name={name} size="xs" />
<span className={cn("truncate", CONTACTS_HEADING_TEXT)}>{name}</span>
</span>
<span className={cn("truncate", CONTACTS_MUTED_TEXT)}>{reason}</span>

View File

@ -0,0 +1,262 @@
"use client"
import { useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { buildContactUpdatePayload } from "@/lib/api/adapters"
import {
findApiContactInCaches,
replaceContactInBookCaches,
resolveContactForUpdate,
} from "@/lib/api/contact-book-cache"
import { fetchContactByPath } from "@/lib/api/hooks/use-contact-queries"
import { contactApiPath } from "@/lib/contacts/contact-api-path"
import type { FullContact } from "@/lib/contacts/types"
import type { ContactBulkEditField } from "@/lib/contacts/bulk-edit-fields"
import { applyBulkFieldValue } from "@/lib/contacts/bulk-edit-fields"
import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store"
import { useNavStore } from "@/lib/stores/nav-store"
import type { ContactLabelPresence } from "./contact-label-picker-block"
function contactHasLabel(contact: FullContact, labelId: string): boolean {
return contact.labels?.includes(labelId) ?? false
}
function mergeLabelOnContact(
contact: FullContact,
labelId: string,
add: boolean,
): FullContact {
const current = contact.labels ?? []
const next = add
? current.includes(labelId)
? current
: [...current, labelId]
: current.filter((id) => id !== labelId)
return {
...contact,
labels: next.length ? next : undefined,
updatedAt: Date.now(),
}
}
export function useContactBulkActions() {
const queryClient = useQueryClient()
const updateContact = useUpdateContact()
const blockSenders = useBlockedSendersStore((s) => s.blockSenders)
const labelRows = useNavStore((s) => s.labelRows)
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
const resolveContactEtag = useCallback(
async (contact: FullContact): Promise<FullContact | null> => {
let resolved = resolveContactForUpdate(queryClient, contact)
if (resolved.etag) return resolved
const apiPath = contactApiPath(resolved)
try {
const fetched = await fetchContactByPath(apiPath)
replaceContactInBookCaches(queryClient, apiPath, fetched)
resolved = resolveContactForUpdate(queryClient, {
...resolved,
etag: fetched.etag,
path: fetched.path ?? resolved.path,
})
} catch {
return null
}
return resolved.etag ? resolved : null
},
[queryClient],
)
const persistContact = useCallback(
async (
contact: FullContact,
opts?: {
bulkField?: ContactBulkEditField
bulkValue?: string
patchLabels?: boolean
skipInvalidation?: boolean
},
) => {
const resolved = await resolveContactEtag(contact)
if (!resolved?.etag) return false
const existing = findApiContactInCaches(queryClient, resolved)
const payload = buildContactUpdatePayload(existing, resolved, {
bulkField: opts?.bulkField,
bulkValue: opts?.bulkValue,
patchLabels: opts?.patchLabels,
})
await updateContact.mutateAsync({
path: contactApiPath(resolved),
etag: resolved.etag,
contact: payload,
skipInvalidation: opts?.skipInvalidation,
})
return true
},
[queryClient, resolveContactEtag, updateContact],
)
const getLabelPresence = useCallback(
(contacts: FullContact[], labelId: string): ContactLabelPresence => {
if (contacts.length === 0) return "none"
let n = 0
for (const contact of contacts) {
if (contactHasLabel(contact, labelId)) n++
}
if (n === 0) return "none"
if (n === contacts.length) return "all"
return "some"
},
[],
)
const runSequentialUpdates = useCallback(
async (
contacts: FullContact[],
buildNext: (contact: FullContact) => FullContact,
opts?: {
bulkField?: ContactBulkEditField
bulkValue?: string
patchLabels?: boolean
},
) => {
let skipped = 0
let failed = 0
let updated = 0
for (const contact of contacts) {
const next = buildNext(resolveContactForUpdate(queryClient, contact))
try {
const ok = await persistContact(next, {
...opts,
skipInvalidation: true,
})
if (ok) updated++
else skipped++
} catch {
failed++
}
}
await queryClient.invalidateQueries({ queryKey: ["contacts"] })
return { skipped, failed, updated }
},
[persistContact, queryClient],
)
const toggleLabelOnContacts = useCallback(
async (contacts: FullContact[], labelId: string) => {
if (contacts.length === 0) return
const presence = getLabelPresence(contacts, labelId)
const shouldAdd = presence !== "all"
const targets = contacts.filter((contact) => {
const has = contactHasLabel(contact, labelId)
return shouldAdd ? !has : has
})
const { skipped, failed, updated } = await runSequentialUpdates(
targets,
(contact) => mergeLabelOnContact(contact, labelId, shouldAdd),
{ patchLabels: true },
)
if (updated === 0 && skipped === 0 && failed === 0) return
if (failed > 0) {
toast.error(
`${failed} mise${failed > 1 ? "s" : ""} à jour échouée${failed > 1 ? "s" : ""}`,
)
}
if (skipped > 0) {
toast.warning(
`${skipped} contact${skipped > 1 ? "s" : ""} ignoré${skipped > 1 ? "s" : ""} (version inconnue)`,
)
}
},
[getLabelPresence, runSequentialUpdates],
)
const createAndApplyLabel = useCallback(
async (contacts: FullContact[], labelText: string) => {
addLabelRowFromSidebar(labelText)
const row = useNavStore
.getState()
.labelRows.find(
(r) => r.label.toLowerCase() === labelText.trim().toLowerCase(),
)
if (row) await toggleLabelOnContacts(contacts, row.id)
},
[addLabelRowFromSidebar, toggleLabelOnContacts],
)
const blockContacts = useCallback(
(contacts: FullContact[]) => {
const emails = contacts
.flatMap((c) => c.emails.map((e) => e.value))
.filter(Boolean)
if (emails.length === 0) {
toast.error("Aucune adresse e-mail à bloquer dans la sélection")
return
}
blockSenders(emails)
toast.success(
`${emails.length} adresse${emails.length > 1 ? "s" : ""} bloquée${emails.length > 1 ? "s" : ""}`,
)
},
[blockSenders],
)
const applyBulkField = useCallback(
async (contacts: FullContact[], field: ContactBulkEditField, value: string) => {
const toUpdate = contacts.map((c) => applyBulkFieldValue(c, field, value))
const { skipped, failed, updated } = await runSequentialUpdates(
toUpdate,
(contact) => contact,
{ bulkField: field, bulkValue: value },
)
if (updated > 0) {
toast.success(
`${updated} contact${updated > 1 ? "s" : ""} mis à jour`,
)
}
if (failed > 0) {
toast.error(
`${failed} mise${failed > 1 ? "s" : ""} à jour échouée${failed > 1 ? "s" : ""}`,
)
}
if (skipped > 0) {
toast.warning(
`${skipped} contact${skipped > 1 ? "s" : ""} ignoré${skipped > 1 ? "s" : ""} (version inconnue)`,
)
}
},
[runSequentialUpdates],
)
const resolveLabelVisualById = useCallback(
(labelId: string) => {
const row = labelRows.find((r) => r.id === labelId)
if (row?.icon) return { kind: "iconify" as const, icon: row.icon }
if (row?.color) return { kind: "dot" as const, colorClass: row.color }
return { kind: "dot" as const, colorClass: "bg-gray-400" }
},
[labelRows],
)
return {
getLabelPresence,
toggleLabelOnContacts,
createAndApplyLabel,
blockContacts,
applyBulkField,
resolveLabelVisualById,
isUpdating: updateContact.isPending,
}
}

View File

@ -0,0 +1,79 @@
"use client"
import { useEffect, useRef, type RefObject } from "react"
interface UseDiscoveryScrollLoadOptions {
sentinelRef: RefObject<HTMLElement | null>
hasNextPage: boolean
isFetchingNextPage: boolean
onLoadMore: () => void
/** Pages chargées auto sans scroll (évite de tout prefetch si le sentinel est visible) */
maxAutoLoads?: number
scrollRootSelector?: string
}
export function useDiscoveryScrollLoad({
sentinelRef,
hasNextPage,
isFetchingNextPage,
onLoadMore,
maxAutoLoads = 1,
scrollRootSelector = "main.overflow-y-auto",
}: UseDiscoveryScrollLoadOptions) {
const autoLoadsRef = useRef(0)
const userScrolledRef = useRef(false)
useEffect(() => {
if (hasNextPage) {
autoLoadsRef.current = 0
userScrolledRef.current = false
}
}, [hasNextPage])
useEffect(() => {
const root =
document.querySelector(scrollRootSelector) ??
sentinelRef.current?.closest("main")
if (!root) return
const onScroll = () => {
if (root.scrollTop > 40) userScrolledRef.current = true
}
root.addEventListener("scroll", onScroll, { passive: true })
return () => root.removeEventListener("scroll", onScroll)
}, [scrollRootSelector, sentinelRef])
useEffect(() => {
const el = sentinelRef.current
if (!el || !hasNextPage) return
const root =
document.querySelector(scrollRootSelector) ??
el.closest("main")
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting || isFetchingNextPage) return
if (!userScrolledRef.current && autoLoadsRef.current >= maxAutoLoads) return
autoLoadsRef.current += 1
onLoadMore()
},
{
root: root instanceof Element ? root : null,
rootMargin: "160px",
threshold: 0,
},
)
observer.observe(el)
return () => observer.disconnect()
}, [
sentinelRef,
hasNextPage,
isFetchingNextPage,
onLoadMore,
scrollRootSelector,
maxAutoLoads,
])
}

View File

@ -0,0 +1,39 @@
"use client"
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"
function childKey(child: ReactNode, index: number): string {
if (child && typeof child === "object" && "key" in child) {
return String((child as ReactElement).key ?? index)
}
return String(index)
}
/** Clés des items ajoutés récemment (pour animation d'entrée unique). */
export function useEnteringItemKeys(items: ReactNode[]): Set<string> {
const seenKeysRef = useRef(new Set<string>())
const [enteringKeys, setEnteringKeys] = useState<Set<string>>(() => new Set())
const itemKeys = items.map(childKey).join("\0")
useEffect(() => {
const keys = items.map(childKey)
const keySet = new Set(keys)
const added = keys.filter((key) => !seenKeysRef.current.has(key))
for (const key of keys) {
seenKeysRef.current.add(key)
}
for (const key of [...seenKeysRef.current]) {
if (!keySet.has(key)) seenKeysRef.current.delete(key)
}
if (added.length === 0) return
setEnteringKeys(new Set(added))
const timer = window.setTimeout(() => setEnteringKeys(new Set()), 360)
return () => window.clearTimeout(timer)
}, [itemKeys, items])
return enteringKeys
}

View File

@ -0,0 +1,115 @@
"use client"
import { useRef } from "react"
import { Plus, User, X } from "lucide-react"
import { toast } from "sonner"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { readAvatarFromFile } from "@/lib/contact-avatar"
import { cn } from "@/lib/utils"
import {
CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS,
CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS,
CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS,
} from "@/lib/contacts-chrome-classes"
interface ContactAvatarPickerProps {
avatarUrl?: string
displayName: string
email?: string
onChange: (avatarUrl: string | undefined) => void
variant?: "panel" | "page"
className?: string
}
export function ContactAvatarPicker({
avatarUrl,
displayName,
email,
onChange,
variant = "panel",
className,
}: ContactAvatarPickerProps) {
const fileRef = useRef<HTMLInputElement>(null)
const isPage = variant === "page"
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
try {
const dataUrl = await readAvatarFromFile(file)
onChange(dataUrl)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Impossible d'ajouter la photo.")
}
}
function openPicker() {
fileRef.current?.click()
}
function removePhoto(e: React.MouseEvent) {
e.stopPropagation()
onChange(undefined)
}
const hasPhoto = !!avatarUrl || !!displayName
return (
<div className={cn("relative flex flex-col items-center", className)}>
<button
type="button"
onClick={openPicker}
className="group relative rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={avatarUrl ? "Changer la photo" : "Ajouter une photo"}
>
{hasPhoto ? (
<ContactAvatar
avatarUrl={avatarUrl}
name={displayName}
email={email}
size={isPage ? "2xl" : "lg"}
/>
) : isPage ? (
<div className={CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS}>
<User className="h-12 w-12" />
</div>
) : (
<div className={CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS}>
<User className="h-8 w-8" />
</div>
)}
<div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
<Plus className="h-4 w-4" />
</div>
{avatarUrl ? (
<span
role="button"
tabIndex={0}
onClick={removePhoto}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onChange(undefined)
}
}}
className="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 hover:text-foreground"
aria-label="Supprimer la photo"
>
<X className="h-3.5 w-3.5" />
</span>
) : null}
</button>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={handleFileChange}
/>
</div>
)
}

View File

@ -0,0 +1,78 @@
"use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { gravatarUrl, primaryContactEmail } from "@/lib/contact-avatar"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { cn } from "@/lib/utils"
import type { FullContact } from "@/lib/contacts/types"
export type ContactAvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
const SIZE_CONFIG: Record<
ContactAvatarSize,
{ className: string; gravatar: number; text: string }
> = {
xs: { className: "size-8", gravatar: 64, text: "text-xs" },
sm: { className: "size-10", gravatar: 80, text: "text-sm" },
md: { className: "size-14", gravatar: 112, text: "text-lg" },
lg: { className: "size-20", gravatar: 160, text: "text-2xl" },
xl: { className: "size-24", gravatar: 192, text: "text-3xl" },
"2xl": { className: "size-28", gravatar: 224, text: "text-4xl" },
}
export interface ContactAvatarProps {
contact?: Pick<FullContact, "avatarUrl" | "emails" | "firstName" | "lastName">
/** Override display name for initials fallback. */
name?: string
/** Override email for Gravatar fallback. */
email?: string
/** Override stored avatar URL. */
avatarUrl?: string
size?: ContactAvatarSize
className?: string
alt?: string
}
export function contactAvatarLabel(
contact: Pick<FullContact, "firstName" | "lastName" | "emails"> | undefined,
nameOverride?: string,
emailOverride?: string,
): string {
if (nameOverride?.trim()) return nameOverride.trim()
if (contact) {
const fromName = `${contact.firstName ?? ""} ${contact.lastName ?? ""}`.trim()
if (fromName) return fromName
}
return emailOverride?.trim() || primaryContactEmail(contact ?? {}) || "?"
}
export function ContactAvatar({
contact,
name: nameOverride,
email: emailOverride,
avatarUrl: avatarUrlOverride,
size = "sm",
className,
alt,
}: ContactAvatarProps) {
const config = SIZE_CONFIG[size]
const name = contactAvatarLabel(contact, nameOverride, emailOverride)
const email = emailOverride?.trim() || primaryContactEmail(contact ?? {})
const avatarUrl = avatarUrlOverride ?? contact?.avatarUrl
const gravatar = email ? gravatarUrl(email, config.gravatar) : undefined
const initial = senderInitial(name)
const color = avatarColor(name)
return (
<Avatar className={cn("shrink-0", config.className, className)}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={alt ?? name} /> : null}
{gravatar ? <AvatarImage src={gravatar} alt={alt ?? name} /> : null}
<AvatarFallback
className={cn("font-medium text-white", config.text)}
style={{ backgroundColor: color }}
>
{initial}
</AvatarFallback>
</Avatar>
)
}

View File

@ -1,8 +1,9 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import {
Pencil,
Sparkles,
Star,
X,
Mail,
@ -18,8 +19,8 @@ import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useMailSearch } from "@/lib/api/hooks/use-mail-queries"
import { useComposeActions } from "@/lib/compose-context"
import { useNavStore } from "@/lib/stores/nav-store"
@ -38,6 +39,9 @@ import {
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
import { ContactsPanelLogo } from "./contacts-panel-logo"
import { useLLMSettings } from "@/lib/api/hooks/use-contact-discovery"
import { isLLMConfigured } from "@/lib/contacts/llm-settings-utils"
import { ContactImproveDialog } from "@/components/gmail/contacts-page/contact-improve-dialog"
interface ContactDetailViewProps {
contactId: string | null
@ -71,6 +75,9 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
const { contacts } = useContactsList()
const { openComposeWithInitial } = useComposeActions()
const labelRows = useNavStore((s) => s.labelRows)
const { data: llmSettings } = useLLMSettings()
const [improveOpen, setImproveOpen] = useState(false)
const llmReady = isLLMConfigured(llmSettings)
const contact = contacts.find((c) => c.id === contactId)
@ -98,8 +105,6 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
const color = avatarColor(name)
const initial = senderInitial(name)
const primaryEmail = contact.emails[0]?.value
return (
@ -108,6 +113,22 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
<div className={CONTACTS_PANEL_HEADER_CLASS}>
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={() => setImproveOpen(true)}
disabled={!llmReady}
aria-label="Amélioration IA"
title={
llmReady
? "Améliorer la fiche avec l'IA"
: "Configurez un fournisseur LLM dans les réglages contacts"
}
>
<Sparkles className="h-4 w-4 text-amber-500" />
</Button>
<Button
type="button"
variant="ghost"
@ -142,20 +163,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
<div className="w-full min-w-0 max-w-full overflow-x-hidden">
{/* Avatar + Name */}
<div className="flex flex-col items-center px-4 pt-6 pb-4">
{contact.avatarUrl ? (
<img
src={contact.avatarUrl}
alt={name}
className="h-20 w-20 rounded-full object-cover"
/>
) : (
<div
className="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
)}
<ContactAvatar contact={contact} name={name} size="lg" />
<h2 className={cn("mt-3 max-w-full truncate px-2 text-center text-lg font-medium", CONTACTS_HEADING_TEXT)}>
{name}
</h2>
@ -186,8 +194,25 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
</div>
{/* Quick actions */}
<div className="flex min-w-0 flex-col items-center gap-2 px-4 pb-4">
<Button
type="button"
variant="outline"
size="sm"
className="w-full max-w-xs rounded-full"
onClick={() => setImproveOpen(true)}
disabled={!llmReady}
title={
llmReady
? undefined
: "Configurez un fournisseur LLM dans les réglages contacts"
}
>
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
Amélioration IA
</Button>
{primaryEmail && (
<div className="flex min-w-0 flex-wrap items-center justify-center gap-2 px-4 pb-4">
<div className="flex min-w-0 flex-wrap items-center justify-center gap-2">
<button
type="button"
className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}
@ -214,6 +239,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
</button>
</div>
)}
</div>
{/* Contact details */}
<div className={cn("min-w-0", CONTACTS_PANEL_DIVIDER_CLASS)}>
@ -307,6 +333,12 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
</div>
</ScrollArea>
<ContactImproveDialog
contact={contact}
open={improveOpen}
onOpenChange={setImproveOpen}
/>
</div>
)
}

View File

@ -42,14 +42,15 @@ import {
} from "@/components/ui/popover"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { toast } from "sonner"
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { contactApiPath } from "@/lib/contacts/contact-api-path"
import { ContactAvatarPicker } from "@/components/gmail/contacts/contact-avatar-picker"
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store"
import {
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS,
CONTACTS_PANEL_CARD_CLASS,
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
@ -119,6 +120,7 @@ const contactFormSchema = z.object({
.optional(),
notes: z.string().optional().default(""),
labels: z.array(z.string()).optional().default([]),
avatarUrl: z.string().optional(),
})
type ContactFormValues = z.infer<typeof contactFormSchema>
@ -175,6 +177,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
birthday: { day: undefined, month: undefined, year: undefined },
notes: "",
labels: [],
avatarUrl: undefined,
},
})
@ -217,6 +220,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
birthday: { day: undefined, month: undefined, year: undefined },
notes: "",
labels: [],
avatarUrl: undefined,
})
clearCreateDraft()
}, [mode, createDraft, reset, clearCreateDraft])
@ -266,12 +270,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
},
notes: contact.notes ?? "",
labels: contact.labels ?? [],
avatarUrl: contact.avatarUrl,
})
}, [mode, contactId, contacts, reset])
const firstName = watch("firstName")
const lastName = watch("lastName")
const watchedEmails = watch("emails")
const avatarUrl = watch("avatarUrl")
const currentLabels = watch("labels") ?? []
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
@ -317,6 +323,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
: undefined,
notes: data.notes || undefined,
labels: data.labels?.length ? data.labels : undefined,
avatarUrl: data.avatarUrl || undefined,
}
if (mode === "create") {
@ -338,24 +345,43 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
const id = created?.uid ?? tempId
setView("view", id)
},
onError: (err) => {
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer le contact"
toast.error(msg)
},
},
)
} else if (contactId) {
} else if (contactId && existingContact) {
const fullContact: FullContact = {
id: contactId,
path: existingContact.path,
etag: existingContact.etag,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: Date.now(),
createdAt: existingContact.createdAt,
updatedAt: Date.now(),
}
updateContactMutation.mutate({
path: contactId,
if (!existingContact.etag) {
toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.")
return
}
updateContactMutation.mutate(
{
path: contactApiPath(fullContact),
etag: existingContact.etag,
contact: fullContactToApiContact(fullContact),
})
setView("view", contactId)
},
{
onSuccess: () => setView("view", contactId),
onError: (err) => {
const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications"
toast.error(msg)
},
},
)
}
}
@ -405,18 +431,13 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Avatar */}
<div className="flex flex-col items-center py-6">
{displayName ? (
<div
className="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-medium text-white"
style={{ backgroundColor: avatarColor(displayName) }}
>
{senderInitial(displayName)}
</div>
) : (
<div className={CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS}>
<User className="h-8 w-8" />
</div>
)}
<ContactAvatarPicker
variant="panel"
avatarUrl={avatarUrl}
displayName={displayName}
email={watchedEmails?.find((e) => e.value?.trim())?.value}
onChange={(next) => setValue("avatarUrl", next, { shouldDirty: true })}
/>
</div>
{/* Labels */}

View File

@ -1,7 +1,7 @@
"use client"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { type FullContact, fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
CONTACTS_PANEL_ROW_CLASS,
CONTACTS_MUTED_TEXT,
@ -18,8 +18,6 @@ export function ContactRow({ contact, onClick }: ContactRowProps) {
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
const subtitle = contact.emails[0]?.value || contact.phones[0]?.value || ""
const initial = senderInitial(name)
const bgColor = avatarColor(name)
return (
<button
@ -30,20 +28,7 @@ export function ContactRow({ contact, onClick }: ContactRowProps) {
CONTACTS_PANEL_ROW_CLASS,
)}
>
{contact.avatarUrl ? (
<img
src={contact.avatarUrl}
alt={name}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: bgColor }}
>
{initial}
</div>
)}
<ContactAvatar contact={contact} name={name} size="sm" />
<div className="min-w-0 flex-1">
<p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{name}</p>
{subtitle && displayName ? (

View File

@ -24,6 +24,7 @@ import {
import { cn } from "@/lib/utils"
import { ContactRow } from "./contact-row"
import { ContactsPanelLogo } from "./contacts-panel-logo"
import { ContactsLoadState } from "./contacts-load-state"
export function ContactsListView() {
const {
@ -35,7 +36,7 @@ export function ContactsListView() {
showContactsList,
closePanel,
} = useContactsStore()
const { contacts } = useContactsList()
const { contacts, isLoading, isError, error, refetch } = useContactsList()
const searchInputRef = useRef<HTMLInputElement>(null)
@ -153,9 +154,17 @@ export function ContactsListView() {
<ScrollArea className="min-h-0 flex-1">
<CreateContactButton onClick={() => setView("create")} />
<div className={CONTACTS_PANEL_SECTION_LABEL_CLASS}>
Contacts ({contacts.length})
Contacts ({isLoading ? "…" : contacts.length})
</div>
{groupedContacts.map((group) => (
{(isLoading || isError) && (
<ContactsLoadState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
/>
)}
{!isLoading && !isError && groupedContacts.map((group) => (
<div key={group.letter}>
<div className={CONTACTS_PANEL_LETTER_CLASS}>{group.letter}</div>
{group.items.map((contact) => (

View File

@ -0,0 +1,58 @@
"use client"
import { Loader2, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ApiRequestError } from "@/lib/api/client"
import { CONTACTS_MUTED_TEXT } from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ContactsLoadStateProps {
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
className?: string
}
function errorMessage(error: unknown): string {
if (error instanceof ApiRequestError) {
if (error.code === "contacts_unavailable") {
return "Connexion au carnet d'adresses indisponible. Réessayez dans quelques secondes."
}
if (error.code === "auth.unavailable") {
return "Service d'authentification indisponible. Vérifiez que le backend est démarré."
}
return error.message
}
if (error instanceof Error) return error.message
return "Impossible de charger les contacts."
}
export function ContactsLoadState({
isLoading,
isError,
error,
onRetry,
className,
}: ContactsLoadStateProps) {
if (isLoading) {
return (
<div className={cn("flex items-center justify-center gap-2 py-12 text-sm", CONTACTS_MUTED_TEXT, className)}>
<Loader2 className="h-4 w-4 animate-spin" />
Chargement des contacts
</div>
)
}
if (!isError) return null
return (
<div className={cn("flex flex-col items-center gap-3 px-6 py-12 text-center", className)}>
<p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>{errorMessage(error)}</p>
<Button type="button" variant="outline" size="sm" className="rounded-full" onClick={onRetry}>
<RefreshCw className="mr-1.5 h-4 w-4" />
Réessayer
</Button>
</div>
)
}

View File

@ -595,7 +595,7 @@ const mailPaginationControls = (mode: "list" | "view") => {
if (variant === "reading-pane") {
return (
<div className="relative z-20 flex shrink-0 min-h-12 items-start gap-2 border-b border-gray-200 py-1.5 pl-2 pr-4">
{openMailToolbar(true)}
{openMailToolbar(false)}
<div className="flex-1" />
{mailPaginationControls("view")}
</div>

View File

@ -8,6 +8,10 @@ import { findContactByEmail } from "@/lib/contacts/find-contact"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails"
import { normalizeMailAddress } from "@/lib/mail-message-participants"
import {
isBlockedSenderEmail,
useBlockedSendersStore,
} from "@/lib/stores/blocked-senders-store"
import {
isMessageRemoteContentAllowed,
isTrustedSenderEmail,
@ -39,6 +43,7 @@ export function MessageBodyContent({
const selfEmails = useSelfMailEmails()
const { contacts } = useContactsList()
const trustedSenderEmails = useTrustedSendersStore((s) => s.trustedSenderEmails)
const blockedSenderEmails = useBlockedSendersStore((s) => s.blockedSenderEmails)
const allowedMessageIds = useTrustedSendersStore((s) => s.allowedMessageIds)
const trustSender = useTrustedSendersStore((s) => s.trustSender)
const allowMessageRemoteContent = useTrustedSendersStore(
@ -53,6 +58,7 @@ export function MessageBodyContent({
const isContact = Boolean(findContactByEmail(contacts, senderEmail))
const isTrusted = isTrustedSenderEmail(trustedSenderEmails, senderEmail)
const isBlocked = isBlockedSenderEmail(blockedSenderEmails, senderEmail)
const isMessageAllowed = isMessageRemoteContentAllowed(
allowedMessageIds,
messageId
@ -100,7 +106,7 @@ export function MessageBodyContent({
const blockRemoteContent = isFromSelf
? false
: isSpam || (hasRemoteContent && !remoteContentAllowed)
: isSpam || isBlocked || (hasRemoteContent && !remoteContentAllowed)
const showRemoteBanner =
!isFromSelf && !isSpam && hasRemoteContent && !remoteContentAllowed

View File

@ -86,9 +86,14 @@ function FavoriteAppTile({ app }: { app: FavoriteApp }) {
interface HeaderAccountActionsProps {
className?: string
/** When set, the settings button navigates here instead of opening quick settings. */
settingsHref?: string
}
export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
export function HeaderAccountActions({
className,
settingsHref,
}: HeaderAccountActionsProps) {
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
const appsMenuRef = useRef<HTMLDivElement>(null)
@ -136,9 +141,17 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
size="icon"
className={HEADER_ICON_BTN_CLASS}
aria-label="Réglages"
onClick={() => openQuickSettings(true)}
{...(settingsHref
? { asChild: true }
: { onClick: () => openQuickSettings(true) })}
>
{settingsHref ? (
<Link href={settingsHref}>
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
</Link>
) : (
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
)}
</Button>
<div className="relative hidden sm:block" ref={appsMenuRef}>

View File

@ -20,6 +20,7 @@ import {
import { Button } from "@/components/ui/button"
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 { useMailSearchStore } from "@/lib/stores/mail-search-store"
import {
@ -107,7 +108,7 @@ export function MailSearchBar({
},
email: c.email ?? "",
displayName: c.full_name,
score: 1,
score: scoreApiContact(c, inputValue),
}))
}, [inputValue, searchContactResults])

View File

@ -22,6 +22,7 @@ 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,
@ -98,7 +99,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
},
email: c.email ?? "",
displayName: c.full_name,
score: 1,
score: scoreApiContact(c, inputValue),
}))
}, [inputValue, searchContactResults])

View File

@ -0,0 +1,106 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { useDiscoverLLMModels } from '@/lib/api/hooks/use-contact-discovery'
import { CONTACTS_MUTED_TEXT } from '@/lib/contacts-chrome-classes'
import { cn } from '@/lib/utils'
const MAX_SUGGESTIONS = 4
interface LLMModelSuggestInputProps {
baseUrl: string
apiKey?: string
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}
export function LLMModelSuggestInput({
baseUrl,
apiKey = '',
value,
onChange,
placeholder,
className,
}: LLMModelSuggestInputProps) {
const [open, setOpen] = useState(false)
const [debouncedBaseUrl, setDebouncedBaseUrl] = useState(baseUrl)
const [debouncedApiKey, setDebouncedApiKey] = useState(apiKey)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedBaseUrl(baseUrl)
setDebouncedApiKey(apiKey)
}, 400)
return () => clearTimeout(timer)
}, [baseUrl, apiKey])
const { data, isFetching, isError } = useDiscoverLLMModels(debouncedBaseUrl, debouncedApiKey)
const models = data?.models ?? []
const filtered = useMemo(() => {
const q = value.trim().toLowerCase()
const matches = q
? models.filter((model) => model.toLowerCase().includes(q))
: models
return matches.slice(0, MAX_SUGGESTIONS)
}, [models, value])
const showDropdown = open && !isFetching && filtered.length > 0
function pickModel(model: string) {
onChange(model)
setOpen(false)
}
return (
<div className="space-y-1">
<div className="relative">
<Input
className={cn('h-9', className)}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => {
window.setTimeout(() => setOpen(false), 150)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false)
}}
/>
{showDropdown ? (
<ul className="absolute z-20 mt-1 w-full rounded-md border border-border bg-popover py-1 shadow-md">
{filtered.map((model) => (
<li key={model}>
<button
type="button"
className="w-full px-2 py-1.5 text-left text-xs hover:bg-muted"
onMouseDown={(e) => {
e.preventDefault()
pickModel(model)
}}
>
<span className="block truncate font-mono">{model}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
{baseUrl.trim() ? (
<p className={cn('text-[11px]', CONTACTS_MUTED_TEXT)}>
{isFetching
? 'Chargement des modèles…'
: isError
? 'Impossible de récupérer les modèles pour cette URL.'
: models.length > 0
? `${models.length} modèle${models.length > 1 ? 's' : ''} disponible${models.length > 1 ? 's' : ''}.`
: 'Aucun modèle trouvé pour cette URL.'}
</p>
) : null}
</div>
)
}

View File

@ -0,0 +1,229 @@
"use client"
import { useEffect, useState } from "react"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
useLLMSettings,
useUpdateLLMSettings,
} from "@/lib/api/hooks/use-contact-discovery"
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
function emptyProvider(): ApiLLMProvider {
return {
id: crypto.randomUUID(),
name: "",
base_url: "https://api.openai.com/v1",
api_key: "",
default_model: "gpt-4o-mini",
}
}
export function LLMProvidersPanel() {
const { data: remote, isLoading } = useLLMSettings()
const updateSettings = useUpdateLLMSettings()
const [draft, setDraft] = useState<ApiLLMSettings>({
default_provider_id: "",
providers: [],
})
const [saved, setSaved] = useState(false)
useEffect(() => {
if (remote) {
setDraft({
...remote,
providers: remote.providers ?? [],
})
}
}, [remote])
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
setDraft((prev) => {
const providers = [...prev.providers]
providers[index] = { ...providers[index], ...patch }
return { ...prev, providers }
})
}
function addProvider() {
const p = emptyProvider()
setDraft((prev) => ({
...prev,
providers: [...prev.providers, p],
default_provider_id: prev.default_provider_id || p.id,
}))
}
function removeProvider(index: number) {
setDraft((prev) => {
const removed = prev.providers[index]
const providers = prev.providers.filter((_, i) => i !== index)
let defaultId = prev.default_provider_id
if (defaultId === removed?.id) {
defaultId = providers[0]?.id ?? ""
}
return { ...prev, providers, default_provider_id: defaultId }
})
}
async function handleSave() {
await updateSettings.mutateAsync(draft)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
if (isLoading) {
return <p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>Chargement</p>
}
return (
<div className="max-w-2xl space-y-6">
<div>
<h3 className="text-base font-medium">Fournisseurs LLM</h3>
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
API OpenAI-compatibles pour l&apos;enrichissement des contacts et le tri par règles.
</p>
</div>
{draft.providers.map((provider, index) => (
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
<Button
variant="ghost"
size="icon"
onClick={() => removeProvider(index)}
aria-label="Supprimer"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Nom</Label>
<Input
className="mt-1 h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
placeholder="OpenAI"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">URL de base</Label>
<Input
className="mt-1 h-9"
value={provider.base_url}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Clé API</Label>
<Input
className="mt-1 h-9"
type="password"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder="sk-…"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Modèle par défaut</Label>
<LLMModelSuggestInput
className="mt-1"
baseUrl={provider.base_url}
apiKey={provider.api_key}
value={provider.default_model}
onChange={(default_model) => updateProvider(index, { default_model })}
placeholder="gpt-4o-mini"
/>
</div>
</div>
</div>
))}
<Button variant="outline" onClick={addProvider}>
<Plus className="mr-2 h-4 w-4" />
Ajouter un fournisseur
</Button>
<div className="space-y-3 rounded-lg border border-border p-4">
<h4 className="text-sm font-medium">Découverte de contacts</h4>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Fournisseur par défaut</Label>
<Select
value={draft.default_provider_id}
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Modèle pour l&apos;enrichissement</Label>
<Select
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
onValueChange={(v) =>
setDraft((p) => ({ ...p, contact_discovery_provider_id: v }))
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Même que défaut" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Modèle LLM</Label>
<Input
className="mt-1 h-9"
value={draft.contact_discovery_model ?? ""}
onChange={(e) =>
setDraft((p) => ({ ...p, contact_discovery_model: e.target.value }))
}
placeholder="Laisser vide pour utiliser le modèle par défaut du fournisseur"
/>
</div>
</div>
</div>
<Button
onClick={handleSave}
disabled={updateSettings.isPending}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
</Button>
</div>
)
}

View File

@ -0,0 +1,123 @@
"use client"
import { useEffect, useState } from "react"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
useSearchSettings,
useUpdateSearchSettings,
} from "@/lib/api/hooks/use-contact-discovery"
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
const BRAVE_PROVIDER_ID = "brave-default"
function defaultBraveProvider(): ApiSearchProvider {
return {
id: BRAVE_PROVIDER_ID,
name: "Brave Search",
type: "brave",
api_key: "",
}
}
function normalizeDraft(raw: ApiSearchSettings | undefined): ApiSearchSettings {
const providers = raw?.providers?.length ? raw.providers : [defaultBraveProvider()]
const brave = providers.find((p) => p.type === "brave") ?? defaultBraveProvider()
return {
default_provider_id: raw?.default_provider_id || brave.id,
providers: [brave],
}
}
export function SearchProvidersPanel() {
const { data: remote, isLoading } = useSearchSettings()
const updateSettings = useUpdateSearchSettings()
const [draft, setDraft] = useState<ApiSearchSettings>(normalizeDraft(undefined))
const [saved, setSaved] = useState(false)
useEffect(() => {
if (remote) {
setDraft(normalizeDraft(remote))
}
}, [remote])
const brave = draft.providers[0] ?? defaultBraveProvider()
function updateBrave(patch: Partial<ApiSearchProvider>) {
setDraft((prev) => {
const current = prev.providers[0] ?? defaultBraveProvider()
const updated = { ...current, ...patch }
return {
default_provider_id: updated.id,
providers: [updated],
}
})
}
async function handleSave() {
await updateSettings.mutateAsync(draft)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
if (isLoading) {
return <p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>Chargement</p>
}
return (
<div className="max-w-2xl space-y-6">
<div>
<h3 className="text-base font-medium">Fournisseurs de recherche</h3>
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
Recherche web utilisée lors de l&apos;amélioration IA des fiches contacts (profils
publics, réseaux sociaux, poste, entreprise).
</p>
</div>
<div className="space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{brave.name}</span>
<a
href="https://api.search.brave.com"
target="_blank"
rel="noopener noreferrer"
className={cn("inline-flex items-center gap-1 text-xs hover:underline", CONTACTS_MUTED_TEXT)}
>
Obtenir une clé API
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div>
<Label className="text-xs">Token API (X-Subscription-Token)</Label>
<Input
className="mt-1 h-9"
type="password"
value={brave.api_key ?? ""}
onChange={(e) => updateBrave({ api_key: e.target.value })}
placeholder="BSA…"
autoComplete="off"
/>
<p className={cn("mt-1.5 text-xs", CONTACTS_MUTED_TEXT)}>
Les 5 premiers résultats web sont ajoutés au prompt LLM avec un avertissement sur les
homonymes. Sans token, l&apos;amélioration IA fonctionne sans recherche en ligne.
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={updateSettings.isPending}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
</Button>
</div>
)
}

View File

@ -5,6 +5,8 @@ import { SettingsSectionHeader } from "@/components/gmail/settings/settings-sect
import { SettingsComingSoon } from "@/components/gmail/settings/settings-coming-soon"
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
export function AutomationSettingsSection() {
return (
@ -18,6 +20,7 @@ export function AutomationSettingsSection() {
<TabsTrigger value="rules">Règles</TabsTrigger>
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
<TabsTrigger value="llm">Fournisseurs LLM</TabsTrigger>
<TabsTrigger value="search">Fournisseurs de recherche</TabsTrigger>
<TabsTrigger value="tokens">Tokens API</TabsTrigger>
</TabsList>
@ -28,10 +31,10 @@ export function AutomationSettingsSection() {
<WebhooksPanel />
</TabsContent>
<TabsContent value="llm" className="mt-4">
<SettingsComingSoon
title="Tri par LLM"
description="Configurez des fournisseurs OpenAI-compatibles. Les nœuds LLM utilisent un heuristique en attendant le branchement complet."
/>
<LLMProvidersPanel />
</TabsContent>
<TabsContent value="search" className="mt-4">
<SearchProvidersPanel />
</TabsContent>
<TabsContent value="tokens" className="mt-4">
<SettingsComingSoon

View File

@ -1,4 +1,4 @@
import Script from 'next/script'
'use client'
/** Contenu exécuté avant hydratation (thème + fond, évite flash clair). */
export const THEME_INIT_SCRIPT = `
@ -61,11 +61,23 @@ export const THEME_INIT_SCRIPT = `
})();
`.trim()
/** Script bloquant injecté par Next.js dans le <head> (compatible React 19). */
/**
* Script bloquant dans <head>. SSR rend script exécutable ; côté client type
* inerte pour éviter l'avertissement React 19 (le script a déjà tourné).
*/
export function ThemeInitScript() {
const isServer = typeof window === 'undefined'
return (
<Script id="ultimail-theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<script
id="ultimail-theme-init"
suppressHydrationWarning
{...(isServer
? { dangerouslySetInnerHTML: { __html: THEME_INIT_SCRIPT } }
: {
type: 'application/json' as const,
dangerouslySetInnerHTML: { __html: THEME_INIT_SCRIPT },
})}
/>
)
}

View File

@ -12,13 +12,15 @@ export type ContactsTableColumn =
const COLUMN_WIDTHS: Record<ContactsTableColumn, string> = {
checkbox: "40px",
name: "minmax(0, 2fr)",
email: "minmax(0, 2fr)",
phone: "minmax(0, 1.5fr)",
job: "minmax(0, 1.5fr)",
labels: "minmax(0, 1fr)",
name: "minmax(0px, 2fr)",
email: "minmax(0px, 2fr)",
phone: "minmax(0px, 1.5fr)",
job: "minmax(0px, 1.5fr)",
labels: "minmax(0px, 1fr)",
}
const SSR_COLUMNS: ContactsTableColumn[] = ["checkbox", "name"]
const COLUMN_LABELS: Record<Exclude<ContactsTableColumn, "checkbox">, string> = {
name: "Nom",
email: "E-mail",
@ -36,11 +38,8 @@ function columnsForWidth(width: number): ContactsTableColumn[] {
}
export function useContactsTableColumns() {
const [visibleColumns, setVisibleColumns] = useState<ContactsTableColumn[]>(() =>
typeof window === "undefined"
? ["checkbox", "name", "email", "phone", "job", "labels"]
: columnsForWidth(window.innerWidth)
)
const [visibleColumns, setVisibleColumns] =
useState<ContactsTableColumn[]>(SSR_COLUMNS)
useLayoutEffect(() => {
const update = () => setVisibleColumns(columnsForWidth(window.innerWidth))

View File

@ -1,5 +1,10 @@
import type { ApiContact } from './types'
import type { FullContact, ContactAddress } from '@/lib/contacts/types'
import type { ContactBulkEditField } from '@/lib/contacts/bulk-edit-fields'
import {
avatarUrlToVCardPhotoLine,
parseVCardPhoto,
} from '@/lib/contact-avatar'
interface VCardFields {
fn?: string
@ -10,11 +15,150 @@ interface VCardFields {
bday?: string
note?: string
nickname?: string
url?: string
socialProfiles: { value: string; type: string }[]
ultiLabels?: string[]
photo?: string
addresses: { street?: string; city?: string; region?: string; postalCode?: string; country?: string; type: string }[]
}
const VCARD_TYPE_MAP: Record<string, string> = {
travail: 'WORK',
work: 'WORK',
domicile: 'HOME',
home: 'HOME',
mobile: 'CELL',
cell: 'CELL',
autre: 'OTHER',
other: 'OTHER',
internet: 'INTERNET',
personal: 'HOME',
}
function escapeVCardValue(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n')
}
function vcardTypeParam(label: string): string {
const key = label.trim().toLowerCase()
return VCARD_TYPE_MAP[key] ?? 'OTHER'
}
function foldVCardLine(line: string): string {
const max = 75
if (line.length <= max) return line
const parts: string[] = [line.slice(0, max)]
let rest = line.slice(max)
while (rest.length > 0) {
parts.push(` ${rest.slice(0, max - 1)}`)
rest = rest.slice(max - 1)
}
return parts.join('\r\n')
}
function vcardSocialTypeParam(label: string): string {
const key = label.trim().toLowerCase()
if (key === 'x') return 'twitter'
return key || 'other'
}
function socialProfileLabelFromType(type: string): string {
const key = type.trim().toLowerCase()
if (key === 'x') return 'twitter'
return key || 'other'
}
export function buildVCardFromFullContact(contact: FullContact): string {
const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0']
const uid = contact.id?.trim()
if (uid) lines.push(`UID:${escapeVCardValue(uid)}`)
const fullName = `${contact.firstName} ${contact.lastName}`.trim()
lines.push(
`N:${escapeVCardValue(contact.lastName)};${escapeVCardValue(contact.firstName)};;;`,
)
lines.push(`FN:${escapeVCardValue(fullName || contact.emails[0]?.value || 'Contact')}`)
for (const email of contact.emails) {
const value = email.value?.trim()
if (!value) continue
const type = vcardTypeParam(email.label)
lines.push(`EMAIL;TYPE=${type}:${escapeVCardValue(value)}`)
}
for (const phone of contact.phones) {
const value = phone.value?.trim()
if (!value) continue
const type = vcardTypeParam(phone.label)
lines.push(`TEL;TYPE=${type}:${escapeVCardValue(value)}`)
}
if (contact.company?.trim() || contact.department?.trim()) {
const orgParts = [contact.company?.trim() ?? '', contact.department?.trim() ?? '']
lines.push(`ORG:${orgParts.map(escapeVCardValue).join(';')}`)
}
if (contact.jobTitle?.trim()) {
lines.push(`TITLE:${escapeVCardValue(contact.jobTitle.trim())}`)
}
if (contact.website?.trim()) {
lines.push(`URL:${escapeVCardValue(contact.website.trim())}`)
}
for (const profile of contact.socialProfiles ?? []) {
const value = profile.value?.trim()
if (!value) continue
const type = vcardSocialTypeParam(profile.label)
lines.push(`X-SOCIALPROFILE;TYPE=${type}:${escapeVCardValue(value)}`)
}
for (const addr of contact.addresses ?? []) {
const hasValue =
addr.street?.trim() ||
addr.city?.trim() ||
addr.region?.trim() ||
addr.postalCode?.trim() ||
addr.country?.trim()
if (!hasValue) continue
const type = vcardTypeParam(addr.label)
const adr = [
'',
'',
addr.street?.trim() ?? '',
addr.city?.trim() ?? '',
addr.region?.trim() ?? '',
addr.postalCode?.trim() ?? '',
addr.country?.trim() ?? '',
]
.map(escapeVCardValue)
.join(';')
lines.push(`ADR;TYPE=${type}:${adr}`)
}
if (contact.notes?.trim()) {
lines.push(`NOTE:${escapeVCardValue(contact.notes.trim())}`)
}
if (contact.labels?.length) {
lines.push(
`X-ULTI-LABELS:${contact.labels.map(escapeVCardValue).join(',')}`,
)
}
const photoLine = avatarUrlToVCardPhotoLine(contact.avatarUrl, escapeVCardValue)
if (photoLine) lines.push(photoLine)
lines.push('END:VCARD')
return lines.map(foldVCardLine).join('\r\n')
}
function parseVCard(raw: string): VCardFields {
const fields: VCardFields = { emails: [], phones: [], addresses: [] }
const fields: VCardFields = { emails: [], phones: [], addresses: [], socialProfiles: [] }
const lines: string[] = []
for (const line of raw.split(/\r?\n/)) {
@ -61,6 +205,13 @@ function parseVCard(raw: string): VCardFields {
case 'NOTE':
fields.note = value
break
case 'URL':
fields.url = value
break
case 'X-SOCIALPROFILE':
case 'SOCIALPROFILE':
fields.socialProfiles.push({ value, type: socialProfileLabelFromType(type) })
break
case 'NICKNAME':
fields.nickname = value
break
@ -76,6 +227,15 @@ function parseVCard(raw: string): VCardFields {
})
break
}
case 'X-ULTI-LABELS':
fields.ultiLabels = value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
break
case 'PHOTO':
fields.photo = parseVCardPhoto(rawKey, value) ?? fields.photo
break
}
}
@ -132,6 +292,8 @@ export function apiContactToFullContact(api: ApiContact): FullContact {
return {
id: api.uid,
path: api.path,
etag: api.etag,
firstName,
lastName,
emails,
@ -139,20 +301,177 @@ export function apiContactToFullContact(api: ApiContact): FullContact {
addresses,
company: vcard?.org ?? api.org,
jobTitle: vcard?.title,
website: vcard?.url,
socialProfiles: vcard?.socialProfiles.length
? vcard.socialProfiles.map((p) => ({ value: p.value, label: p.type }))
: undefined,
birthday,
notes: vcard?.note,
nicknames: vcard?.nickname ? [vcard.nickname] : undefined,
labels: vcard?.ultiLabels?.length ? vcard.ultiLabels : undefined,
avatarUrl: vcard?.photo,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
export function fullContactToApiContact(contact: FullContact): Partial<ApiContact> {
const raw_vcard = buildVCardFromFullContact(contact)
const fullName = `${contact.firstName} ${contact.lastName}`.trim()
return {
uid: contact.id,
full_name: `${contact.firstName} ${contact.lastName}`.trim(),
full_name: fullName || contact.emails[0]?.value || 'Contact',
email: contact.emails[0]?.value,
phone: contact.phones[0]?.value,
org: contact.company,
raw_vcard,
}
}
function unfoldVCardLines(raw: string): string[] {
const lines: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (/^\s/.test(line) && lines.length > 0) {
lines[lines.length - 1] += line.trimStart()
} else {
lines.push(line)
}
}
return lines
}
function vcardPropName(line: string): string {
return line.split(':')[0]?.split(';')[0]?.toUpperCase() ?? ''
}
function upsertVCardLine(
lines: string[],
propName: string,
lineContent: string | null,
): string[] {
const upper = propName.toUpperCase()
const kept = lines.filter((line) => vcardPropName(line) !== upper)
if (!lineContent) return kept
const endIdx = kept.findIndex((l) => l.toUpperCase() === 'END:VCARD')
const insertAt = endIdx >= 0 ? endIdx : kept.length
kept.splice(insertAt, 0, lineContent)
return kept
}
function readOrgParts(vcard: string): { company: string; department: string } {
for (const line of unfoldVCardLines(vcard)) {
if (!line.toUpperCase().startsWith('ORG:')) continue
const value = line.slice(line.indexOf(':') + 1)
const parts = value.split(';')
return {
company: (parts[0] ?? '').replace(/\\;/g, ';').replace(/\\\\/g, '\\'),
department: (parts[1] ?? '').replace(/\\;/g, ';').replace(/\\\\/g, '\\'),
}
}
return { company: '', department: '' }
}
/** Patch an existing vCard in-place to avoid losing unparsed fields on bulk edit. */
export function patchVCardBulkField(
rawVCard: string,
contact: FullContact,
field: ContactBulkEditField,
value: string,
): string {
const trimmed = value.trim()
let lines = unfoldVCardLines(rawVCard)
switch (field) {
case 'company': {
const org = readOrgParts(rawVCard)
const company = trimmed
const department = contact.department?.trim() ?? org.department
const orgLine =
company || department
? `ORG:${escapeVCardValue(company)};${escapeVCardValue(department)}`
: null
lines = upsertVCardLine(lines, 'ORG', orgLine)
break
}
case 'department': {
const org = readOrgParts(rawVCard)
const company = contact.company?.trim() ?? org.company
const department = trimmed
const orgLine =
company || department
? `ORG:${escapeVCardValue(company)};${escapeVCardValue(department)}`
: null
lines = upsertVCardLine(lines, 'ORG', orgLine)
break
}
case 'jobTitle':
lines = upsertVCardLine(
lines,
'TITLE',
trimmed ? `TITLE:${escapeVCardValue(trimmed)}` : null,
)
break
case 'website':
lines = upsertVCardLine(
lines,
'URL',
trimmed ? `URL:${escapeVCardValue(trimmed)}` : null,
)
break
case 'notes':
lines = upsertVCardLine(
lines,
'NOTE',
trimmed ? `NOTE:${escapeVCardValue(trimmed)}` : null,
)
break
}
return lines.map(foldVCardLine).join('\r\n')
}
export function patchVCardPhoto(rawVCard: string, avatarUrl: string | undefined): string {
const lines = unfoldVCardLines(rawVCard)
const lineContent = avatarUrlToVCardPhotoLine(avatarUrl, escapeVCardValue)
const next = upsertVCardLine(lines, 'PHOTO', lineContent)
return next.map(foldVCardLine).join('\r\n')
}
export function patchVCardLabels(rawVCard: string, labelIds: string[] | undefined): string {
const lines = unfoldVCardLines(rawVCard)
const lineContent =
labelIds?.length
? `X-ULTI-LABELS:${labelIds.map(escapeVCardValue).join(',')}`
: null
const next = upsertVCardLine(lines, 'X-ULTI-LABELS', lineContent)
return next.map(foldVCardLine).join('\r\n')
}
export function buildContactUpdatePayload(
existing: ApiContact | undefined,
contact: FullContact,
opts?: {
bulkField?: ContactBulkEditField
bulkValue?: string
patchLabels?: boolean
},
): Partial<ApiContact> {
const base = fullContactToApiContact(contact)
const raw = existing?.raw_vcard?.trim()
if (raw && opts?.bulkField !== undefined) {
const patched = patchVCardBulkField(raw, contact, opts.bulkField, opts.bulkValue ?? '')
return { ...base, raw_vcard: patched }
}
if (raw && opts?.patchLabels) {
return { ...base, raw_vcard: patchVCardLabels(raw, contact.labels) }
}
if (raw) {
const patched = patchVCardPhoto(raw, contact.avatarUrl)
return { ...base, raw_vcard: patched }
}
return base
}

View File

@ -83,6 +83,7 @@ class ApiClient {
opts?: {
body?: unknown
params?: Record<string, string | undefined>
headers?: Record<string, string>
timeout?: number
retries?: number
}
@ -118,7 +119,7 @@ class ApiClient {
try {
const response = await fetch(url.toString(), {
method,
headers: this.getHeaders(),
headers: { ...this.getHeaders(), ...opts?.headers },
body: opts?.body ? JSON.stringify(opts.body) : undefined,
signal: controller.signal,
})
@ -211,8 +212,12 @@ class ApiClient {
return this.request<T>("POST", path, { body })
}
async put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PUT", path, { body })
async put<T>(
path: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<T> {
return this.request<T>("PUT", path, { body, headers })
}
async patch<T>(path: string, body?: unknown): Promise<T> {

View File

@ -0,0 +1,80 @@
import type { QueryClient } from '@tanstack/react-query'
import type { ApiContact } from '@/lib/api/types'
import { contactApiPath } from '@/lib/contacts/contact-api-path'
import type { FullContact } from '@/lib/contacts/types'
function contactMatchesPath(api: ApiContact, apiPath: string): boolean {
const normalizedPath = apiPath.replace(/^\/+/, '')
const contactPath = api.path?.replace(/^\/+/, '')
return (
contactPath === normalizedPath ||
api.uid === normalizedPath ||
contactPath?.endsWith(`/${normalizedPath}`) === true ||
contactPath?.endsWith(`/${normalizedPath}.vcf`) === true
)
}
/** Merge selection snapshot with latest React Query cache (etag, path). */
export function resolveContactForUpdate(
queryClient: QueryClient,
contact: FullContact,
): FullContact {
const api = findApiContactInCaches(queryClient, contact)
if (!api) return contact
const etag = contact.etag ?? api.etag
const path = contact.path ?? api.path
if (etag === contact.etag && path === contact.path) return contact
return { ...contact, etag, path }
}
export function findApiContactInCaches(
queryClient: QueryClient,
contact: Pick<FullContact, 'id' | 'path'>,
): ApiContact | undefined {
const apiPath = contactApiPath(contact)
for (const [, list] of queryClient.getQueriesData<ApiContact[]>({
queryKey: ['contacts'],
})) {
if (!list) continue
const hit = list.find(
(c) => c.uid === contact.id || contactMatchesPath(c, apiPath),
)
if (hit) return hit
}
return undefined
}
export function appendContactToBookCache(
queryClient: QueryClient,
bookId: string,
contact: ApiContact,
) {
if (!contact.uid && !contact.path) return
queryClient.setQueryData<ApiContact[]>(['contacts', bookId], (old) => {
const list = old ?? []
const uid = contact.uid
if (uid && list.some((item) => item.uid === uid)) return list
return [...list, contact]
})
}
export function replaceContactInBookCaches(
queryClient: QueryClient,
apiPath: string,
patch: Partial<ApiContact>,
) {
for (const [key, list] of queryClient.getQueriesData<ApiContact[]>({
queryKey: ['contacts'],
})) {
if (!list) continue
let changed = false
const next = list.map((item) => {
if (!contactMatchesPath(item, apiPath)) return item
changed = true
return { ...item, ...patch }
})
if (changed) {
queryClient.setQueryData(key, next)
}
}
}

View File

@ -0,0 +1,99 @@
import { apiContactToFullContact } from '@/lib/api/adapters'
import type { ApiContact } from '@/lib/api/types'
import type { FullContact } from '@/lib/contacts/types'
type BookListCache = {
apiContacts: ApiContact[]
fullContacts: FullContact[]
}
const parseCache = new Map<string, FullContact>()
const parseSigCache = new Map<string, string>()
const listCacheByBook = new Map<string, BookListCache>()
function contactCacheKey(api: ApiContact): string {
return api.uid || api.path || `${api.full_name}:${api.email ?? ''}`
}
function contactSignature(api: ApiContact): string {
return [
api.uid,
api.path,
api.etag,
api.full_name,
api.email,
api.raw_vcard?.length ?? 0,
].join('|')
}
/** Parse vCard une seule fois par contact tant que la signature API est stable. */
export function apiContactToFullContactCached(api: ApiContact): FullContact {
const key = contactCacheKey(api)
const sig = contactSignature(api)
if (parseSigCache.get(key) === sig) {
const hit = parseCache.get(key)
if (hit) return hit
}
const full = apiContactToFullContact(api)
parseCache.set(key, full)
parseSigCache.set(key, sig)
return full
}
function rebuildFullList(bookId: string, apiContacts: ApiContact[]): FullContact[] {
const fullContacts = apiContacts.map(apiContactToFullContactCached)
listCacheByBook.set(bookId, { apiContacts, fullContacts })
return fullContacts
}
/**
* Reconstruit la liste complète seulement si nécessaire.
* Sur ajout en fin de liste (cas courant), réutilise les contacts déjà parsés.
*/
export function mapApiContactsToFullContacts(
bookId: string,
apiContacts: ApiContact[] | undefined,
): FullContact[] {
if (!apiContacts) return []
const prev = listCacheByBook.get(bookId)
if (prev && prev.apiContacts === apiContacts) {
return prev.fullContacts
}
if (prev && apiContacts.length === prev.apiContacts.length + 1) {
let prefixMatch = true
for (let i = 0; i < prev.apiContacts.length; i++) {
if (apiContacts[i] !== prev.apiContacts[i]) {
prefixMatch = false
break
}
}
if (prefixMatch) {
const appended = apiContactToFullContactCached(apiContacts[apiContacts.length - 1]!)
const fullContacts = [...prev.fullContacts, appended]
listCacheByBook.set(bookId, { apiContacts, fullContacts })
return fullContacts
}
}
if (
prev &&
apiContacts.length === prev.apiContacts.length &&
apiContacts.every((c, i) => c === prev.apiContacts[i])
) {
return prev.fullContacts
}
return rebuildFullList(bookId, apiContacts)
}
export function invalidateContactListCache(bookId?: string) {
if (bookId) {
listCacheByBook.delete(bookId)
return
}
listCacheByBook.clear()
parseCache.clear()
parseSigCache.clear()
}

View File

@ -0,0 +1,855 @@
'use client'
import { useMemo } from 'react'
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type InfiniteData,
type QueryClient,
} from '@tanstack/react-query'
import { appendContactToBookCache } from '../contact-book-cache'
import { apiClient, ApiRequestError, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import type { ApiContact } from '../types'
import type {
ApiDiscoveryCounts,
ApiDiscoveryScan,
ApiDiscoveredProfile,
ApiDiscoveredProfileGroup,
ApiDispositionEmails,
ApiEnrichmentSuggestion,
ApiLLMModelsResponse,
ApiLLMSettings,
ApiSearchSettings,
} from '@/lib/contacts/discovery-types'
import {
coerceDiscoveryGroups,
dedupeDiscoveryGroups,
discoveryGroupReactKey,
parseDiscoveryOtherPage,
parseDiscoveryOtherResponse,
type DiscoveryOtherPageResult,
} from '@/lib/contacts/discovery-grouping'
import { filterVisibleEnrichmentSuggestions } from '@/lib/contacts/discovery-utils'
import { useContactsList } from '@/lib/contacts/use-contacts-list'
const ACTIVE_SCAN_STORAGE_KEY = 'ultimail-contact-discovery-scan-id'
export function getPersistedScanId(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(ACTIVE_SCAN_STORAGE_KEY)
}
export function persistScanId(scanId: string | null) {
if (typeof window === 'undefined') return
if (scanId) {
localStorage.setItem(ACTIVE_SCAN_STORAGE_KEY, scanId)
} else {
localStorage.removeItem(ACTIVE_SCAN_STORAGE_KEY)
}
}
export function useDiscoveryCounts() {
return useQuery({
queryKey: ['contact-discovery-counts'],
queryFn: () => apiClient.get<ApiDiscoveryCounts>('/contacts/discovery/counts'),
staleTime: 30_000,
})
}
export const OTHER_CONTACTS_PAGE_SIZE = 12
const OTHER_CONTACTS_QUERY_KEY = ['contact-discovery-other', 'groups', 'infinite'] as const
function otherContactsQueryKey(searchQuery: string) {
const q = searchQuery.trim()
return q ? ([...OTHER_CONTACTS_QUERY_KEY, q] as const) : OTHER_CONTACTS_QUERY_KEY
}
/** Cache session si l'API renvoie encore toute la liste d'un coup (legacy). */
let legacyOtherContactsFullList: ApiDiscoveredProfileGroup[] | null = null
function sliceLegacyOtherContactsPage(offset: number): DiscoveryOtherPageResult | null {
if (!legacyOtherContactsFullList?.length) return null
const groups = legacyOtherContactsFullList.slice(offset, offset + OTHER_CONTACTS_PAGE_SIZE)
return {
groups,
total: legacyOtherContactsFullList.length,
hasMore: offset + OTHER_CONTACTS_PAGE_SIZE < legacyOtherContactsFullList.length,
}
}
export function flattenOtherContactPages(
data: InfiniteData<DiscoveryOtherPageResult> | undefined,
): ApiDiscoveredProfileGroup[] {
if (!data?.pages?.length) return []
return dedupeDiscoveryGroups(data.pages.flatMap((page) => page.groups ?? []))
}
function dedupeOtherContactsInfiniteCache(
data: InfiniteData<DiscoveryOtherPageResult> | undefined,
): InfiniteData<DiscoveryOtherPageResult> | undefined {
if (!data?.pages?.length) return data
const seen = new Set<string>()
const pages = data.pages.map((page) => ({
...page,
groups: dedupeDiscoveryGroups(page.groups ?? []).filter((group) => {
const key = discoveryGroupReactKey(group)
if (seen.has(key)) return false
seen.add(key)
return true
}),
}))
return { ...data, pages }
}
export function useOtherDiscoveredContacts(enabled = true, searchQuery = '') {
const trimmedSearch = searchQuery.trim()
return useInfiniteQuery({
queryKey: otherContactsQueryKey(trimmedSearch),
queryFn: async ({ pageParam }) => {
const offset = typeof pageParam === 'number' ? pageParam : 0
const cachedLegacy = offset > 0 && !trimmedSearch ? sliceLegacyOtherContactsPage(offset) : null
if (cachedLegacy) {
return {
groups: coerceDiscoveryGroups(cachedLegacy.groups),
total: cachedLegacy.total,
hasMore: cachedLegacy.hasMore,
}
}
const res = await apiClient.get<{
groups?: ApiDiscoveredProfileGroup[]
profiles?: ApiDiscoveredProfile[]
total?: number
has_more?: boolean
limit?: number
offset?: number
}>('/contacts/discovery/other', {
limit: String(OTHER_CONTACTS_PAGE_SIZE),
offset: String(offset),
...(trimmedSearch ? { q: trimmedSearch } : {}),
})
const allGroups = coerceDiscoveryGroups(parseDiscoveryOtherResponse(res))
const serverPaginated =
('has_more' in res && res.has_more != null) || typeof res.total === 'number'
if (!serverPaginated && allGroups.length > OTHER_CONTACTS_PAGE_SIZE) {
legacyOtherContactsFullList = allGroups
const groups = allGroups.slice(offset, offset + OTHER_CONTACTS_PAGE_SIZE)
return {
groups,
total: allGroups.length,
hasMore: offset + OTHER_CONTACTS_PAGE_SIZE < allGroups.length,
}
}
legacyOtherContactsFullList = null
const page = parseDiscoveryOtherPage(res, {
offset,
pageSize: OTHER_CONTACTS_PAGE_SIZE,
})
return {
groups: coerceDiscoveryGroups(page.groups ?? []),
total: page.total,
hasMore: page.hasMore,
}
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (!lastPage?.hasMore) return undefined
return allPages.reduce((sum, p) => sum + (p.groups?.length ?? 0), 0)
},
staleTime: 30_000,
enabled,
refetchInterval: (query) => {
const groups = flattenOtherContactPages(query.state.data)
return groups.some((g) => g.profile?.enrichment_status === 'enriching') ? 2000 : false
},
})
}
export function useIgnoredDiscoveredContacts() {
return useQuery({
queryKey: ['contact-discovery-ignored'],
queryFn: async () => {
const res = await apiClient.get<{ profiles: ApiDiscoveredProfile[] }>(
'/contacts/discovery/ignored',
)
return res.profiles ?? []
},
staleTime: 30_000,
})
}
export function useBlockedDiscoveredContacts() {
return useQuery({
queryKey: ['contact-discovery-blocked'],
queryFn: async () => {
const res = await apiClient.get<{ profiles: ApiDiscoveredProfile[] }>(
'/contacts/discovery/blocked',
)
return res.profiles ?? []
},
staleTime: 30_000,
})
}
export function useEnrichmentSuggestions(type?: 'enrich' | 'all') {
return useQuery({
queryKey: ['contact-discovery-suggestions', type],
queryFn: async () => {
const res = await apiClient.get<{ suggestions: ApiEnrichmentSuggestion[] }>(
'/contacts/discovery/suggestions',
type === 'enrich' ? { type: 'enrich' } : undefined,
)
return res.suggestions ?? []
},
staleTime: 30_000,
})
}
/** Enrichment suggestions for existing contacts, excluding values already on the contact. */
export function useVisibleEnrichmentSuggestions() {
const { contacts } = useContactsList()
const query = useEnrichmentSuggestions('enrich')
const suggestions = useMemo(
() => filterVisibleEnrichmentSuggestions(query.data ?? [], contacts),
[query.data, contacts],
)
return { ...query, suggestions }
}
export function useActiveDiscoveryScan() {
return useQuery({
queryKey: ['contact-discovery-scan-active'],
queryFn: async () => {
const res = await apiClient.get<{ scan: ApiDiscoveryScan | null }>(
'/contacts/discovery/scan/active',
)
const scan = res.scan ?? null
if (scan && (scan.status === 'running' || scan.status === 'pending')) {
persistScanId(scan.id)
} else if (!scan) {
persistScanId(null)
}
return scan
},
refetchInterval: (query) => {
const scan = query.state.data
if (scan && (scan.status === 'running' || scan.status === 'pending')) {
return 2000
}
return false
},
staleTime: 0,
})
}
export function useDiscoveryScan(scanId?: string) {
return useQuery({
queryKey: ['contact-discovery-scan', scanId],
queryFn: () => apiClient.get<ApiDiscoveryScan>(`/contacts/discovery/scan/${scanId}`),
enabled: !!scanId,
refetchInterval: (query) => {
const status = query.state.data?.status
return status === 'running' || status === 'pending' ? 2000 : false
},
})
}
export function useStartDiscoveryScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (bookId?: string) => {
const path = bookId
? `/contacts/discovery/scan?book_id=${encodeURIComponent(bookId)}`
: '/contacts/discovery/scan'
return apiClient.post<ApiDiscoveryScan>(path)
},
onSuccess: (scan) => {
persistScanId(scan.id)
queryClient.setQueryData(['contact-discovery-scan-active'], scan)
queryClient.invalidateQueries({ queryKey: ['contact-discovery-scan', scan.id] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-other'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-ignored'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-blocked'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
},
})
}
export function useCancelDiscoveryScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => apiClient.post<void>('/contacts/discovery/scan/cancel'),
onSuccess: () => {
persistScanId(null)
queryClient.setQueryData(['contact-discovery-scan-active'], null)
queryClient.invalidateQueries({ queryKey: ['contact-discovery-scan-active'] })
},
})
}
function groupMatchesProfileId(group: ApiDiscoveredProfileGroup, profileId: string): boolean {
if (group.profile?.id === profileId) return true
if (group.profile_ids?.includes(profileId)) return true
return group.profiles?.some((p) => p.id === profileId) ?? false
}
function findOtherContactGroupInCache(
queryClient: QueryClient,
profileId: string,
): ApiDiscoveredProfileGroup | undefined {
const data = queryClient.getQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
)
if (!data?.pages?.length) return undefined
for (const page of data.pages) {
for (const group of page.groups ?? []) {
if (groupMatchesProfileId(group, profileId)) return group
}
}
return undefined
}
/** Emails liés à un groupe découvert (pour blocage optimiste côté client). */
export function extractDiscoveryGroupEmails(group: ApiDiscoveredProfileGroup): string[] {
const emails = new Set<string>()
if (group.primary_email) emails.add(group.primary_email.toLowerCase())
for (const profile of group.profiles ?? (group.profile ? [group.profile] : [])) {
if (profile.primary_email) emails.add(profile.primary_email.toLowerCase())
for (const entry of profile.all_emails ?? []) {
if (entry.email) emails.add(entry.email.toLowerCase())
}
}
return [...emails]
}
export function removeOtherContactGroupFromCache(
queryClient: QueryClient,
profileId: string,
): boolean {
let removed = false
queryClient.setQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
(old) => {
if (!old) return old
let didRemove = false
const pages = old.pages.map((page) => {
const groups = (page.groups ?? []).filter((group) => {
if (groupMatchesProfileId(group, profileId)) {
didRemove = true
return false
}
return true
})
return { ...page, groups }
})
if (didRemove && pages[0]) {
removed = true
pages[0] = {
...pages[0],
total: Math.max(0, pages[0].total - 1),
}
}
return { ...old, pages }
},
)
if (removed) {
if (legacyOtherContactsFullList) {
legacyOtherContactsFullList = legacyOtherContactsFullList.filter(
(group) => !groupMatchesProfileId(group, profileId),
)
}
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return {
...old,
other_contacts: Math.max(0, old.other_contacts - 1),
}
})
}
return removed
}
interface OptimisticDispositionContext {
previous: InfiniteData<DiscoveryOtherPageResult> | undefined
previousCounts: ApiDiscoveryCounts | undefined
previousIgnored: ApiDiscoveredProfile[] | undefined
previousBlocked: ApiDiscoveredProfile[] | undefined
removedGroup: ApiDiscoveredProfileGroup | undefined
}
function rollbackDispositionContext(
queryClient: QueryClient,
context: OptimisticDispositionContext | undefined,
) {
if (!context) return
if (context.previous) {
queryClient.setQueryData(OTHER_CONTACTS_QUERY_KEY, context.previous)
}
if (context.previousCounts) {
queryClient.setQueryData(['contact-discovery-counts'], context.previousCounts)
}
if (context.previousIgnored) {
queryClient.setQueryData(['contact-discovery-ignored'], context.previousIgnored)
}
if (context.previousBlocked) {
queryClient.setQueryData(['contact-discovery-blocked'], context.previousBlocked)
}
}
function optimisticRemoveFromDispositionLists(
queryClient: QueryClient,
profileId: string,
): Pick<
OptimisticDispositionContext,
'previousIgnored' | 'previousBlocked' | 'previousCounts'
> {
const previousIgnored = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-ignored',
])
const previousBlocked = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-blocked',
])
const previousCounts = queryClient.getQueryData<ApiDiscoveryCounts>([
'contact-discovery-counts',
])
let removedIgnored = false
let removedBlocked = false
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-ignored'], (old) => {
if (!old) return old
const next = old.filter((p) => p.id !== profileId)
removedIgnored = next.length !== old.length
return next
})
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-blocked'], (old) => {
if (!old) return old
const next = old.filter((p) => p.id !== profileId)
removedBlocked = next.length !== old.length
return next
})
if (removedIgnored || removedBlocked) {
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return {
...old,
ignored: removedIgnored ? Math.max(0, old.ignored - 1) : old.ignored,
blocked: removedBlocked ? Math.max(0, old.blocked - 1) : old.blocked,
}
})
}
return { previousIgnored, previousBlocked, previousCounts }
}
function optimisticRemoveOtherContact(
queryClient: QueryClient,
profileId: string,
): OptimisticDispositionContext {
const previous = queryClient.getQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
)
const previousCounts = queryClient.getQueryData<ApiDiscoveryCounts>([
'contact-discovery-counts',
])
const previousIgnored = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-ignored',
])
const previousBlocked = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-blocked',
])
const removedGroup = findOtherContactGroupInCache(queryClient, profileId)
removeOtherContactGroupFromCache(queryClient, profileId)
void queryClient.cancelQueries({ queryKey: OTHER_CONTACTS_QUERY_KEY })
return {
previous,
previousCounts,
previousIgnored,
previousBlocked,
removedGroup,
}
}
function prependIgnoredProfile(
queryClient: QueryClient,
group: ApiDiscoveredProfileGroup | undefined,
) {
const profile = group?.profile ?? group?.profiles?.[0]
if (!profile) return
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-ignored'], (old) => {
const list = old ?? []
if (list.some((p) => p.id === profile.id)) return list
return [{ ...profile, status: 'ignored' as const }, ...list]
})
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return { ...old, ignored: old.ignored + 1 }
})
}
function prependBlockedProfile(
queryClient: QueryClient,
group: ApiDiscoveredProfileGroup | undefined,
) {
const profile = group?.profile ?? group?.profiles?.[0]
if (!profile) return
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-blocked'], (old) => {
const list = old ?? []
if (list.some((p) => p.id === profile.id)) return list
return [{ ...profile, status: 'blocked' as const }, ...list]
})
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return { ...old, blocked: old.blocked + 1 }
})
}
export function useAcceptDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { profileId: string; contactUid?: string }) =>
apiClient.post<void>(`/contacts/discovery/profiles/${vars.profileId}/accept`, {
contact_uid: vars.contactUid ?? '',
}),
onMutate: (vars) => {
return optimisticRemoveOtherContact(queryClient, vars.profileId)
},
onError: (_err, _vars, context) => {
rollbackDispositionContext(queryClient, context)
},
})
}
/** Crée le contact NC puis marque le profil découvert comme accepté (retrait optimiste immédiat). */
export function useAddDiscoveredContact() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: {
bookId: string
profileId: string
contact: Partial<ApiContact>
}) => {
try {
const created = await apiClient.post<ApiContact>(
`/contacts/discovery/profiles/${vars.profileId}/add-to-book`,
{ book_id: vars.bookId, contact: vars.contact },
)
return { created, contactUid: created.uid }
} catch (err) {
if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 405)) {
throw err
}
const created = await apiClient.post<ApiContact | undefined>(
`/contacts/books/${vars.bookId}`,
vars.contact,
)
const contactUid = created?.uid ?? vars.contact.uid ?? ''
await apiClient.post<void>(`/contacts/discovery/profiles/${vars.profileId}/accept`, {
contact_uid: contactUid,
})
const merged: ApiContact = {
...(vars.contact as ApiContact),
...(created ?? {}),
uid: contactUid,
}
return { created: merged, contactUid }
}
},
onMutate: (vars) => {
return optimisticRemoveOtherContact(queryClient, vars.profileId)
},
onSuccess: (data, vars) => {
if (data.created.uid) {
appendContactToBookCache(queryClient, vars.bookId, data.created)
}
},
onError: (err, _vars, context) => {
rollbackDispositionContext(queryClient, context)
if (err instanceof OfflineError) {
enqueue({
id: crypto.randomUUID(),
timestamp: Date.now(),
type: 'create_contact',
payload: { bookId: _vars.bookId, ..._vars.contact },
retries: 0,
})
}
},
})
}
export function useRejectDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<void>(`/contacts/discovery/profiles/${profileId}/reject`),
onMutate: (profileId) => {
const dispositionContext = optimisticRemoveFromDispositionLists(queryClient, profileId)
void queryClient.cancelQueries({ queryKey: ['contact-discovery-ignored'] })
void queryClient.cancelQueries({ queryKey: ['contact-discovery-blocked'] })
return dispositionContext
},
onError: (_err, _profileId, context) => {
if (!context) return
if (context.previousIgnored) {
queryClient.setQueryData(['contact-discovery-ignored'], context.previousIgnored)
}
if (context.previousBlocked) {
queryClient.setQueryData(['contact-discovery-blocked'], context.previousBlocked)
}
if (context.previousCounts) {
queryClient.setQueryData(['contact-discovery-counts'], context.previousCounts)
}
},
})
}
export function useIgnoreDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<ApiDispositionEmails>(
`/contacts/discovery/profiles/${profileId}/ignore`,
),
onMutate: (profileId) => {
const context = optimisticRemoveOtherContact(queryClient, profileId)
prependIgnoredProfile(queryClient, context.removedGroup)
return context
},
onError: (_err, _profileId, context) => {
rollbackDispositionContext(queryClient, context)
},
})
}
export function useBlockDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<ApiDispositionEmails>(
`/contacts/discovery/profiles/${profileId}/block`,
),
onMutate: (profileId) => {
const context = optimisticRemoveOtherContact(queryClient, profileId)
prependBlockedProfile(queryClient, context.removedGroup)
return context
},
onError: (_err, _profileId, context) => {
rollbackDispositionContext(queryClient, context)
},
})
}
export interface ProfileEnrichResponse {
profile_id: string
enrichment_status: ApiDiscoveredProfile['enrichment_status']
}
function patchGroupEnrichmentStatus(
groups: ApiDiscoveredProfileGroup[] | undefined,
profileId: string,
status: ApiDiscoveredProfile['enrichment_status'],
): ApiDiscoveredProfileGroup[] | undefined {
if (!groups) return groups
return groups.map((g) => {
const id = g.profile?.id ?? g.profile_ids[0]
if (id !== profileId) return g
const patchProfile = (p: ApiDiscoveredProfile): ApiDiscoveredProfile => ({
...p,
enrichment_status: status,
})
return {
...g,
profile: g.profile ? patchProfile(g.profile) : g.profile,
profiles: g.profiles?.map(patchProfile),
}
})
}
export function useEnrichDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<ProfileEnrichResponse>(
`/contacts/discovery/profiles/${profileId}/enrich`,
),
onMutate: async (profileId) => {
await queryClient.cancelQueries({ queryKey: OTHER_CONTACTS_QUERY_KEY })
queryClient.setQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
(old) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page) => ({
...page,
groups:
patchGroupEnrichmentStatus(page.groups ?? [], profileId, 'enriching') ??
page.groups ??
[],
})),
}
},
)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contact-discovery-other'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
},
})
}
export function useAcceptEnrichmentSuggestion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (suggestionId: string) =>
apiClient.post<ApiEnrichmentSuggestion>(
`/contacts/discovery/suggestions/${suggestionId}/accept`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
queryClient.invalidateQueries({ queryKey: ['contacts'] })
},
})
}
export function useRejectEnrichmentSuggestion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (suggestionId: string) =>
apiClient.post<void>(`/contacts/discovery/suggestions/${suggestionId}/reject`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
},
})
}
function normalizeLLMSettings(raw: ApiLLMSettings): ApiLLMSettings {
return {
...raw,
default_provider_id: raw.default_provider_id ?? '',
providers: raw.providers ?? [],
}
}
export function useLLMSettings() {
return useQuery({
queryKey: ['llm-settings'],
queryFn: async () => {
const data = await apiClient.get<ApiLLMSettings>('/contacts/discovery/llm-settings')
return normalizeLLMSettings(data)
},
staleTime: 60_000,
})
}
export function useUpdateLLMSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (settings: ApiLLMSettings) =>
apiClient.put<ApiLLMSettings>('/contacts/discovery/llm-settings', settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['llm-settings'] })
},
})
}
function normalizeSearchSettings(raw: ApiSearchSettings): ApiSearchSettings {
return {
...raw,
default_provider_id: raw.default_provider_id ?? '',
providers: raw.providers ?? [],
}
}
export function useSearchSettings() {
return useQuery({
queryKey: ['search-settings'],
queryFn: async () => {
const data = await apiClient.get<ApiSearchSettings>('/contacts/discovery/search-settings')
return normalizeSearchSettings(data)
},
staleTime: 60_000,
})
}
export function useUpdateSearchSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (settings: ApiSearchSettings) =>
apiClient.put<ApiSearchSettings>('/contacts/discovery/search-settings', settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-settings'] })
},
})
}
export function useDiscoverLLMModels(baseUrl: string, apiKey: string) {
const trimmedBaseUrl = baseUrl.trim()
const trimmedApiKey = apiKey.trim()
return useQuery({
queryKey: ['llm-models', trimmedBaseUrl, trimmedApiKey],
queryFn: () =>
apiClient.post<ApiLLMModelsResponse>('/contacts/discovery/llm-models/discover', {
base_url: trimmedBaseUrl,
api_key: trimmedApiKey || undefined,
}),
enabled: trimmedBaseUrl.length > 0,
staleTime: 5 * 60_000,
retry: false,
})
}
export function scanProgressLabel(scan: ApiDiscoveryScan): string {
const phase = scanPhaseLabel(scan.phase)
if (scan.phase === 'enriching') {
const done = scan.profiles_found
const total = scan.profiles_total
if (total > 0) {
return `${phase} ${done.toLocaleString('fr-FR')} / ${total.toLocaleString('fr-FR')} contacts`
}
return phase
}
if (scan.phase === 'building_profiles') {
const done = scan.profiles_found
const total = scan.profiles_total
if (total > 0) {
return `${phase} ${done.toLocaleString('fr-FR')} / ${total.toLocaleString('fr-FR')} profils`
}
return phase
}
if (scan.phase === 'scanning_messages') {
if (scan.total_messages > 0) {
return `${phase} ${scan.messages_scanned.toLocaleString('fr-FR')} / ${scan.total_messages.toLocaleString('fr-FR')} messages`
}
return `${phase} ${scan.messages_scanned.toLocaleString('fr-FR')} messages`
}
return phase
}
export function scanPhaseLabel(phase: ApiDiscoveryScan['phase']): string {
switch (phase) {
case 'scanning_messages':
return 'Analyse des messages…'
case 'building_profiles':
return 'Construction des profils…'
case 'enriching':
return 'Enrichissement IA…'
case 'done':
return 'Terminé'
default:
return 'En attente…'
}
}

View File

@ -3,20 +3,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import { fullContactToApiContact, buildContactUpdatePayload } from '../adapters'
import {
appendContactToBookCache,
replaceContactInBookCaches,
} from '../contact-book-cache'
import { invalidateContactListCache } from '../contact-list-cache'
import { contactApiPath } from '@/lib/contacts/contact-api-path'
import { mergeTwoContacts, mergeManyContacts } from '@/lib/contacts/merge-contacts'
import type { FullContact } from '@/lib/contacts/types'
import type { ApiContact } from '../types'
function appendContactToBookCache(
queryClient: ReturnType<typeof useQueryClient>,
bookId: string,
contact: ApiContact,
) {
queryClient.setQueryData<ApiContact[]>(['contacts', bookId], (old) => {
const list = old ?? []
if (list.some((item) => item.uid === contact.uid)) return list
return [...list, contact]
})
}
export function useCreateContact() {
const queryClient = useQueryClient()
@ -30,9 +27,15 @@ export function useCreateContact() {
return vars.contact as ApiContact
},
onSuccess: (data, vars) => {
if (data?.uid) {
appendContactToBookCache(queryClient, vars.bookId, data)
const contact = data?.uid ? data : (vars.contact as ApiContact)
if (contact?.uid) {
appendContactToBookCache(queryClient, vars.bookId, {
...contact,
etag: contact.etag ?? data?.etag,
path: contact.path ?? data?.path,
})
}
invalidateContactListCache(vars.bookId)
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
},
onError: (err, vars) => {
@ -57,12 +60,28 @@ export function useUpdateContact() {
path: string
contact: Partial<ApiContact>
etag?: string
}) => apiClient.put<ApiContact>(`/contacts/${vars.path}`, {
skipInvalidation?: boolean
}) => {
const ifMatch = vars.etag ?? vars.contact.etag
const headers: Record<string, string> = {}
if (ifMatch) {
headers['If-Match'] = ifMatch
}
const { etag: _bodyEtag, ...contactBody } = vars.contact
const apiPath = vars.path.replace(/^\/+/, '')
return apiClient.put<{ etag?: string }>(`/contacts/${apiPath}`, contactBody, headers)
},
onSuccess: (data, vars) => {
const apiPath = vars.path.replace(/^\/+/, '')
const nextEtag = data?.etag ?? vars.etag
replaceContactInBookCaches(queryClient, apiPath, {
...vars.contact,
etag: vars.etag,
}),
onSuccess: () => {
etag: nextEtag,
})
invalidateContactListCache()
if (!vars.skipInvalidation) {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
}
},
onError: (err, vars) => {
if (err instanceof OfflineError) {
@ -117,6 +136,7 @@ export function useDeleteContact() {
}
},
onSettled: () => {
invalidateContactListCache()
queryClient.invalidateQueries({ queryKey: ['contacts'] })
},
})
@ -129,6 +149,84 @@ export function useMergeDuplicates() {
mutationFn: async (vars: { bookId: string }) =>
apiClient.post(`/contacts/books/${vars.bookId}/merge-duplicates`),
onSuccess: (_data, vars) => {
invalidateContactListCache(vars.bookId)
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
},
})
}
export function useMergeContactPair() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: {
bookId: string
contactA: FullContact
contactB: FullContact
}) => {
const { primary, secondary, merged } = mergeTwoContacts(vars.contactA, vars.contactB)
const primaryPath = contactApiPath(primary)
const secondaryPath = contactApiPath(secondary)
const headers: Record<string, string> = {}
if (primary.etag) {
headers['If-Match'] = primary.etag
}
await apiClient.put(
`/contacts/${primaryPath}`,
fullContactToApiContact(merged),
headers,
)
if (secondaryPath !== primaryPath) {
await apiClient.delete(`/contacts/${secondaryPath}`)
}
},
onSuccess: (_data, vars) => {
invalidateContactListCache(vars.bookId)
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
},
})
}
export function useMergeManyContacts() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: {
bookId: string
contacts: FullContact[]
primaryId?: string
}) => {
const result = mergeManyContacts(vars.contacts, vars.primaryId)
if (!result) {
throw new Error('At least 2 contacts are required to merge')
}
const { primary, secondaries, merged } = result
if (!primary.etag) {
throw new Error('Cannot merge: unknown contact version. Reload the list.')
}
const headers: Record<string, string> = {
'If-Match': primary.etag,
}
await apiClient.put(
`/contacts/${contactApiPath(primary)}`,
fullContactToApiContact(merged),
headers,
)
for (const secondary of secondaries) {
const secondaryPath = contactApiPath(secondary)
if (secondaryPath !== contactApiPath(primary)) {
await apiClient.delete(`/contacts/${secondaryPath}`)
}
}
},
onSuccess: (_data, vars) => {
invalidateContactListCache(vars.bookId)
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
},
})

View File

@ -2,6 +2,7 @@
import { useMemo } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { rankApiContacts } from '@/lib/contacts/contact-match-score'
import { apiClient, OfflineError } from '../client'
import type { ApiContact, ApiContactSyncResponse } from '../types'
@ -11,6 +12,11 @@ type ApiContactBook = { id: string; name: string }
type ApiContactsListResponse = {
contacts: ApiContact[]
pagination?: {
page: number
page_size: number
total?: number
}
}
export function normalizeContactBooksResponse(booksRaw: unknown): ApiContactBook[] {
@ -21,19 +27,40 @@ export function normalizeContactBooksResponse(booksRaw: unknown): ApiContactBook
return []
}
export async function fetchContactByPath(path: string): Promise<ApiContact> {
const apiPath = path.replace(/^\/+/, '')
return apiClient.get<ApiContact>(`/contacts/${apiPath}`)
}
export async function fetchContactsForBook(bookId: string): Promise<ApiContact[]> {
const pageSize = 500
let page = 1
const all: ApiContact[] = []
while (page <= 100) {
const res = await apiClient.get<ApiContact[] | ApiContactsListResponse>(
`/contacts/books/${bookId}`,
{ page: String(page), page_size: String(pageSize) },
)
return Array.isArray(res) ? res : (res.contacts ?? [])
const batch = Array.isArray(res) ? res : (res.contacts ?? [])
all.push(...batch)
if (batch.length < pageSize) break
const total = !Array.isArray(res) ? res.pagination?.total : undefined
if (total != null && all.length >= total) break
page += 1
}
return all
}
export function useDefaultContactBookId() {
const { data: booksRaw } = useContactBooks()
const { data: booksRaw, isLoading, isError } = useContactBooks()
return useMemo(() => {
if (isLoading || isError) return undefined
const books = normalizeContactBooksResponse(booksRaw)
return books[0]?.id ?? FALLBACK_CONTACT_BOOK_ID
}, [booksRaw])
}, [booksRaw, isLoading, isError])
}
export function useContacts(bookId?: string) {
@ -42,7 +69,7 @@ export function useContacts(bookId?: string) {
return useQuery({
queryKey: ['contacts', resolvedBookId],
queryFn: () => fetchContactsForBook(resolvedBookId),
queryFn: () => fetchContactsForBook(resolvedBookId!),
enabled: !!resolvedBookId,
staleTime: 5 * 60_000,
})
@ -79,7 +106,12 @@ export function useSearchContacts(query: string) {
queryKey: ['contacts-search', query],
queryFn: async () => {
try {
return await apiClient.get<ApiContact[]>('/contacts/search', { q: query })
const res = await apiClient.get<ApiContact[] | ApiContactsListResponse>(
'/contacts/search',
{ q: query },
)
const list = Array.isArray(res) ? res : (res.contacts ?? [])
return rankApiContacts(list, query)
} catch (err) {
if (err instanceof OfflineError) {
const cached = queryClient.getQueriesData<ApiContact[]>({
@ -89,13 +121,7 @@ export function useSearchContacts(query: string) {
for (const [, data] of cached) {
if (data) allContacts.push(...data)
}
const q = query.toLowerCase()
return allContacts.filter(
(c) =>
c.full_name.toLowerCase().includes(q) ||
c.email?.toLowerCase().includes(q) ||
c.org?.toLowerCase().includes(q)
)
return rankApiContacts(allContacts, query)
}
throw err
}

View File

@ -0,0 +1,13 @@
'use client'
import { useMutation } from '@tanstack/react-query'
import { apiClient } from '../client'
import type { ApiEnrichedContactData } from '@/lib/contacts/discovery-types'
import type { ImproveContactPayload } from '@/lib/contacts/improve-contact'
export function useImproveContact() {
return useMutation({
mutationFn: (payload: ImproveContactPayload) =>
apiClient.post<ApiEnrichedContactData>('/contacts/improve', payload),
})
}

View File

@ -0,0 +1,46 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
avatarUrlToVCardPhotoLine,
gravatarUrl,
parseVCardPhoto,
} from './contact-avatar.ts'
describe('contact-avatar', () => {
it('builds stable Gravatar URL for normalized email', () => {
assert.equal(
gravatarUrl('Alice@Example.COM', 80),
'https://www.gravatar.com/avatar/7206d8c4dd027ffeae12fb6540cbed2ba?s=80&d=404',
)
})
it('parses embedded vCard PHOTO as data URI', () => {
const photo = parseVCardPhoto('PHOTO;ENCODING=b;TYPE=PNG', 'aGVsbG8=')
assert.equal(photo, 'data:image/png;base64,aGVsbG8=')
})
it('parses vCard PHOTO URI', () => {
assert.equal(
parseVCardPhoto('PHOTO;VALUE=URI', 'https://cdn.example/face.jpg'),
'https://cdn.example/face.jpg',
)
})
it('serializes data URI to vCard PHOTO line', () => {
const line = avatarUrlToVCardPhotoLine(
'data:image/jpeg;base64,/9j/abc',
(v) => v,
)
assert.equal(line, 'PHOTO;ENCODING=b;TYPE=JPEG:/9j/abc')
})
it('skips Gravatar URLs when exporting vCard', () => {
assert.equal(
avatarUrlToVCardPhotoLine(
'https://www.gravatar.com/avatar/abc?s=80&d=404',
(v) => v,
),
null,
)
})
})

230
lib/contact-avatar.ts Normal file
View File

@ -0,0 +1,230 @@
/** MD5 hex digest (Gravatar). Sync, no deps. */
function md5Hex(input: string): string {
const utf8 = unescape(encodeURIComponent(input))
function md5cycle(x: number[], k: number[]) {
let [a, b, c, d] = x
a = ff(a, b, c, d, k[0], 7, -680876936)
d = ff(d, a, b, c, k[1], 12, -389564586)
c = ff(c, d, a, b, k[2], 17, 606105819)
b = ff(b, c, d, a, k[3], 22, -1044525330)
a = ff(a, b, c, d, k[4], 7, -176418897)
d = ff(d, a, b, c, k[5], 12, 1200080426)
c = ff(c, d, a, b, k[6], 17, -1473231341)
b = ff(b, c, d, a, k[7], 22, -45705983)
a = ff(a, b, c, d, k[8], 7, 1770035416)
d = ff(d, a, b, c, k[9], 12, -1958414417)
c = ff(c, d, a, b, k[10], 17, -42063)
b = ff(b, c, d, a, k[11], 22, -1990404162)
a = ff(a, b, c, d, k[12], 7, 1804603682)
d = ff(d, a, b, c, k[13], 12, -40341101)
c = ff(c, d, a, b, k[14], 17, -1502002290)
b = ff(b, c, d, a, k[15], 22, 1236535329)
a = gg(a, b, c, d, k[1], 5, -165796510)
d = gg(d, a, b, c, k[6], 9, -1069501632)
c = gg(c, d, a, b, k[11], 14, 643717713)
b = gg(b, c, d, a, k[0], 20, -373897302)
a = gg(a, b, c, d, k[5], 5, -701558691)
d = gg(d, a, b, c, k[10], 9, 38016083)
c = gg(c, d, a, b, k[15], 14, -660478335)
b = gg(b, c, d, a, k[4], 20, -405537848)
a = gg(a, b, c, d, k[9], 5, 568446438)
d = gg(d, a, b, c, k[14], 9, -1019803690)
c = gg(c, d, a, b, k[3], 14, -187363961)
b = gg(b, c, d, a, k[8], 20, 1163531501)
a = gg(a, b, c, d, k[13], 5, -1444681467)
d = gg(d, a, b, c, k[2], 9, -51403784)
c = gg(c, d, a, b, k[7], 14, 1735328473)
b = gg(b, c, d, a, k[12], 20, -1926607734)
a = hh(a, b, c, d, k[5], 4, -378558)
d = hh(d, a, b, c, k[8], 11, -2022574463)
c = hh(c, d, a, b, k[11], 16, 1839030562)
b = hh(b, c, d, a, k[14], 23, -35309556)
a = hh(a, b, c, d, k[1], 4, -1530992060)
d = hh(d, a, b, c, k[4], 11, 1272893353)
c = hh(c, d, a, b, k[7], 16, -155497632)
b = hh(b, c, d, a, k[10], 23, -1094730640)
a = hh(a, b, c, d, k[13], 4, 681279174)
d = hh(d, a, b, c, k[0], 11, -358537222)
c = hh(c, d, a, b, k[3], 16, -722521979)
b = hh(b, c, d, a, k[6], 23, 76029189)
a = hh(a, b, c, d, k[9], 4, -640364487)
d = hh(d, a, b, c, k[12], 11, -421815835)
c = hh(c, d, a, b, k[15], 16, 530742520)
b = hh(b, c, d, a, k[2], 23, -995338651)
a = ii(a, b, c, d, k[0], 6, -198630844)
d = ii(d, a, b, c, k[7], 10, 1126891415)
c = ii(c, d, a, b, k[14], 15, -1416354905)
b = ii(b, c, d, a, k[5], 21, -57434055)
a = ii(a, b, c, d, k[12], 6, 1700485571)
d = ii(d, a, b, c, k[3], 10, -1894986606)
c = ii(c, d, a, b, k[10], 15, -1051523)
b = ii(b, c, d, a, k[1], 21, -2054922799)
a = ii(a, b, c, d, k[8], 6, 1873313359)
d = ii(d, a, b, c, k[15], 10, -30611744)
c = ii(c, d, a, b, k[6], 15, -1560198380)
b = ii(b, c, d, a, k[13], 21, 1309151649)
a = ii(a, b, c, d, k[4], 6, -145523070)
d = ii(d, a, b, c, k[11], 10, -1120210379)
c = ii(c, d, a, b, k[2], 15, 718787259)
b = ii(b, c, d, a, k[9], 21, -343485551)
x[0] = add32(a, x[0])
x[1] = add32(b, x[1])
x[2] = add32(c, x[2])
x[3] = add32(d, x[3])
}
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
a = add32(add32(a, q), add32(x, t))
return add32((a << s) | (a >>> (32 - s)), b)
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & c) | (~b & d), a, b, x, s, t)
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(b ^ c ^ d, a, b, x, s, t)
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(c ^ (b | ~d), a, b, x, s, t)
}
function add32(a: number, b: number) {
return (a + b) & 0xffffffff
}
const n = utf8.length
const state = [1732584193, -271733879, -1732584194, 271733878]
let i = 0
for (; i + 64 <= n; i += 64) {
const block: number[] = []
for (let j = 0; j < 64; j += 4) {
block[j >> 2] =
utf8.charCodeAt(i + j) |
(utf8.charCodeAt(i + j + 1) << 8) |
(utf8.charCodeAt(i + j + 2) << 16) |
(utf8.charCodeAt(i + j + 3) << 24)
}
md5cycle(state, block)
}
const tail = new Array<number>(16).fill(0)
for (let j = i; j < n; j++) {
tail[(j - i) >> 2] |= utf8.charCodeAt(j) << ((j - i) % 4) * 8
}
tail[(n - i) >> 2] |= 0x80 << ((n - i) % 4) * 8
if ((n - i) > 55) {
md5cycle(state, tail)
tail.fill(0)
}
tail[14] = n * 8
md5cycle(state, tail)
return state
.map((v) => {
let s = ''
for (let j = 0; j < 4; j++) {
s += ((v >> (j * 8)) & 0xff).toString(16).padStart(2, '0')
}
return s
})
.join('')
}
export function gravatarUrl(email: string, size = 80): string | undefined {
const normalized = email.trim().toLowerCase()
if (!normalized || !normalized.includes('@')) return undefined
const hash = md5Hex(normalized)
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=404`
}
export function isGravatarUrl(url: string | undefined): boolean {
return !!url && /gravatar\.com\/avatar\//i.test(url)
}
export function primaryContactEmail(contact: {
emails?: { value: string }[]
}): string | undefined {
const email = contact.emails?.find((e) => e.value?.trim())?.value?.trim()
return email || undefined
}
/** Parse a vCard PHOTO property into a displayable URL / data URI. */
export function parseVCardPhoto(rawKey: string, value: string): string | undefined {
if (!rawKey.toUpperCase().startsWith('PHOTO') || !value.trim()) return undefined
const params = rawKey.split(';').slice(1).join(';').toUpperCase()
const trimmed = value.trim()
if (params.includes('VALUE=URI') || /^https?:\/\//i.test(trimmed)) {
return trimmed.replace(/^uri:/i, '')
}
if (trimmed.startsWith('data:')) return trimmed
let mime = 'image/jpeg'
const typeMatch = params.match(/(?:TYPE|MEDIATYPE)=([^;,]+)/)
if (typeMatch) {
const t = typeMatch[1].toLowerCase()
if (t === 'png') mime = 'image/png'
else if (t === 'gif') mime = 'image/gif'
else if (t === 'webp') mime = 'image/webp'
else if (t.includes('/')) mime = t
}
return `data:${mime};base64,${trimmed.replace(/\s/g, '')}`
}
/** Build a vCard PHOTO line from avatarUrl (skip Gravatar — computed at display time). */
export function avatarUrlToVCardPhotoLine(
avatarUrl: string | undefined,
escapeValue: (v: string) => string,
): string | null {
const url = avatarUrl?.trim()
if (!url || isGravatarUrl(url)) return null
if (url.startsWith('data:')) {
const match = url.match(/^data:([^;]+);base64,([\s\S]+)$/)
if (!match) return null
const mime = match[1].toLowerCase()
const b64 = match[2].replace(/\s/g, '')
const type = mime.includes('png') ? 'PNG' : mime.includes('gif') ? 'GIF' : 'JPEG'
return `PHOTO;ENCODING=b;TYPE=${type}:${b64}`
}
if (/^https?:\/\//i.test(url)) {
return `PHOTO;VALUE=uri:${escapeValue(url)}`
}
return null
}
const MAX_AVATAR_BYTES = 512 * 1024
const ACCEPTED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
])
export async function readAvatarFromFile(file: File): Promise<string> {
if (!ACCEPTED_IMAGE_TYPES.has(file.type)) {
throw new Error('Format non pris en charge. Utilisez JPEG, PNG, GIF ou WebP.')
}
if (file.size > MAX_AVATAR_BYTES) {
throw new Error('Image trop volumineuse (max 512 Ko).')
}
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Impossible de lire l\'image.'))
}
reader.onerror = () => reject(new Error('Impossible de lire l\'image.'))
reader.readAsDataURL(file)
})
}

View File

@ -38,7 +38,18 @@ export const CONTACTS_ICON_BTN_CLASS =
"text-muted-foreground hover:bg-accent hover:text-foreground"
export const CONTACTS_TABLE_HEADER_CLASS =
"grid gap-2 border-b border-border py-2 text-xs font-medium text-muted-foreground"
"grid items-center gap-2 border-b border-border py-1.5 text-sm font-medium text-muted-foreground"
export const CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS =
"flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-muted/60 -m-0.5"
export const CONTACTS_TABLE_TOOLBAR_CLASS =
"mb-2 flex h-10 items-center justify-between gap-2"
/** Toolbar + en-têtes colonnes — sticky ensemble au scroll. */
export const CONTACTS_TABLE_STICKY_HEAD_CLASS = cn(
"sticky top-0 z-10 -mx-3 bg-app-canvas px-3 sm:-mx-6 sm:px-6",
)
export const CONTACTS_TABLE_ROW_CLASS = cn(
"grid w-full cursor-pointer items-center gap-2 border-b border-border py-2.5 text-left text-sm",
@ -161,9 +172,62 @@ export const CONTACTS_PAGE_SECTION_TITLE_CLASS = "text-lg font-normal text-foreg
export const CONTACTS_PAGE_HEADING_CLASS = cn("font-normal", CONTACTS_HEADING_TEXT)
export const CONTACTS_PAGE_CARD_CLASS = "rounded-xl border border-border p-5"
export const CONTACTS_PAGE_CARD_CLASS = cn(
"min-w-0 rounded-xl border border-mail-border bg-mail-surface p-5 shadow-sm",
"dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
)
export const CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS = "mt-3 border-t border-border pt-3"
/** Conteneur masonry discovery (2 colonnes flex en lg+) */
export const CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS = "flex flex-col gap-5"
export const CONTACTS_DISCOVERY_CARD_MASONRY_COLUMN_CLASS =
"flex min-w-0 flex-1 flex-col gap-5"
export const CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_ENTER_CLASS = cn(
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300 ease-out",
"motion-reduce:animate-none",
)
/** Sentinel chargement progressif — pleine largeur sous les colonnes */
export const CONTACTS_DISCOVERY_CARD_MASONRY_SENTINEL_CLASS = cn(
"flex w-full flex-col items-center justify-center py-6",
)
/** @deprecated */
export const CONTACTS_DISCOVERY_CARD_MASONRY_CLASS = CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS
/** @deprecated */
export const CONTACTS_DISCOVERY_CARD_MASONRY_ITEM_CLASS = "min-w-0"
/** @deprecated Utiliser DiscoveryCardsMasonry */
export const CONTACTS_DISCOVERY_CARD_GRID_CLASS = CONTACTS_DISCOVERY_CARD_MASONRY_ROOT_CLASS
export const CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS =
"mt-3 border-t border-mail-border pt-3"
/** Chips discovery (signatures, champs suggérés) — lisibles sur carte en dark mode */
export const CONTACTS_DISCOVERY_CHIP_CLASS = cn(
"inline-flex max-w-full items-center gap-0.5 rounded-full border px-2 py-0.5 text-xs",
"border-mail-list-chip-border bg-mail-list-chip-muted text-mail-list-chip-text",
)
/** Panneau secondaire dans une carte discovery (signature, détail champ) */
export const CONTACTS_DISCOVERY_INNER_PANEL_CLASS = cn(
"rounded-md border border-mail-border bg-mail-surface-muted/70 p-2",
"dark:border-mail-border dark:bg-mail-surface-muted",
)
/** Ligne champ par champ (Ajouter des coordonnées) */
export const CONTACTS_DISCOVERY_FIELD_ROW_CLASS = cn(
"flex items-center justify-between gap-2 rounded-md border border-mail-border bg-mail-surface-muted/50 px-2 py-1.5",
"dark:bg-mail-surface-muted",
)
/** Cellule de groupe dans la grille dense des cartes discovery */
export const CONTACTS_DISCOVERY_GRID_CELL_CLASS = cn(
"min-w-0 rounded-lg border border-mail-border-subtle bg-mail-surface-muted/40 p-2",
"dark:border-mail-border-subtle dark:bg-mail-surface-muted/70",
)
export const CONTACTS_PAGE_LINK_BTN_CLASS =
"text-sm font-medium text-primary hover:text-primary/80"
@ -181,8 +245,10 @@ export const CONTACTS_PAGE_TAG_CLASS = cn(
export const CONTACTS_PAGE_BANNER_CLASS =
"mb-4 flex items-center justify-between rounded-lg bg-muted px-4 py-3"
export const CONTACTS_PAGE_INFO_BANNER_CLASS =
"mb-6 flex items-start gap-4 rounded-xl bg-muted p-5"
export const CONTACTS_PAGE_INFO_BANNER_CLASS = cn(
"mb-6 flex items-start gap-4 rounded-xl border border-mail-border bg-mail-surface-muted p-5",
"dark:bg-mail-surface dark:border-mail-border",
)
export const CONTACTS_PAGE_INFO_BANNER_ICON_CLASS =
"flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/15"

View File

@ -0,0 +1,57 @@
import type { FullContact } from "./types"
export type ContactBulkEditField =
| "company"
| "department"
| "jobTitle"
| "website"
| "notes"
export const CONTACT_BULK_EDIT_FIELDS: {
id: ContactBulkEditField
label: string
}[] = [
{ id: "company", label: "Entreprise" },
{ id: "department", label: "Service" },
{ id: "jobTitle", label: "Poste" },
{ id: "website", label: "Site web" },
{ id: "notes", label: "Notes" },
]
export function getContactBulkFieldValue(
contact: FullContact,
field: ContactBulkEditField,
): string {
const raw = contact[field]
return typeof raw === "string" ? raw.trim() : ""
}
export function collectBulkFieldSuggestions(
contacts: FullContact[],
field: ContactBulkEditField,
): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const contact of contacts) {
const value = getContactBulkFieldValue(contact, field)
if (!value) continue
const key = value.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(value)
}
return out.sort((a, b) => a.localeCompare(b, "fr"))
}
export function applyBulkFieldValue(
contact: FullContact,
field: ContactBulkEditField,
value: string,
): FullContact {
const trimmed = value.trim()
return {
...contact,
[field]: trimmed || undefined,
updatedAt: Date.now(),
}
}

View File

@ -0,0 +1,10 @@
import type { FullContact } from '@/lib/contacts/types'
/** Chemin API CardDAV pour PUT/DELETE (sans slash initial). */
export function contactApiPath(contact: Pick<FullContact, 'path' | 'id'>): string {
let path = (contact.path ?? contact.id).replace(/^\/+/, '')
if (path.startsWith('cloud/')) {
path = path.slice('cloud/'.length)
}
return path
}

View File

@ -0,0 +1,127 @@
import type { ApiContact } from "@/lib/api/types"
import type { FullContact } from "./types"
import { fullContactDisplayName } from "./types"
export function normalizeContactSearchText(value: string): string {
return value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
}
function queryTokens(query: string): string[] {
return normalizeContactSearchText(query).split(/\s+/).filter(Boolean)
}
/** Score 0-1. Strict substring match only (no fuzzy). Higher = closer match. */
export function fieldMatchScore(haystack: string, needle: string): number {
const h = normalizeContactSearchText(haystack)
const n = normalizeContactSearchText(needle)
if (!n || !h.includes(n)) return 0
if (h === n) return 1
if (h.startsWith(n)) {
return 0.95 + 0.05 * (n.length / h.length)
}
for (const word of h.split(/[\s@._+-]+/)) {
if (!word) continue
if (word.startsWith(n)) {
return 0.88 + 0.07 * (n.length / word.length)
}
}
const idx = h.indexOf(n)
const positionBonus = 1 - (idx / Math.max(h.length, 1)) * 0.35
const lengthBonus = n.length / Math.max(h.length, 1)
return 0.42 + 0.28 * positionBonus + 0.22 * lengthBonus
}
function scoreAgainstFields(fields: string[], query: string): number {
const tokens = queryTokens(query)
if (tokens.length === 0) return 0
let total = 0
for (const token of tokens) {
let best = 0
for (const field of fields) {
if (!field) continue
best = Math.max(best, fieldMatchScore(field, token))
best = Math.max(best, fieldMatchScore(field, query))
}
if (best === 0) return 0
total += best
}
return total / tokens.length
}
function fullContactSearchFields(contact: FullContact): string[] {
const fields = [
fullContactDisplayName(contact),
contact.firstName,
contact.lastName,
contact.middleName,
contact.company,
contact.department,
contact.jobTitle,
contact.website,
contact.notes,
...(contact.nicknames ?? []),
...contact.emails.map((e) => e.value),
...contact.phones.map((p) => p.value),
...(contact.addresses ?? []).flatMap((a) => [
a.street,
a.city,
a.region,
a.postalCode,
a.country,
]),
]
return fields.filter((value): value is string => Boolean(value?.trim()))
}
function apiContactSearchFields(contact: ApiContact): string[] {
return [contact.full_name, contact.email, contact.phone, contact.org].filter(
(value): value is string => Boolean(value?.trim())
)
}
export function scoreFullContact(contact: FullContact, query: string): number {
return scoreAgainstFields(fullContactSearchFields(contact), query)
}
export function scoreApiContact(contact: ApiContact, query: string): number {
return scoreAgainstFields(apiContactSearchFields(contact), query)
}
export function rankFullContacts(contacts: FullContact[], query: string): FullContact[] {
const trimmed = query.trim()
if (!trimmed) return contacts
return contacts
.map((contact) => ({ contact, score: scoreFullContact(contact, trimmed) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
const nameA = fullContactDisplayName(a.contact) || a.contact.emails[0]?.value || ""
const nameB = fullContactDisplayName(b.contact) || b.contact.emails[0]?.value || ""
return nameA.localeCompare(nameB, "fr")
})
.map((entry) => entry.contact)
}
export function rankApiContacts(contacts: ApiContact[], query: string): ApiContact[] {
const trimmed = query.trim()
if (!trimmed) return contacts
return contacts
.map((contact) => ({ contact, score: scoreApiContact(contact, trimmed) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return a.contact.full_name.localeCompare(b.contact.full_name, "fr")
})
.map((entry) => entry.contact)
}

View File

@ -4,6 +4,7 @@ import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import type { FullContact } from "./types"
import { mergePairKey } from "./duplicate-detection"
type ContactsView = "list" | "view" | "create" | "edit"
@ -149,7 +150,7 @@ export const useContactsStore = create<ContactsStore>()(
ignoreMergePair: (idA, idB) =>
set((s) => {
const key = [idA, idB].sort().join("::")
const key = mergePairKey(idA, idB)
if (s.ignoredMergePairs.includes(key)) return s
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
}),

View File

@ -0,0 +1,224 @@
import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from './discovery-types'
import type { FullContact } from './types'
import { FIELD_LABELS, discoveredProfileToFullContact } from './discovery-utils'
import type { ContactAddress } from './types'
export interface DraftField {
id: string
label: string
fieldKey: string
value: string
removed: boolean
}
function fieldId(fieldKey: string, value: string) {
return `${fieldKey}::${value}`
}
/** Clé de déduplication par valeur normalisée (indépendante du libellé / source). */
function normalizedValueKey(fieldKey: string, value: string): string {
const v = value.trim()
if (fieldKey === 'emails') {
return `emails:${v.toLowerCase()}`
}
if (fieldKey === 'phones') {
const digits = v.replace(/\D/g, '')
return digits.length >= 6 ? `phones:${digits}` : `phones:${v.toLowerCase()}`
}
if (fieldKey === 'addresses') {
return `addresses:${v.toLowerCase()}`
}
return `${fieldKey}:${v.toLowerCase()}`
}
function pushDraftField(
out: DraftField[],
seen: Set<string>,
fieldKey: string,
value: string,
label: string,
) {
const v = value.trim()
if (!v) return
const dedupeKey = normalizedValueKey(fieldKey, v)
if (seen.has(dedupeKey)) return
seen.add(dedupeKey)
out.push({
id: fieldId(fieldKey, fieldKey === 'emails' ? v.toLowerCase() : v),
label,
fieldKey,
value: v,
removed: false,
})
}
function pushScalar(
out: DraftField[],
fieldKey: string,
value: string | undefined,
seen: Set<string>,
) {
const v = value?.trim()
if (!v) return
pushDraftField(out, seen, fieldKey, v, FIELD_LABELS[fieldKey] ?? fieldKey)
}
function pushLabeled(
out: DraftField[],
fieldKey: string,
items: { value: string; label?: string }[] | undefined,
seen: Set<string>,
) {
for (const item of items ?? []) {
const v = item.value?.trim()
if (!v) continue
const suffix = item.label ? ` (${item.label})` : ''
pushDraftField(
out,
seen,
fieldKey,
v,
`${FIELD_LABELS[fieldKey] ?? fieldKey}${suffix}`,
)
}
}
export function buildDraftFields(group: ApiDiscoveredProfileGroup): DraftField[] {
const profile = group.profile ?? group.profiles?.[0]
if (!profile) return []
const data = profile.enriched_data
const seen = new Set<string>()
const out: DraftField[] = []
pushScalar(out, 'first_name', data?.first_name, seen)
pushScalar(out, 'last_name', data?.last_name, seen)
pushScalar(out, 'company', data?.company, seen)
pushScalar(out, 'department', data?.department, seen)
pushScalar(out, 'job_title', data?.job_title, seen)
pushScalar(out, 'website', data?.website, seen)
pushLabeled(out, 'social_profiles', data?.social_profiles, seen)
pushScalar(out, 'notes', data?.notes, seen)
pushLabeled(out, 'emails', data?.emails, seen)
pushLabeled(out, 'phones', data?.phones, seen)
for (const addr of data?.addresses ?? []) {
const parts = [addr.street, addr.city, addr.region, addr.postal_code, addr.country]
.map((p) => p?.trim())
.filter(Boolean)
if (parts.length === 0) continue
const v = parts.join(', ')
pushDraftField(
out,
seen,
'addresses',
v,
addr.label ? `${FIELD_LABELS.addresses} (${addr.label})` : FIELD_LABELS.addresses,
)
}
for (const p of group.profiles ?? [profile]) {
for (const e of p.all_emails ?? []) {
const v = e.email?.trim()
if (!v) continue
pushDraftField(out, seen, 'emails', v, FIELD_LABELS.emails)
}
const primary = p.primary_email?.trim()
if (primary) {
pushDraftField(out, seen, 'emails', primary, FIELD_LABELS.emails)
}
}
return out
}
function draftFieldContactLabel(field: DraftField): string {
const base = FIELD_LABELS[field.fieldKey] ?? field.fieldKey
if (field.label.startsWith(base)) {
const suffix = field.label.slice(base.length).trim()
if (suffix.startsWith('(') && suffix.endsWith(')')) {
return suffix.slice(1, -1)
}
}
return 'Autre'
}
function parseAddressDraftValue(value: string, label: string): ContactAddress {
const parts = value
.split(',')
.map((p) => p.trim())
.filter(Boolean)
if (parts.length >= 5) {
return {
street: parts[0],
city: parts[1],
region: parts[2],
postalCode: parts[3],
country: parts.slice(4).join(', '),
label,
}
}
return { street: value, label }
}
export function applyDraftToContact(
profile: ApiDiscoveredProfile | undefined,
fields: DraftField[],
): FullContact {
if (!profile) {
throw new Error('profile is required to build contact')
}
const base = discoveredProfileToFullContact(profile)
const active = fields.filter((f) => !f.removed)
const scalar = (key: string) => active.find((f) => f.fieldKey === key)?.value
const firstName = scalar('first_name')
const lastName = scalar('last_name')
if (firstName) base.firstName = firstName
if (lastName) base.lastName = lastName
if (scalar('company')) base.company = scalar('company')
if (scalar('department')) base.department = scalar('department')
if (scalar('job_title')) base.jobTitle = scalar('job_title')
if (scalar('website')) base.website = scalar('website')
if (scalar('notes')) base.notes = scalar('notes')
const hasSocialFields = fields.some((f) => f.fieldKey === 'social_profiles')
const socialProfiles = active
.filter((f) => f.fieldKey === 'social_profiles')
.map((f) => ({
value: f.value,
label: draftFieldContactLabel(f),
}))
if (hasSocialFields) {
base.socialProfiles = socialProfiles.length > 0 ? socialProfiles : undefined
}
const hasEmailFields = fields.some((f) => f.fieldKey === 'emails')
const emails = active
.filter((f) => f.fieldKey === 'emails')
.map((f) => ({
value: f.value,
label: draftFieldContactLabel(f),
}))
if (hasEmailFields) base.emails = emails
const hasPhoneFields = fields.some((f) => f.fieldKey === 'phones')
const phones = active
.filter((f) => f.fieldKey === 'phones')
.map((f) => ({
value: f.value,
label: draftFieldContactLabel(f),
}))
if (hasPhoneFields) base.phones = phones
const hasAddressFields = fields.some((f) => f.fieldKey === 'addresses')
const addresses = active
.filter((f) => f.fieldKey === 'addresses')
.map((f) => parseAddressDraftValue(f.value, draftFieldContactLabel(f)))
if (hasAddressFields) {
base.addresses = addresses.length > 0 ? addresses : undefined
}
return base
}

View File

@ -0,0 +1,164 @@
import type {
ApiDiscoveredProfile,
ApiDiscoveredProfileGroup,
ApiDiscoveryOtherPage,
} from './discovery-types'
import { profileDisplayName } from './discovery-utils'
export function profileToDiscoveryGroup(
profile: ApiDiscoveredProfile,
): ApiDiscoveredProfileGroup {
return {
group_key: profile.id,
profile_ids: [profile.id],
display_name: profileDisplayName(profile),
primary_email: profile.primary_email,
message_count: profile.message_count,
profile,
profiles: [profile],
}
}
function isDiscoveredProfile(value: unknown): value is ApiDiscoveredProfile {
if (!value || typeof value !== 'object') return false
const row = value as Record<string, unknown>
return typeof row.id === 'string' && typeof row.primary_email === 'string' && !('profile' in row)
}
export function normalizeDiscoveryGroup(
group: ApiDiscoveredProfileGroup,
): ApiDiscoveredProfileGroup | null {
const profile = group.profile ?? group.profiles?.[0]
if (!profile?.id) return null
const profileIds =
group.profile_ids?.length > 0 ? group.profile_ids : [profile.id]
return {
...group,
group_key: group.group_key || profileIds[0] || profile.id,
profile_ids: profileIds,
profile,
profiles: group.profiles?.length ? group.profiles : [profile],
display_name: group.display_name || profileDisplayName(profile),
primary_email: group.primary_email || profile.primary_email,
message_count: group.message_count ?? profile.message_count,
}
}
export interface DiscoveryOtherPageResult {
groups: ApiDiscoveredProfileGroup[]
total: number
hasMore: boolean
}
export function parseDiscoveryOtherPage(
res: ApiDiscoveryOtherPage | {
groups?: ApiDiscoveredProfileGroup[]
profiles?: ApiDiscoveredProfile[]
total?: number
has_more?: boolean
limit?: number
offset?: number
},
options?: { offset?: number; pageSize?: number },
): DiscoveryOtherPageResult {
const offset = options?.offset ?? 0
const pageSize = options?.pageSize ?? 20
const allGroups = parseDiscoveryOtherResponse(res)
const hasServerHasMore = 'has_more' in res && res.has_more != null
const hasServerTotal = typeof res.total === 'number'
const serverLimit = typeof res.limit === 'number' ? res.limit : undefined
// Réponse legacy : tous les groupes en une fois sans pagination
const looksUnpaginated =
!hasServerHasMore &&
!hasServerTotal &&
allGroups.length > pageSize &&
(serverLimit == null || serverLimit >= allGroups.length)
if (looksUnpaginated) {
const groups = allGroups.slice(offset, offset + pageSize)
return {
groups,
total: allGroups.length,
hasMore: offset + pageSize < allGroups.length,
}
}
const groups = allGroups
const total = hasServerTotal ? res.total! : groups.length + offset
const hasMore = hasServerHasMore
? Boolean(res.has_more)
: hasServerTotal
? offset + groups.length < res.total!
: groups.length >= pageSize
return { groups, total, hasMore }
}
export function parseDiscoveryOtherResponse(res: {
groups?: ApiDiscoveredProfileGroup[]
profiles?: ApiDiscoveredProfile[]
}): ApiDiscoveredProfileGroup[] {
if (res.groups?.length) {
const normalized: ApiDiscoveredProfileGroup[] = []
for (const group of res.groups) {
const row = normalizeDiscoveryGroup(group)
if (row) normalized.push(row)
}
return normalized
}
if (res.profiles?.length) {
return res.profiles.map(profileToDiscoveryGroup)
}
return []
}
/** Stable React key — prefer person group id when several profiles are merged. */
export function discoveryGroupReactKey(group: ApiDiscoveredProfileGroup): string {
const profileIds = (group.profile_ids ?? []).filter(Boolean)
const profileId = group.profile?.id ?? profileIds[0]
const groupKey = group.group_key?.trim()
if (groupKey && profileIds.length > 1) {
return groupKey
}
if (groupKey && profileId && groupKey !== profileId) {
return groupKey
}
return profileId ?? groupKey ?? 'unknown'
}
/** Remove duplicate groups (infinite scroll / refetch overlap). */
export function dedupeDiscoveryGroups(
groups: ApiDiscoveredProfileGroup[],
): ApiDiscoveredProfileGroup[] {
const seen = new Set<string>()
const out: ApiDiscoveredProfileGroup[] = []
for (const group of groups) {
const normalized = normalizeDiscoveryGroup(group)
if (!normalized) continue
const key = discoveryGroupReactKey(normalized)
if (seen.has(key)) continue
seen.add(key)
out.push(normalized)
}
return out
}
/** Coerce cached query rows that may still be flat profiles. */
export function coerceDiscoveryGroups(data: unknown): ApiDiscoveredProfileGroup[] {
if (!Array.isArray(data) || data.length === 0) return []
if (isDiscoveredProfile(data[0])) {
return (data as ApiDiscoveredProfile[]).map(profileToDiscoveryGroup)
}
const out: ApiDiscoveredProfileGroup[] = []
for (const row of data as ApiDiscoveredProfileGroup[]) {
const normalized = normalizeDiscoveryGroup(row)
if (normalized) out.push(normalized)
}
return out
}

View File

@ -0,0 +1,155 @@
export interface ApiLLMProvider {
id: string
name: string
base_url: string
api_key?: string
default_model: string
}
export interface ApiLLMSettings {
default_provider_id: string
providers: ApiLLMProvider[]
contact_discovery_model?: string
contact_discovery_provider_id?: string
}
export interface ApiLLMModelsResponse {
models: string[]
}
export type ApiSearchProviderType = 'brave'
export interface ApiSearchProvider {
id: string
name: string
type: ApiSearchProviderType
api_key?: string
}
export interface ApiSearchSettings {
default_provider_id: string
providers: ApiSearchProvider[]
}
export interface ApiDiscoveredEmail {
email: string
display_name: string
roles?: string[]
message_count: number
}
export interface ApiDiscoveredSignature {
id: string
message_id?: string
signature_text: string
message_date: string
confidence: number
}
export interface ApiEnrichedContactData {
first_name?: string
last_name?: string
company?: string
department?: string
job_title?: string
emails?: { value: string; label: string }[]
phones?: { value: string; label: string }[]
addresses?: {
street?: string
city?: string
region?: string
postal_code?: string
country?: string
label: string
}[]
website?: string
social_profiles?: { value: string; label: string }[]
notes?: string
}
export interface ApiDiscoveredProfile {
id: string
display_name: string
primary_email: string
all_emails: ApiDiscoveredEmail[]
message_count: number
sent_count: number
received_count: number
spam_count: number
forwarded_count: number
is_mailing_list: boolean
is_disposable: boolean
is_spam_heavy: boolean
classification_reason?: string
linked_contact_uid?: string
enrichment_status: 'pending' | 'enriching' | 'enriched' | 'skipped' | 'failed'
enriched_data?: ApiEnrichedContactData
status: 'suggested' | 'accepted' | 'rejected' | 'ignored' | 'blocked'
signatures?: ApiDiscoveredSignature[]
detected_in_accounts?: ApiAccountDetection[]
last_message_at?: string
}
export interface ApiEnrichmentSuggestion {
id: string
profile_id?: string
target_contact_uid?: string
suggestion_type: 'new_contact' | 'enrich_contact'
field_path: string
suggested_value: string
suggested_label: string
confidence: number
status: 'pending' | 'accepted' | 'rejected'
profile?: ApiDiscoveredProfile
}
export interface ApiAccountDetection {
account_id: string
account_email: string
account_name: string
message_count: number
}
export interface ApiDiscoveryScan {
id: string
status: 'pending' | 'running' | 'completed' | 'failed'
phase: 'pending' | 'scanning_messages' | 'building_profiles' | 'enriching' | 'done'
messages_scanned: number
total_messages: number
profiles_found: number
profiles_total: number
progress_percent: number
error_message?: string
started_at: string
updated_at: string
completed_at?: string
}
export interface ApiDiscoveryOtherPage {
groups: ApiDiscoveredProfileGroup[]
total: number
limit: number
offset: number
has_more: boolean
}
export interface ApiDiscoveredProfileGroup {
group_key: string
profile_ids: string[]
display_name: string
primary_email: string
message_count: number
profile: ApiDiscoveredProfile
profiles?: ApiDiscoveredProfile[]
}
export interface ApiDiscoveryCounts {
other_contacts: number
suggestions: number
ignored: number
blocked: number
}
export interface ApiDispositionEmails {
emails: string[]
}

View File

@ -0,0 +1,241 @@
import type {
ApiDiscoveredProfile,
ApiEnrichedContactData,
ApiEnrichmentSuggestion,
} from './discovery-types'
import type { FullContact } from './types'
const LABEL_MAP: Record<string, string> = {
work: 'Travail',
home: 'Domicile',
mobile: 'Mobile',
other: 'Autre',
linkedin: 'LinkedIn',
twitter: 'X / Twitter',
facebook: 'Facebook',
instagram: 'Instagram',
github: 'GitHub',
}
function mapLabel(label?: string): string {
if (!label) return 'Autre'
return LABEL_MAP[label.toLowerCase()] ?? label
}
export function discoveredProfileToFullContact(profile: ApiDiscoveredProfile): FullContact {
const data: ApiEnrichedContactData | undefined = profile.enriched_data
const now = Date.now()
const emails = data?.emails?.length
? data.emails.map((e) => ({ value: e.value, label: mapLabel(e.label) }))
: [{ value: profile.primary_email, label: 'Autre' }]
const phones = (data?.phones ?? []).map((p) => ({
value: p.value,
label: mapLabel(p.label),
}))
const addresses = (data?.addresses ?? []).map((a) => ({
street: a.street,
city: a.city,
region: a.region,
postalCode: a.postal_code,
country: a.country,
label: mapLabel(a.label),
}))
const socialProfiles = (data?.social_profiles ?? []).map((p) => ({
value: p.value,
label: mapLabel(p.label),
}))
return {
id: profile.id,
firstName: data?.first_name ?? profile.display_name.split(' ')[0] ?? '',
lastName: data?.last_name ?? profile.display_name.split(' ').slice(1).join(' ') ?? '',
company: data?.company,
department: data?.department,
jobTitle: data?.job_title,
website: data?.website,
socialProfiles: socialProfiles.length > 0 ? socialProfiles : undefined,
emails,
phones,
addresses: addresses.length > 0 ? addresses : undefined,
notes: data?.notes,
interactionCount: profile.message_count,
isOtherContact: true,
createdAt: now,
updatedAt: now,
}
}
function emailLocalPart(email: string): string {
const at = email.lastIndexOf('@')
if (at <= 0) return email.trim().toLowerCase()
return email.slice(0, at).trim().toLowerCase()
}
function hasMeaningfulDisplayName(name: string | undefined, email: string): boolean {
const trimmed = name?.trim()
if (!trimmed || trimmed.includes('@')) return false
const local = emailLocalPart(email)
if (!local) return true
return trimmed.toLowerCase() !== local
}
function enrichedDataHasValueBeyondEmail(
data: ApiEnrichedContactData | undefined | null,
): boolean {
if (!data) return false
if (
data.first_name?.trim() ||
data.last_name?.trim() ||
data.company?.trim() ||
data.department?.trim() ||
data.job_title?.trim() ||
data.website?.trim() ||
data.notes?.trim()
) {
return true
}
if (data.social_profiles?.some((p) => p.value?.trim())) return true
if (data.phones?.some((p) => p.value?.trim())) return true
return (
data.addresses?.some(
(a) =>
a.street?.trim() ||
a.city?.trim() ||
a.region?.trim() ||
a.postal_code?.trim() ||
a.country?.trim(),
) ?? false
)
}
/** True when profile has more than a bare email (signature, name, enriched fields, etc.). */
export function profileHasValueBeyondEmail(profile: ApiDiscoveredProfile): boolean {
if ((profile.signatures?.length ?? 0) > 0) return true
if (enrichedDataHasValueBeyondEmail(profile.enriched_data)) return true
if (hasMeaningfulDisplayName(profile.display_name, profile.primary_email)) return true
for (const e of profile.all_emails ?? []) {
if (hasMeaningfulDisplayName(e.display_name, e.email)) return true
}
return false
}
export function isSuggestableDiscoveryGroup(group: {
profile?: ApiDiscoveredProfile
profiles?: ApiDiscoveredProfile[]
}): boolean {
const profiles = group.profiles?.length
? group.profiles
: group.profile
? [group.profile]
: []
if (profiles.some((p) => profileHasNoReplyEmail(p))) return false
return profiles.some((p) => profileHasValueBeyondEmail(p))
}
export function profileDisplayName(profile: ApiDiscoveredProfile): string {
const data = profile.enriched_data
if (data?.first_name || data?.last_name) {
return `${data.first_name ?? ''} ${data.last_name ?? ''}`.trim()
}
return profile.display_name || profile.primary_email
}
function normalizePhone(s: string): string {
return s.replace(/\D/g, '')
}
function phonesMatch(a: string, b: string): boolean {
const na = normalizePhone(a)
const nb = normalizePhone(b)
if (!na || !nb) return false
if (na.length >= 9 && nb.length >= 9) {
return na.slice(-9) === nb.slice(-9)
}
return na === nb
}
function stringsEqualInsensitive(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase()
}
export function isNoReplyEmail(email: string): boolean {
const lower = email.trim().toLowerCase()
return (
lower.includes('noreply') ||
lower.includes('no-reply') ||
lower.includes('no_reply')
)
}
function profileHasNoReplyEmail(profile: ApiDiscoveredProfile): boolean {
if (isNoReplyEmail(profile.primary_email)) return true
for (const e of profile.all_emails ?? []) {
if (isNoReplyEmail(e.email)) return true
}
for (const e of profile.enriched_data?.emails ?? []) {
if (isNoReplyEmail(e.value)) return true
}
return false
}
/** Suggestions shown in « Ajouter des coordonnées » (enrichissement de contacts existants). */
export function filterVisibleEnrichmentSuggestions(
rawSuggestions: ApiEnrichmentSuggestion[],
contacts: FullContact[],
): ApiEnrichmentSuggestion[] {
return rawSuggestions.filter((s) => {
if (s.suggestion_type !== 'enrich_contact' || !s.target_contact_uid) {
return true
}
const contact = contacts.find((c) => c.id === s.target_contact_uid)
if (!contact) return true
return !isEnrichmentSuggestionRedundant(contact, s)
})
}
/** True when the suggestion value is already present on the contact. */
export function isEnrichmentSuggestionRedundant(
contact: FullContact,
suggestion: Pick<ApiEnrichmentSuggestion, 'field_path' | 'suggested_value'>,
): boolean {
const val = suggestion.suggested_value.trim()
if (!val) return true
switch (suggestion.field_path) {
case 'full_name': {
const full = `${contact.firstName} ${contact.lastName}`.trim()
return stringsEqualInsensitive(val, full)
}
case 'company':
return contact.company ? stringsEqualInsensitive(val, contact.company) : false
case 'job_title':
return contact.jobTitle ? stringsEqualInsensitive(val, contact.jobTitle) : false
case 'phones':
return contact.phones.some((p) => phonesMatch(p.value, val))
case 'emails':
return contact.emails.some((e) => stringsEqualInsensitive(e.value, val))
case 'social_profiles':
return (contact.socialProfiles ?? []).some((p) => stringsEqualInsensitive(p.value, val))
default:
return false
}
}
export const FIELD_LABELS: Record<string, string> = {
first_name: 'Prénom',
last_name: 'Nom',
company: 'Entreprise',
department: 'Service',
job_title: 'Poste',
emails: 'E-mail',
phones: 'Téléphone',
addresses: 'Adresse',
website: 'Site web',
social_profiles: 'Réseaux sociaux',
notes: 'Notes',
full_name: 'Nom complet',
}

View File

@ -1,64 +1,9 @@
import Fuse, { type IFuseOptions } from "fuse.js"
import type { FullContact } from "./types"
function stripAccents(str: string): string {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
}
const fuseOptions: IFuseOptions<FullContact> = {
keys: [
"firstName",
"lastName",
"middleName",
"nicknames",
"emails.value",
"phones.value",
"company",
"department",
"jobTitle",
"addresses.street",
"addresses.city",
"addresses.country",
],
isCaseSensitive: false,
threshold: 0.4,
ignoreLocation: true,
getFn: (obj: FullContact, path: string | string[]) => {
const raw = Fuse.config.getFn(obj, path)
if (Array.isArray(raw)) {
return raw.map((v) => (typeof v === "string" ? stripAccents(v) : v)) as unknown as string
}
if (typeof raw === "string") {
return stripAccents(raw)
}
return raw as unknown as string
},
}
let cachedFuse: { key: FullContact[]; fuse: Fuse<FullContact> } | null = null
function getFuse(contacts: FullContact[]): Fuse<FullContact> {
if (cachedFuse && cachedFuse.key === contacts) return cachedFuse.fuse
const fuse = new Fuse(contacts, fuseOptions)
cachedFuse = { key: contacts, fuse }
return fuse
}
import { rankFullContacts } from "./contact-match-score"
export function searchContacts(
contacts: FullContact[],
query: string
): FullContact[] {
if (!query.trim()) return contacts
const fuse = getFuse(contacts)
const results = fuse.search(stripAccents(query))
const seen = new Set<string>()
const deduped: FullContact[] = []
for (const r of results) {
if (!seen.has(r.item.id)) {
seen.add(r.item.id)
deduped.push(r.item)
}
}
return deduped
return rankFullContacts(contacts, query)
}

View File

@ -1,4 +1,5 @@
import { parseDisplayNameToNameParts } from "./find-contact"
import { parseVCardPhoto } from "@/lib/contact-avatar"
import type { FullContact } from "./types"
export type ContactImportInput = Omit<FullContact, "id" | "createdAt" | "updatedAt">
@ -49,13 +50,13 @@ function unfoldVcardLines(text: string): string[] {
return lines
}
function parseVcardProperty(line: string): { key: string; value: string } | null {
function parseVcardProperty(line: string): { key: string; rawKey: string; value: string } | null {
const idx = line.indexOf(":")
if (idx === -1) return null
const left = line.slice(0, idx)
const rawKey = line.slice(0, idx)
const value = line.slice(idx + 1).trim()
const key = left.split(";")[0].toUpperCase()
return { key, value }
const key = rawKey.split(";")[0].toUpperCase()
return { key, rawKey, value }
}
function parseVcardBlock(lines: string[]): ContactImportInput | null {
@ -66,11 +67,12 @@ function parseVcardBlock(lines: string[]): ContactImportInput | null {
const emails: { value: string; label: string }[] = []
const phones: { value: string; label: string }[] = []
let notes: string | undefined
let avatarUrl: string | undefined
for (const line of lines) {
const prop = parseVcardProperty(line)
if (!prop || !prop.value) continue
const { key, value } = prop
const { key, rawKey, value } = prop
switch (key) {
case "FN": {
@ -102,6 +104,9 @@ function parseVcardBlock(lines: string[]): ContactImportInput | null {
case "NOTE":
notes = value
break
case "PHOTO":
avatarUrl = parseVCardPhoto(rawKey, value) ?? avatarUrl
break
default:
break
}
@ -119,6 +124,7 @@ function parseVcardBlock(lines: string[]): ContactImportInput | null {
emails,
phones,
notes,
avatarUrl,
}
}

View File

@ -0,0 +1,172 @@
import { buildVCardFromFullContact } from '@/lib/api/adapters'
import type { ApiEnrichedContactData } from '@/lib/contacts/discovery-types'
import type { FullContact } from '@/lib/contacts/types'
const REVERSE_LABEL_MAP: Record<string, string> = {
travail: 'work',
work: 'work',
domicile: 'home',
home: 'home',
mobile: 'mobile',
autre: 'other',
other: 'other',
personal: 'home',
}
const LABEL_MAP: Record<string, string> = {
work: 'Travail',
home: 'Domicile',
mobile: 'Mobile',
other: 'Autre',
linkedin: 'LinkedIn',
twitter: 'X / Twitter',
facebook: 'Facebook',
instagram: 'Instagram',
github: 'GitHub',
}
function toApiLabel(label?: string): string {
if (!label) return 'other'
const key = label.trim().toLowerCase()
return REVERSE_LABEL_MAP[key] ?? key
}
function fromApiLabel(label?: string): string {
if (!label) return 'Autre'
return LABEL_MAP[label.toLowerCase()] ?? label
}
function formatBirthdayForPayload(b?: FullContact['birthday']): string | undefined {
if (!b?.month && !b?.day && !b?.year) return undefined
const y = b.year ? String(b.year).padStart(4, '0') : ''
const m = b.month ? String(b.month).padStart(2, '0') : ''
const d = b.day ? String(b.day).padStart(2, '0') : ''
if (y && m && d) return `${y}-${m}-${d}`
if (m && d) return `--${m}-${d}`
return undefined
}
export interface ImproveContactPayload {
first_name: string
last_name: string
middle_name?: string
company?: string
department?: string
job_title?: string
website?: string
notes?: string
emails: { value: string; label: string }[]
phones: { value: string; label: string }[]
social_profiles?: { value: string; label: string }[]
addresses?: {
street?: string
city?: string
region?: string
postal_code?: string
country?: string
label: string
}[]
birthday?: string
raw_vcard?: string
}
export function fullContactToImprovePayload(contact: FullContact): ImproveContactPayload {
return {
first_name: contact.firstName,
last_name: contact.lastName,
middle_name: contact.middleName,
company: contact.company,
department: contact.department,
job_title: contact.jobTitle,
website: contact.website,
notes: contact.notes,
social_profiles: contact.socialProfiles?.map((p) => ({
value: p.value,
label: toApiLabel(p.label),
})),
emails: contact.emails.map((e) => ({ value: e.value, label: toApiLabel(e.label) })),
phones: contact.phones.map((p) => ({ value: p.value, label: toApiLabel(p.label) })),
addresses: contact.addresses?.map((a) => ({
street: a.street,
city: a.city,
region: a.region,
postal_code: a.postalCode,
country: a.country,
label: toApiLabel(a.label),
})),
birthday: formatBirthdayForPayload(contact.birthday),
raw_vcard: buildVCardFromFullContact(contact),
}
}
export function mergeImprovedContact(
contact: FullContact,
data: ApiEnrichedContactData,
): FullContact {
const emails = data.emails?.length
? data.emails.map((e) => ({ value: e.value, label: fromApiLabel(e.label) }))
: contact.emails
const phones = data.phones?.length
? data.phones.map((p) => ({ value: p.value, label: fromApiLabel(p.label) }))
: contact.phones
const addresses = data.addresses?.length
? data.addresses.map((a) => ({
street: a.street,
city: a.city,
region: a.region,
postalCode: a.postal_code,
country: a.country,
label: fromApiLabel(a.label),
}))
: contact.addresses
const socialProfiles = data.social_profiles?.length
? data.social_profiles.map((p) => ({ value: p.value, label: fromApiLabel(p.label) }))
: contact.socialProfiles
return {
...contact,
firstName: data.first_name?.trim() || contact.firstName,
lastName: data.last_name?.trim() || contact.lastName,
company: data.company?.trim() || contact.company,
department: data.department?.trim() || contact.department,
jobTitle: data.job_title?.trim() || contact.jobTitle,
website: data.website?.trim() || contact.website,
notes: data.notes?.trim() || contact.notes,
emails,
phones,
addresses,
socialProfiles,
updatedAt: Date.now(),
}
}
export function improvedDataToDraftFields(data: ApiEnrichedContactData) {
const items: { id: string; fieldKey: string; value: string }[] = []
let i = 0
const push = (fieldKey: string, value: string | undefined) => {
const v = value?.trim()
if (!v) return
items.push({ id: `${fieldKey}-${i++}`, fieldKey, value: v })
}
push('first_name', data.first_name)
push('last_name', data.last_name)
push('company', data.company)
push('department', data.department)
push('job_title', data.job_title)
push('website', data.website)
push('notes', data.notes)
for (const p of data.social_profiles ?? []) push('social_profiles', p.value)
for (const e of data.emails ?? []) push('emails', e.value)
for (const p of data.phones ?? []) push('phones', p.value)
for (const a of data.addresses ?? []) {
const line = [a.street, [a.postal_code, a.city].filter(Boolean).join(' '), a.region, a.country]
.filter(Boolean)
.join(', ')
push('addresses', line)
}
return items
}

View File

@ -0,0 +1,6 @@
import type { ApiLLMSettings } from '@/lib/contacts/discovery-types'
export function isLLMConfigured(settings?: ApiLLMSettings): boolean {
if (!settings?.providers?.length) return false
return settings.providers.some((p) => Boolean(p.base_url?.trim()))
}

View File

@ -0,0 +1,138 @@
import type { FullContact } from "./types"
import { fullContactDisplayName } from "./types"
function contactMergeScore(c: FullContact): number {
let score = 0
if (fullContactDisplayName(c)) score += 3
if (c.emails.some((e) => e.value.trim())) score += 4
if (c.phones.some((p) => p.value.trim())) score += 2
if (c.company?.trim()) score += 1
if (c.path?.trim()) score += 1
return score
}
export function pickContactMergePrimary(contacts: FullContact[]): FullContact | undefined {
if (contacts.length === 0) return undefined
return [...contacts].sort((a, b) => contactMergeScore(b) - contactMergeScore(a))[0]
}
export function mergeManyContacts(
contacts: FullContact[],
primaryId?: string,
): { primary: FullContact; secondaries: FullContact[]; merged: FullContact } | null {
if (contacts.length < 2) return null
const primary =
(primaryId ? contacts.find((c) => c.id === primaryId) : undefined) ??
pickContactMergePrimary(contacts)
if (!primary) return null
const secondaries = contacts.filter((c) => c.id !== primary.id)
let merged = primary
for (const secondary of secondaries) {
merged = mergeFullContactFields(merged, secondary)
}
return {
primary,
secondaries,
merged: {
...merged,
id: primary.id,
path: primary.path,
etag: primary.etag,
createdAt: primary.createdAt,
},
}
}
function dedupeLabeledValues<T extends { value: string; label: string }>(items: T[]): T[] {
const seen = new Set<string>()
const out: T[] = []
for (const item of items) {
const key = item.value.trim().toLowerCase()
if (!key || seen.has(key)) continue
seen.add(key)
out.push(item)
}
return out
}
function dedupeAddresses(items: NonNullable<FullContact["addresses"]>): NonNullable<FullContact["addresses"]> {
const seen = new Set<string>()
const out: NonNullable<FullContact["addresses"]> = []
for (const item of items) {
const key = [
item.street,
item.city,
item.region,
item.postalCode,
item.country,
item.label,
]
.map((part) => part?.trim().toLowerCase() ?? "")
.join("|")
if (!key.replace(/\|/g, "") || seen.has(key)) continue
seen.add(key)
out.push(item)
}
return out
}
function dedupeStrings(items: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const item of items) {
const key = item.trim().toLowerCase()
if (!key || seen.has(key)) continue
seen.add(key)
out.push(item)
}
return out
}
function mergeNotes(a?: string, b?: string): string | undefined {
const parts = [a, b]
.map((n) => n?.trim())
.filter(Boolean) as string[]
if (parts.length === 0) return undefined
return dedupeStrings(parts).join("\n\n")
}
export function mergeFullContactFields(primary: FullContact, secondary: FullContact): FullContact {
return {
...primary,
namePrefix: primary.namePrefix || secondary.namePrefix,
firstName: primary.firstName || secondary.firstName,
middleName: primary.middleName || secondary.middleName,
lastName: primary.lastName || secondary.lastName,
nameSuffix: primary.nameSuffix || secondary.nameSuffix,
phoneticFirstName: primary.phoneticFirstName || secondary.phoneticFirstName,
phoneticLastName: primary.phoneticLastName || secondary.phoneticLastName,
company: primary.company || secondary.company,
department: primary.department || secondary.department,
jobTitle: primary.jobTitle || secondary.jobTitle,
website: primary.website || secondary.website,
notes: mergeNotes(primary.notes, secondary.notes),
emails: dedupeLabeledValues([...primary.emails, ...secondary.emails]),
phones: dedupeLabeledValues([...primary.phones, ...secondary.phones]),
addresses: dedupeAddresses([
...(primary.addresses ?? []),
...(secondary.addresses ?? []),
]),
nicknames: dedupeStrings([...(primary.nicknames ?? []), ...(secondary.nicknames ?? [])]),
labels: dedupeStrings([...(primary.labels ?? []), ...(secondary.labels ?? [])]),
avatarUrl: primary.avatarUrl || secondary.avatarUrl,
birthday: primary.birthday ?? secondary.birthday,
updatedAt: Date.now(),
}
}
export function mergeTwoContacts(
a: FullContact,
b: FullContact,
): { primary: FullContact; secondary: FullContact; merged: FullContact } {
const [primary, secondary] =
contactMergeScore(a) >= contactMergeScore(b) ? [a, b] : [b, a]
return { primary, secondary, merged: mergeFullContactFields(primary, secondary) }
}

View File

@ -11,6 +11,9 @@ export interface ContactAddress {
export interface FullContact {
id: string
/** Chemin CardDAV (PUT/DELETE). Préférer à id pour les appels API. */
path?: string
etag?: string
namePrefix?: string
firstName: string
middleName?: string
@ -22,6 +25,8 @@ export interface FullContact {
company?: string
department?: string
jobTitle?: string
website?: string
socialProfiles?: { value: string; label: string }[]
emails: { value: string; label: string }[]
phones: { value: string; label: string }[]
addresses?: ContactAddress[]

View File

@ -2,18 +2,37 @@
import { useMemo } from 'react'
import {
useContactBooks,
useContacts,
useDefaultContactBookId,
} from '@/lib/api/hooks/use-contact-queries'
import { apiContactToFullContact } from '@/lib/api/adapters'
import { mapApiContactsToFullContacts } from '@/lib/api/contact-list-cache'
export function useContactsList(bookId?: string) {
const defaultBookId = useDefaultContactBookId()
const resolvedBookId = bookId ?? defaultBookId
const booksQuery = useContactBooks()
const { data: apiContacts, ...rest } = useContacts(resolvedBookId)
const contacts = useMemo(
() => apiContacts?.map(apiContactToFullContact) ?? [],
[apiContacts]
() => mapApiContactsToFullContacts(resolvedBookId ?? '', apiContacts),
[resolvedBookId, apiContacts],
)
return { contacts, bookId: resolvedBookId, ...rest }
const isLoading = booksQuery.isLoading || (!!resolvedBookId && rest.isLoading)
const isError = booksQuery.isError || rest.isError
const error = booksQuery.error ?? rest.error
function refetch() {
void booksQuery.refetch()
void rest.refetch()
}
return {
contacts,
bookId: resolvedBookId,
...rest,
isLoading,
isError,
error,
refetch,
}
}

View File

@ -1,5 +1,10 @@
import type { Email } from "@/lib/email-data"
import type { FullContact } from "@/lib/contacts/types"
import { fullContactDisplayName } from "@/lib/contacts/types"
import {
fieldMatchScore,
normalizeContactSearchText,
} from "@/lib/contacts/contact-match-score"
import type { SearchParams } from "./search-params"
// ---------------------------------------------------------------------------
@ -29,21 +34,7 @@ export type SearchSuggestion = ContactSuggestion | EmailSuggestion
// ---------------------------------------------------------------------------
function normalize(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
}
function prefixScore(haystack: string, needle: string): number {
const h = normalize(haystack)
const n = normalize(needle)
if (!n) return 0
if (h === n) return 1
if (h.startsWith(n)) return 0.9 + 0.1 * (n.length / h.length)
const idx = h.indexOf(n)
if (idx > 0) return 0.5 + 0.4 * (n.length / h.length)
return 0
return normalizeContactSearchText(s)
}
// ---------------------------------------------------------------------------
@ -58,16 +49,16 @@ export function matchContacts(
if (!query.trim()) return []
const results: ContactSuggestion[] = []
for (const c of contacts) {
const fullName = `${c.firstName} ${c.lastName}`.trim()
const fullName = fullContactDisplayName(c)
let bestScore = 0
let bestEmail = c.emails[0]?.value ?? ""
bestScore = Math.max(bestScore, prefixScore(fullName, query))
bestScore = Math.max(bestScore, prefixScore(c.firstName, query))
bestScore = Math.max(bestScore, prefixScore(c.lastName, query))
bestScore = Math.max(bestScore, fieldMatchScore(fullName, query))
bestScore = Math.max(bestScore, fieldMatchScore(c.firstName, query))
bestScore = Math.max(bestScore, fieldMatchScore(c.lastName, query))
for (const e of c.emails) {
const s = prefixScore(e.value, query)
const s = fieldMatchScore(e.value, query)
if (s > bestScore) {
bestScore = s
bestEmail = e.value
@ -104,8 +95,8 @@ export function matchEmails(
if (!e.senderEmail) continue
const addr = e.senderEmail.toLowerCase()
const name = e.sender
const s1 = prefixScore(addr, query)
const s2 = prefixScore(name, query)
const s1 = fieldMatchScore(addr, query)
const s2 = fieldMatchScore(name, query)
const s = Math.max(s1, s2)
if (s > 0) {
const existing = seen.get(addr)

View File

@ -63,7 +63,7 @@ export const MAIL_SETTINGS_NAV: MailSettingsNavItem[] = [
{
id: "automation",
label: "Automatisations",
description: "Règles, webhooks, LLM, tokens API",
description: "Règles, webhooks, LLM, recherche web, tokens API",
href: "/mail/settings/automation",
icon: Bot,
},

View File

@ -0,0 +1,54 @@
"use client"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { normalizeEmail } from "@/lib/contacts/find-contact"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
type BlockedSendersState = {
blockedSenderEmails: string[]
}
type BlockedSendersActions = {
blockSenders: (emails: string[]) => void
unblockSender: (email: string) => void
}
export function isBlockedSenderEmail(
blockedSenderEmails: string[],
email: string,
): boolean {
const norm = normalizeEmail(email)
return norm ? blockedSenderEmails.includes(norm) : false
}
export const useBlockedSendersStore = create<
BlockedSendersState & BlockedSendersActions
>()(
persist(
(set, get) => ({
blockedSenderEmails: [],
blockSenders: (emails) => {
const next = new Set(get().blockedSenderEmails)
for (const email of emails) {
const norm = normalizeEmail(email)
if (norm) next.add(norm)
}
set({ blockedSenderEmails: [...next] })
},
unblockSender: (email) => {
const norm = normalizeEmail(email)
if (!norm) return
set((s) => ({
blockedSenderEmails: s.blockedSenderEmails.filter((e) => e !== norm),
}))
},
}),
{
name: "ultimail-blocked-senders",
storage: debouncedPersistJSONStorage,
},
),
)

View File

@ -23,6 +23,7 @@
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@formkit/auto-animate": "^0.9.0",
"@hookform/resolvers": "^3.9.1",
"@iconify-json/cbi": "^1.2.36",
"@iconify-json/fluent": "^1.2.47",

View File

@ -14,6 +14,9 @@ importers:
'@emoji-mart/react':
specifier: ^1.1.1
version: 1.1.1(emoji-mart@5.6.0)(react@19.2.4)
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
'@hookform/resolvers':
specifier: ^3.9.1
version: 3.10.0(react-hook-form@7.71.1(react@19.2.4))
@ -305,6 +308,9 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@formkit/auto-animate@0.9.0':
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
'@hookform/resolvers@3.10.0':
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
peerDependencies:
@ -2233,6 +2239,8 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@formkit/auto-animate@0.9.0': {}
'@hookform/resolvers@3.10.0(react-hook-form@7.71.1(react@19.2.4))':
dependencies:
react-hook-form: 7.71.1(react@19.2.4)

File diff suppressed because one or more lines are too long