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.
263 lines
7.9 KiB
TypeScript
263 lines
7.9 KiB
TypeScript
"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,
|
|
}
|
|
}
|