ultisuite-client/components/gmail/contacts-page/other-contacts-view.tsx
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views.
- Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts.
- Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface.
- Added loading and error states in ContactsListView for better user feedback during data fetching.
- Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list.
- Updated package.json to include @formkit/auto-animate for improved UI animations.
2026-06-06 20:26:51 +02:00

316 lines
11 KiB
TypeScript

"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>
)
}