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.
316 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|