feat(api): offline-first mail sync w/ TanStack Query
Some checks failed
E2E / Playwright e2e (push) Has been cancelled

Move mail, compose, contacts, and accounts off mocks onto REST + WS.
Add client, auth store, IDB-backed query cache, offline queue, and
sync bar; hybrid Zustand for UI-only state. Settings still local until
backend has preferences API.
This commit is contained in:
R3D347HR4Y 2026-05-23 00:04:28 +02:00
parent 9d0fb2766b
commit c87670e90f
59 changed files with 2839 additions and 1368 deletions

View File

@ -4,6 +4,7 @@ import { Analytics } from '@vercel/analytics/next'
import './globals.css'
import { ThemeInitScript } from '@/components/theme-init-script'
import { FirstLaunchSplash } from '@/components/first-launch-splash'
import { QueryProvider } from '@/lib/api/query-provider'
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
@ -32,7 +33,9 @@ export default function RootLayout({
<html lang="fr" suppressHydrationWarning className="h-dvh max-h-dvh overflow-hidden">
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
<ThemeInitScript />
<QueryProvider>
<FirstLaunchSplash>{children}</FirstLaunchSplash>
</QueryProvider>
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>

View File

@ -1,12 +1,11 @@
"use client"
import { useState } from "react"
import type { UserAccount } from "@/lib/accounts/types"
import type { ApiMailAccount } from "@/lib/api/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { cn } from "@/lib/utils"
interface AccountAvatarProps {
account: UserAccount
account: Pick<ApiMailAccount, "name" | "email">
size?: "sm" | "md" | "lg"
className?: string
}
@ -22,24 +21,9 @@ export function AccountAvatar({
size = "md",
className,
}: AccountAvatarProps) {
const [imageFailed, setImageFailed] = useState(false)
const initial = senderInitial(account.displayName)
const color = avatarColor(account.displayName)
if (account.avatarUrl && !imageFailed) {
return (
<img
src={account.avatarUrl}
alt=""
className={cn(
"shrink-0 rounded-full object-cover",
sizeClasses[size],
className,
)}
onError={() => setImageFailed(true)}
/>
)
}
const displayName = account.name || account.email
const initial = senderInitial(displayName)
const color = avatarColor(displayName)
return (
<div

View File

@ -1,23 +1,20 @@
"use client"
import { useEffect, useRef, type RefObject } from "react"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
import { AccountAvatar } from "@/components/gmail/account-avatar"
import { Button } from "@/components/ui/button"
import { MOCK_USER_ACCOUNTS, STORAGE_USAGE } from "@/lib/accounts/mock-accounts"
import type { UserAccount } from "@/lib/accounts/types"
import type { ApiMailAccount } from "@/lib/api/types"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import {
useAccountStore,
useActiveAccount,
useSignOutAll,
} from "@/lib/stores/account-store"
addCollection(mdiIcons)
interface AccountSwitcherDropdownProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Clicks inside this node (e.g. avatar trigger) do not close the panel. */
containerRef: RefObject<HTMLElement | null>
}
@ -25,7 +22,7 @@ function AccountRow({
account,
onSelect,
}: {
account: UserAccount
account: ApiMailAccount
onSelect: () => void
}) {
return (
@ -37,7 +34,7 @@ function AccountRow({
<AccountAvatar account={account} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{account.displayName}
{account.name}
</p>
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
</div>
@ -54,13 +51,16 @@ export function AccountSwitcherDropdown({
const activeAccount = useActiveAccount()
const activeAccountId = useAccountStore((s) => s.activeAccountId)
const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded)
const setActiveAccount = useAccountStore((s) => s.setActiveAccount)
const setActiveAccountId = useAccountStore((s) => s.setActiveAccountId)
const toggleOtherAccountsExpanded = useAccountStore(
(s) => s.toggleOtherAccountsExpanded,
)
const signOutAll = useAccountStore((s) => s.signOutAll)
const signOutAll = useSignOutAll()
const otherAccounts = MOCK_USER_ACCOUNTS.filter((a) => a.id !== activeAccountId)
const { data: accounts } = useMailAccounts()
const otherAccounts = (accounts ?? []).filter((a) => a.id !== activeAccountId)
const firstName = activeAccount?.name.split(" ")[0] ?? ""
useEffect(() => {
if (!open) return
@ -83,10 +83,10 @@ export function AccountSwitcherDropdown({
}
}, [open, onOpenChange, containerRef])
if (!open) return null
if (!open || !activeAccount) return null
const handleSelectAccount = (id: string) => {
setActiveAccount(id)
setActiveAccountId(id)
onOpenChange(false)
}
@ -97,7 +97,6 @@ export function AccountSwitcherDropdown({
aria-label="Comptes connectés"
className="absolute right-0 top-12 z-50 w-[min(100vw-1rem,356px)] overflow-hidden rounded-[28px] bg-mail-surface-elevated text-foreground shadow-[0_4px_16px_rgba(0,0,0,0.35)] border border-border"
>
{/* Current account header */}
<div className="relative px-4 pb-3 pt-4">
<p className="truncate pr-8 text-center text-sm text-foreground">
{activeAccount.email}
@ -121,19 +120,18 @@ export function AccountSwitcherDropdown({
</span>
</div>
<h2 className="mt-3 text-xl font-normal text-foreground">
Bonjour {activeAccount.firstName} !
Bonjour {firstName} !
</h2>
<Button
type="button"
variant="outline"
className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary"
>
Gérer votre compte Google
Gérer votre compte
</Button>
</div>
</div>
{/* Other accounts + actions */}
<div className="px-3 pb-3">
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
<button
@ -191,19 +189,6 @@ export function AccountSwitcherDropdown({
</div>
</div>
{/* Storage */}
<div className="mt-3 flex items-center gap-2 rounded-full border border-border bg-mail-surface px-4 py-2.5">
<Icon
icon="mdi:alert-circle"
className="size-5 shrink-0 text-[#e8710a]"
aria-hidden
/>
<span className="text-sm text-foreground">
{STORAGE_USAGE.percentUsed} % utilisé(s) sur {STORAGE_USAGE.totalLabel}
</span>
</div>
{/* Footer links */}
<div className="mt-4 flex flex-wrap items-center justify-center gap-1 pb-2 text-center text-xs text-muted-foreground">
<button type="button" className="hover:underline">
Règles de confidentialité

View File

@ -25,6 +25,7 @@ import {
} from "lucide-react"
import { useComposeActions } from "@/lib/compose-context"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import {
findContactByEmail,
parseDisplayNameToNameParts,
@ -55,7 +56,7 @@ export function ContactHoverCard({
side = "bottom",
}: ContactHoverCardProps) {
const { openComposeWithInitial } = useComposeActions()
const contacts = useContactsStore((s) => s.contacts)
const { contacts } = useContactsList()
const openContactDetail = useContactsStore((s) => s.openContactDetail)
const openCreateContact = useContactsStore((s) => s.openCreateContact)
const [open, setOpen] = useState(false)

View File

@ -1,118 +1,23 @@
"use client"
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_CARD_CLASS,
CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS,
CONTACTS_PAGE_LINK_BTN_CLASS,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
export function AddCoordinatesView() {
const { getCoordinateSuggestions, updateContact } = useContactsStore()
const suggestions = useMemo(() => getCoordinateSuggestions(), [getCoordinateSuggestions])
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
const visible = suggestions.filter((s) => !dismissed.has(s.contact.id))
function handleAdd(contactId: string, field: string, value: string) {
updateContact(contactId, { [field]: value })
setDismissed((s) => new Set(s).add(contactId))
}
function handleIgnore(contactId: string) {
setDismissed((s) => new Set(s).add(contactId))
}
function handleAddAll() {
for (const s of visible) {
updateContact(s.contact.id, { [s.suggestedField]: s.suggestedValue })
}
setDismissed(new Set(suggestions.map((s) => s.contact.id)))
}
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
Ajouter des coordonnées ({visible.length})
Ajouter des coordonnées (0)
</h3>
{visible.length > 0 && (
<Button onClick={handleAddAll} className={CONTACTS_PRIMARY_BTN_CLASS}>
Ajouter tous les détails
</Button>
)}
</div>
{visible.length === 0 && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucune suggestion disponible
</p>
)}
<div className="space-y-4">
{visible.map((suggestion) => {
const { contact, suggestedField, suggestedValue } = suggestion
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || "?"
const color = avatarColor(name)
const initial = senderInitial(name)
return (
<div key={contact.id} className={CONTACTS_PAGE_CARD_CLASS}>
<p className={cn("mb-2 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Contact à modifier</p>
<div className="flex 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 }}
>
{initial}
</div>
)}
<div className="min-w-0">
<p className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}>{name}</p>
{contact.emails[0] && (
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{contact.emails[0].value}</p>
)}
{contact.phones[0] && (
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>
{contact.phones[0].value} ({contact.phones[0].label})
</p>
)}
</div>
</div>
<div className={CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS}>
<p className={cn("text-xs font-medium", CONTACTS_MUTED_TEXT)}>Détails à ajouter</p>
<p className={cn("mt-1 text-sm", CONTACTS_HEADING_TEXT)}>{suggestedValue}</p>
</div>
<div className="mt-4 flex items-center justify-end gap-3">
<button type="button" onClick={() => handleIgnore(contact.id)} className={CONTACTS_PAGE_LINK_BTN_CLASS}>
Ignorer
</button>
<Button
onClick={() => handleAdd(contact.id, suggestedField, suggestedValue)}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
Ajouter
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -8,8 +8,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { parseBulkContactText } from "@/lib/contacts/import-parsers"
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import type { FullContact } from "@/lib/contacts/types"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_LINK_BTN_CLASS,
@ -25,13 +27,28 @@ interface BulkCreateDialogProps {
export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) {
const [input, setInput] = useState("")
const addContacts = useContactsStore((s) => s.addContacts)
const createContactMutation = useCreateContact()
function handleCreate() {
const parsed = parseBulkContactText(input)
if (parsed.length === 0) return
addContacts(parsed)
for (const partial of parsed) {
const fullContact: FullContact = {
id: crypto.randomUUID(),
createdAt: Date.now(),
updatedAt: Date.now(),
...partial,
firstName: partial.firstName ?? "",
lastName: partial.lastName ?? "",
emails: partial.emails ?? [],
phones: partial.phones ?? [],
}
createContactMutation.mutate({
bookId: "default",
contact: fullContactToApiContact(fullContact),
})
}
setInput("")
onOpenChange(false)
}

View File

@ -41,8 +41,11 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { fullContactDisplayName } from "@/lib/contacts/types"
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"
@ -112,7 +115,9 @@ interface ContactCreatePageProps {
}
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) {
const { contacts, addContact, updateContact } = useContactsStore()
const { contacts } = useContactsList()
const createContactMutation = useCreateContact()
const updateContactMutation = useUpdateContact()
const labelRows = useNavStore((s) => s.labelRows)
const availableLabels = labelRows.filter((r) => r.enabled !== false)
const [starred, setStarred] = useState(false)
@ -208,10 +213,37 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
}
if (mode === "create") {
const id = addContact(payload)
onSaved(id)
const tempId = crypto.randomUUID()
const fullContact: FullContact = {
id: tempId,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
createContactMutation.mutate(
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
{ onSuccess: (created) => onSaved(created?.uid ?? tempId) },
)
onSaved(tempId)
} else if (contactId) {
updateContact(contactId, payload)
const fullContact: FullContact = {
id: contactId,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
updateContactMutation.mutate({
path: contactId,
contact: fullContactToApiContact(fullContact),
})
onSaved(contactId)
}
}

View File

@ -17,6 +17,8 @@ import {
} from "lucide-react"
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 { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store"
@ -53,7 +55,9 @@ interface ContactDetailPageProps {
}
export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPageProps) {
const { contacts, softDeleteContact } = useContactsStore()
const { contacts } = useContactsList()
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const deleteContactMutation = useDeleteContact()
const labelRows = useNavStore((s) => s.labelRows)
const contact = contacts.find((c) => c.id === contactId)
@ -72,7 +76,8 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
const primaryEmail = contact.emails[0]?.value
function handleDelete() {
softDeleteContact(contactId, "Supprimé manuellement")
if (contact) softDeleteContact(contact, "Supprimé manuellement")
deleteContactMutation.mutate({ path: contactId })
onBack()
}

View File

@ -1,6 +1,7 @@
"use client"
import { useMemo, useState } from "react"
import {
Users,
Clock,
@ -32,7 +33,9 @@ import {
CONTACTS_SIDEBAR_CLASS,
} from "@/lib/contacts-chrome-classes"
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 { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
import { useNavStore } from "@/lib/stores/nav-store"
import type { ContactsPageView } from "./contacts-app-shell"
@ -63,8 +66,12 @@ export function ContactsSidebar({
onBulkCreate,
onSelectLabel,
}: ContactsSidebarProps) {
const contacts = useContactsStore((s) => s.contacts)
const mergeSuggestionCount = useContactsStore((s) => s.getMergeSuggestions().length)
const { contacts } = useContactsList()
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
const mergeSuggestionCount = useMemo(
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)).length,
[contacts, ignoredMergePairs]
)
const labelRows = useNavStore((s) => s.labelRows)
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
const [labelInput, setLabelInput] = useState("")

View File

@ -11,6 +11,8 @@ import {
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 { useNavStore } from "@/lib/stores/nav-store"
import { searchContacts } from "@/lib/contacts/fuzzy-search"
import { printContacts } from "@/lib/contacts/print-contacts"
@ -53,8 +55,9 @@ interface ContactsTableProps {
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
const { visibleColumns, columnLabels } = useContactsTableColumns()
const gridStyle = contactsTableGridStyle(visibleColumns)
const contacts = useContactsStore((s) => s.contacts)
const { contacts } = useContactsList()
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const deleteContactMutation = useDeleteContact()
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const filteredContacts = useMemo(() => {
@ -144,7 +147,8 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
function handleDeleteSelected() {
if (selectionCount === 0) return
for (const contact of selectedContacts) {
softDeleteContact(contact.id, "Supprimé manuellement")
softDeleteContact(contact, "Supprimé manuellement")
deleteContactMutation.mutate({ path: contact.id })
}
setSelectedIds(new Set())
}

View File

@ -9,8 +9,10 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Info } from "lucide-react"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { parseContactFile } from "@/lib/contacts/import-parsers"
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import type { FullContact } from "@/lib/contacts/types"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
@ -27,7 +29,7 @@ interface ImportDialogProps {
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
const fileRef = useRef<HTMLInputElement>(null)
const addContacts = useContactsStore((s) => s.addContacts)
const createContactMutation = useCreateContact()
const [pendingFile, setPendingFile] = useState<File | null>(null)
const [previewCount, setPreviewCount] = useState(0)
const [error, setError] = useState<string | null>(null)
@ -76,11 +78,26 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
setError(null)
try {
const parsed = await parseContactFile(pendingFile)
const count = addContacts(parsed)
if (count === 0) {
if (parsed.length === 0) {
setError("Aucun contact importé.")
return
}
for (const partial of parsed) {
const fullContact: FullContact = {
id: crypto.randomUUID(),
createdAt: Date.now(),
updatedAt: Date.now(),
...partial,
firstName: partial.firstName ?? "",
lastName: partial.lastName ?? "",
emails: partial.emails ?? [],
phones: partial.phones ?? [],
}
createContactMutation.mutate({
bookId: "default",
contact: fullContactToApiContact(fullContact),
})
}
handleOpenChange(false)
} catch {
setError("L'import a échoué. Vérifiez le format du fichier.")

View File

@ -2,9 +2,11 @@
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { useContactsStore, type MergeSuggestion } from "@/lib/contacts/contacts-store"
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 { fullContactDisplayName } from "@/lib/contacts/types"
import { fullContactDisplayName, type MergeSuggestion } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { AddCoordinatesView } from "./add-coordinates-view"
import {
@ -31,26 +33,20 @@ const REASON_LABELS: Record<DuplicateMatchReason, string> = {
export function MergeDuplicatesView() {
const [subView, setSubView] = useState<SubView>("merge")
const contacts = useContactsStore((s) => s.contacts)
const { contacts } = useContactsList()
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
const mergeContacts = useContactsStore((s) => s.mergeContacts)
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
const getCoordinateSuggestions = useContactsStore((s) => s.getCoordinateSuggestions)
const mergeDuplicatesMutation = useMergeDuplicates()
const mergeSuggestions = useMemo(
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)),
[contacts, ignoredMergePairs]
)
const coordSuggestions = useMemo(
() => getCoordinateSuggestions(),
[getCoordinateSuggestions, contacts]
)
const [mergingAll, setMergingAll] = useState(false)
function handleMerge(suggestion: MergeSuggestion) {
mergeContacts(suggestion.contactA.id, suggestion.contactB.id)
function handleMerge(_suggestion: MergeSuggestion) {
mergeDuplicatesMutation.mutate({ bookId: "default" })
}
function handleIgnore(suggestion: MergeSuggestion) {
@ -59,20 +55,10 @@ export function MergeDuplicatesView() {
function handleMergeAll() {
setMergingAll(true)
try {
let pairs = findDuplicatePairs(
useContactsStore.getState().contacts,
new Set(useContactsStore.getState().ignoredMergePairs)
mergeDuplicatesMutation.mutate(
{ bookId: "default" },
{ onSettled: () => setMergingAll(false) },
)
while (pairs.length > 0) {
const { contactA, contactB } = pairs[0]
mergeContacts(contactA.id, contactB.id)
const state = useContactsStore.getState()
pairs = findDuplicatePairs(state.contacts, new Set(state.ignoredMergePairs))
}
} finally {
setMergingAll(false)
}
}
return (
@ -108,9 +94,6 @@ export function MergeDuplicatesView() {
className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
>
Ajouter des coordonnées
{coordSuggestions.length > 0 && (
<span className="ml-2 text-xs">({coordSuggestions.length})</span>
)}
</button>
</div>

View File

@ -9,6 +9,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
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 {
@ -25,6 +26,7 @@ import { cn } from "@/lib/utils"
export function TrashView() {
const { deletedContacts, restoreContact, emptyTrash } = useContactsStore()
const deleteContactMutation = useDeleteContact()
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString("fr-FR", {
@ -112,7 +114,7 @@ export function TrashView() {
Restaurer
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => useContactsStore.getState().deleteContact(contact.id)}
onClick={() => deleteContactMutation.mutate({ path: contact.id })}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />

View File

@ -17,9 +17,10 @@ import {
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 { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { emails as allEmails } from "@/lib/email-data"
import { useMailSearch } from "@/lib/api/hooks/use-mail-queries"
import { useComposeActions } from "@/lib/compose-context"
import { useNavStore } from "@/lib/stores/nav-store"
import {
@ -66,29 +67,26 @@ function formatEmailDate(iso: string): string {
}
export function ContactDetailView({ contactId }: ContactDetailViewProps) {
const { contacts, setView, showContactsList, closePanel } = useContactsStore()
const { setView, showContactsList, closePanel } = useContactsStore()
const { contacts } = useContactsList()
const { openComposeWithInitial } = useComposeActions()
const labelRows = useNavStore((s) => s.labelRows)
const contact = contacts.find((c) => c.id === contactId)
const recentInteractions = useMemo(() => {
if (!contact) return []
const contactEmails = new Set(
contact.emails.map((e) => e.value.toLowerCase()).filter(Boolean)
const primaryContactEmail = contact?.emails[0]?.value
const { data: searchResult } = useMailSearch(
primaryContactEmail ? { from: primaryContactEmail } : null
)
if (contactEmails.size === 0) return []
return allEmails
.filter((email) => {
const se = email.senderEmail?.toLowerCase()
if (se && contactEmails.has(se)) return true
const senderLower = email.sender.toLowerCase()
return [...contactEmails].some((ce) => senderLower.includes(ce.split("@")[0] ?? ""))
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 10)
}, [contact])
const recentInteractions = useMemo(() => {
if (!searchResult?.data) return []
return searchResult.data.slice(0, 10).map((msg) => ({
id: msg.id,
subject: msg.subject,
preview: msg.snippet,
date: msg.date,
}))
}, [searchResult])
if (!contact) {
return (

View File

@ -41,7 +41,10 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store"
import {
@ -127,15 +130,15 @@ interface ContactFormViewProps {
export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
const {
contacts,
addContact,
updateContact,
setView,
showContactsList,
closePanel,
createDraft,
clearCreateDraft,
} = useContactsStore()
const { contacts } = useContactsList()
const createContactMutation = useCreateContact()
const updateContactMutation = useUpdateContact()
const labelRows = useNavStore((s) => s.labelRows)
const [starred, setStarred] = useState(false)
const [nameExpanded, setNameExpanded] = useState(false)
@ -309,10 +312,37 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
}
if (mode === "create") {
const id = addContact(payload)
setView("view", id)
const tempId = crypto.randomUUID()
const fullContact: FullContact = {
id: tempId,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
createContactMutation.mutate(
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
{ onSuccess: (created) => setView("view", created?.uid ?? tempId) },
)
setView("view", tempId)
} else if (contactId) {
updateContact(contactId, payload)
const fullContact: FullContact = {
id: contactId,
...payload,
firstName: payload.firstName ?? "",
lastName: payload.lastName ?? "",
emails: payload.emails ?? [],
phones: payload.phones ?? [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
updateContactMutation.mutate({
path: contactId,
contact: fullContactToApiContact(fullContact),
})
setView("view", contactId)
}
}

View File

@ -6,6 +6,7 @@ import { Search, ExternalLink, X, Plus } from "lucide-react"
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 { searchContacts } from "@/lib/contacts/fuzzy-search"
import { fullContactDisplayName } from "@/lib/contacts/types"
import {
@ -26,7 +27,6 @@ import { ContactsPanelLogo } from "./contacts-panel-logo"
export function ContactsListView() {
const {
contacts,
searchMode,
searchQuery,
setSearchMode,
@ -35,6 +35,7 @@ export function ContactsListView() {
showContactsList,
closePanel,
} = useContactsStore()
const { contacts } = useContactsList()
const searchInputRef = useRef<HTMLInputElement>(null)

View File

@ -1,13 +1,37 @@
"use client"
import { useMemo } from "react"
import { mailLabelShouldShowInListStrip } from "@/components/gmail/mail-label-pills"
import { EmailView } from "@/components/gmail/email-view"
import { LABEL_PICKER_EXCLUDE } from "@/lib/mail-list/label-actions"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import type { Email } from "@/lib/email-data"
import type { ApiMessageSummary } from "@/lib/api/types"
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection"
function emailToApiSummary(email: Email): ApiMessageSummary {
const flags: string[] = []
if (email.read) flags.push("read")
if (email.starred) flags.push("starred")
if (email.important) flags.push("important")
if (email.spam) flags.push("spam")
return {
id: email.id,
message_id: email.id,
thread_id: email.threadHeadId,
account_id: "",
subject: email.subject,
from: [{ name: email.sender, address: email.senderEmail ?? "" }],
to: [],
date: email.date,
snippet: email.preview,
flags,
labels: email.labels ?? [],
has_attachments: email.hasAttachment ?? false,
}
}
type EmailListEmailViewPaneProps = {
data: EmailListData
reading: EmailListReading
@ -17,37 +41,31 @@ type EmailListEmailViewPaneProps = {
export function EmailListEmailViewPane({
data,
reading,
selection,
selection: _selection,
}: EmailListEmailViewPaneProps) {
const {
openEmail,
openEmailThreadRoot,
isSingleMessageView,
handleNavigateToLabel,
singleNotSpam,
} = reading
const { toggleStar } = selection
const {
starredEmails,
listRowLabelBgByTextLower,
sidebarNav,
selectedFolder,
} = data
if (!openEmail) return null
const apiEmail = useMemo(
() => (openEmail ? emailToApiSummary(openEmail) : null),
[openEmail]
)
if (!openEmail || !apiEmail) return null
return (
<EmailView
email={openEmail}
threadRoot={openEmailThreadRoot}
email={apiEmail}
isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar}
isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}

View File

@ -72,7 +72,6 @@ import {
} from "@/lib/mail-chrome-classes"
import { readXsMatches } from "@/hooks/use-xs"
import type { LabelRowItem, FolderTreeNode } from "@/lib/sidebar-nav-data"
import type { LabelEditState } from "@/lib/stores/mail-store"
import {
contextMenuTargetIdsForRow,
formatScheduledDateTimeDisplay,

View File

@ -144,7 +144,7 @@ export type EmailListToolbarProps = {
tabUnseenSenderLineById: Record<string, string>
handleCategoryInboxTabClick: (tabId: string) => void
searchParams: SearchParams | null
searchAccount: { email: string }
searchAccount: { email: string } | null
allEmails: Email[]
setSearchFilter: (patch: Partial<SearchParams>) => void
toggleSearchFilter: (key: keyof SearchParams, value: string) => void
@ -1202,8 +1202,8 @@ const mailPaginationControls = (mode: "list" | "view") => (
<DropdownMenuItem onSelect={() => setSearchFilter({ from: "" })}>
N&apos;importe qui
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSearchFilter({ from: searchAccount.email })}>
De moi ({searchAccount.email})
<DropdownMenuItem onSelect={() => setSearchFilter({ from: searchAccount?.email ?? "" })}>
De moi ({searchAccount?.email})
</DropdownMenuItem>
<DropdownMenuSeparator />
{Array.from(new Set(allEmails.map((e) => e.senderEmail).filter(Boolean))).slice(0, 8).map((addr) => (
@ -1297,8 +1297,8 @@ const mailPaginationControls = (mode: "list" | "view") => (
<DropdownMenuItem onSelect={() => setSearchFilter({ to: "" })}>
N&apos;importe qui
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSearchFilter({ to: searchAccount.email })}>
À moi ({searchAccount.email})
<DropdownMenuItem onSelect={() => setSearchFilter({ to: searchAccount?.email ?? "" })}>
À moi ({searchAccount?.email})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -8,12 +8,18 @@ import {
useState,
} from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { useQueryClient } from "@tanstack/react-query"
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills"
import { emails } from "@/lib/email-data"
import { useMessages, useMailSearch } from "@/lib/api/hooks/use-mail-queries"
import {
useUpdateFlags,
useUpdateLabels,
useDeleteMessage,
} from "@/lib/api/hooks/use-mail-mutations"
import type { ApiMessageSummary, PaginatedResponse } from "@/lib/api/types"
import type { Email, EmailAttachment } from "@/lib/email-data"
import {
isListRowRead,
isThreadHeadMessage,
readStateTargets,
} from "@/lib/mail-thread"
import { useScheduledMail } from "@/lib/scheduled-mail-context"
import { useMailStore } from "@/lib/stores/mail-store"
@ -24,11 +30,7 @@ import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import {
emailMatchesFolder,
emailMatchesInboxPrimaryTab,
type MailNavFolderMaps,
} from "@/lib/mail-folder-filter"
import type { MailNavFolderMaps } from "@/lib/mail-folder-filter"
import {
getMailNavFolderLabel,
inboxTabDisplayLabel,
@ -45,7 +47,6 @@ import {
buildSearchUrl,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
@ -53,18 +54,6 @@ import {
useComposeActions,
useComposeDrafts,
} from "@/lib/compose-context"
import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
import {
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import type { LabelEditState } from "@/lib/stores/mail-store"
import { useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
import {
applyNavRenameToEdits,
applyNavRemoveLabelToEdits,
} from "@/lib/mail-list/label-actions"
import {
LIST_PAGE_SIZE,
type EmailListProps,
@ -75,9 +64,33 @@ import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { attachmentsForEmailList } from "@/lib/attachment-display"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
import type { Email, EmailAttachment } from "@/lib/email-data"
import { cleanSenderName } from "@/lib/sender-display"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import { useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
import type { MessageSearchFilter } from "@/lib/api/types"
function apiMessageToEmail(msg: ApiMessageSummary): Email {
const sender = msg.from[0]?.name || msg.from[0]?.address || ""
const senderEmail = msg.from[0]?.address || ""
return {
id: msg.id,
sender,
senderEmail,
subject: msg.subject,
preview: msg.snippet,
date: msg.date,
read: msg.flags.includes("read"),
starred: msg.flags.includes("starred"),
important: msg.flags.includes("important"),
spam: msg.labels.includes("spam"),
hasAttachment: msg.has_attachments,
labels: msg.labels,
threadHeadId: msg.thread_id ?? msg.id,
threadMessageIds: [msg.id],
isThreadHead: true,
}
}
export function useEmailListData({
selectedFolder,
@ -138,7 +151,6 @@ export function useEmailListData({
const {
scheduledEmails,
snoozedEmails,
sentPlaceholderEmails,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
@ -152,19 +164,100 @@ export function useEmailListData({
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
const allEmails = useMemo(
() =>
scheduledPersistHydrated
? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails]
: emails,
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
const accountId = searchAccount?.id
const queryClient = useQueryClient()
const effectiveApiFolder = useMemo(() => {
if (isSearchMode) return "__search__"
if (selectedFolder === "scheduled" || selectedFolder === "snoozed") return "__local__"
if (selectedFolder !== "inbox") return selectedFolder
const tab = normalizeInboxTabSegment(inboxTab)
if (tab === INBOX_ALL_TAB) return "inbox"
return tab
}, [selectedFolder, inboxTab, isSearchMode])
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
if (!isSearchMode || !searchParams) return null
return {
q: searchParams.q || undefined,
from: searchParams.from || undefined,
label: searchParams.in !== "all" ? searchParams.in : undefined,
account_id: accountId,
date_from: searchParams.after || undefined,
date_to: searchParams.before || undefined,
has_attachment: searchParams.has.includes("attachment") ? true : undefined,
}
}, [isSearchMode, searchParams, accountId])
const messagesQuery = useMessages(
effectiveApiFolder === "__search__" || effectiveApiFolder === "__local__"
? "inbox"
: effectiveApiFolder,
accountId,
listPage
)
const searchQuery = useMailSearch(searchFilter)
const updateFlags = useUpdateFlags()
const updateLabels = useUpdateLabels()
const deleteMessage = useDeleteMessage()
const apiMessages: ApiMessageSummary[] = useMemo(() => {
if (isSearchMode) return searchQuery.data?.data ?? []
if (effectiveApiFolder === "__local__") return []
return messagesQuery.data?.data ?? []
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data])
const apiEmails: Email[] = useMemo(
() => apiMessages.map(apiMessageToEmail),
[apiMessages]
)
const apiMessagesById = useMemo(
() => new Map(apiMessages.map((m) => [m.id, m])),
[apiMessages]
)
const allEmails = useMemo(() => {
if (selectedFolder === "scheduled" && scheduledPersistHydrated) {
return scheduledEmails.map<Email>((entry) => ({
id: entry.id,
sender: entry.to[0]?.name ?? "Destinataire",
senderEmail: entry.to[0]?.address,
subject: entry.subject || "(Sans objet)",
preview: "",
body: "",
date: entry.scheduled_at ?? entry.created_at,
read: true,
starred: false,
important: false,
labels: ["scheduled"],
scheduledSendAt: entry.scheduled_at,
scheduledToName: entry.to[0]?.name,
}))
}
if (selectedFolder === "snoozed" && scheduledPersistHydrated) {
return snoozedEmails
}
return apiEmails
}, [
selectedFolder,
scheduledPersistHydrated,
scheduledEmails,
snoozedEmails,
apiEmails,
])
const emailById = useMemo(
() => new Map(allEmails.map((e) => [e.id, e])),
[allEmails]
)
const isLoading = isSearchMode ? searchQuery.isLoading : messagesQuery.isLoading
const error = isSearchMode ? searchQuery.error : messagesQuery.error
const isFetching = isSearchMode ? searchQuery.isFetching : messagesQuery.isFetching
const sidebarNav = useSidebarNav()
const navMaps = useMemo<MailNavFolderMaps>(
() => ({
@ -255,45 +348,105 @@ export function useEmailListData({
pruneInlineComposesToOpenThread,
])
const starredEmails = useMailStore((s) => s.starredIds)
const importantEmails = useMailStore((s) => s.importantIds)
const readOverrides = useMailStore((s) => s.readOverrides)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const density = useMailSettingsStore((s) => s.density)
const isMd = useIsMd()
const labelEdits = useMailStore((s) => s.labelEdits)
const mailActions = useRef(useMailStore.getState()).current
const readOverrides = useMemo<Record<string, boolean>>(() => ({}), [])
const starredEmails = useMemo<string[]>(() => [], [])
const importantEmails = useMemo<string[]>(() => [], [])
const labelEdits = useMemo(() => ({ additions: {} as Record<string, string[]>, removals: {} as Record<string, string[]> }), [])
const hiddenEmailIds = useMemo<string[]>(() => [], [])
const notSpamEmailIds = useMemo<string[]>(() => [], [])
const setReadOverrides = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
const current = useMailStore.getState().readOverrides
const next = updater(current)
if (next !== current) mailActions.setReadOverrides(next)
const changes = updater({})
for (const [id, isRead] of Object.entries(changes)) {
const msg = apiMessagesById.get(id)
if (!msg) continue
const flags = [...msg.flags]
if (isRead && !flags.includes("read")) {
updateFlags.mutate({ id, flags: [...flags, "read"] })
} else if (!isRead && flags.includes("read")) {
updateFlags.mutate({ id, flags: flags.filter((f) => f !== "read") })
}
}
},
[mailActions]
[apiMessagesById, updateFlags]
)
const setLabelEdits = useCallback(
(updater: (prev: LabelEditState) => LabelEditState) => {
mailActions.setLabelEdits(updater)
},
[mailActions]
(updater: (prev: { additions: Record<string, string[]>; removals: Record<string, string[]> }) => { additions: Record<string, string[]>; removals: Record<string, string[]> }) => {
const result = updater({ additions: {}, removals: {} })
for (const [id, additions] of Object.entries(result.additions)) {
const msg = apiMessagesById.get(id)
if (!msg) continue
const newLabels = [...new Set([...msg.labels, ...additions])]
const removals = result.removals[id] ?? []
const finalLabels = newLabels.filter(
(l) => !removals.some((r) => r.toLowerCase() === l.toLowerCase())
)
updateLabels.mutate({ id, labels: finalLabels })
}
for (const [id, removals] of Object.entries(result.removals)) {
if (result.additions[id]) continue
const msg = apiMessagesById.get(id)
if (!msg) continue
const finalLabels = msg.labels.filter(
(l) => !removals.some((r) => r.toLowerCase() === l.toLowerCase())
)
updateLabels.mutate({ id, labels: finalLabels })
}
},
[apiMessagesById, updateLabels]
)
const mailActions = useMemo(() => ({
markSeen: (id: string) => useMailStore.getState().markSeen(id),
pushRecentMoveTarget: (targetId: string) => useMailStore.getState().pushRecentMoveTarget(targetId),
hideEmail: (id: string) => deleteMessage.mutate({ id }),
hideEmails: (ids: string[]) => { for (const id of ids) deleteMessage.mutate({ id }) },
markNotSpam: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const newLabels = msg.labels.filter((l) => l !== "spam")
if (!newLabels.includes("inbox")) newLabels.push("inbox")
updateLabels.mutate({ id, labels: newLabels })
},
unhideEmail: (_id: string) => { /* no-op - API manages visibility */ },
toggleStar: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const flags = msg.flags.includes("starred")
? msg.flags.filter((f) => f !== "starred")
: [...msg.flags, "starred"]
updateFlags.mutate({ id, flags })
},
toggleImportant: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const flags = msg.flags.includes("important")
? msg.flags.filter((f) => f !== "important")
: [...msg.flags, "important"]
updateFlags.mutate({ id, flags })
},
}), [deleteMessage, updateLabels, updateFlags, apiMessagesById])
useEffect(() => {
registerNavEmailSync({
renameLabel: (from, to) => {
setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to))
renameLabel: (_from, _to) => {
queryClient.invalidateQueries({ queryKey: ["messages"] })
},
removeLabel: (label) => {
setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label))
removeLabel: (_label) => {
queryClient.invalidateQueries({ queryKey: ["messages"] })
},
})
return () => registerNavEmailSync(null)
}, [allEmails, setLabelEdits])
}, [queryClient])
const [labelPickerQuery, setLabelPickerQuery] = useState("")
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
const isXs = useIsXs()
@ -303,8 +456,8 @@ export function useEmailListData({
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
const handleRefreshMessages = useCallback(async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
}, [])
await queryClient.invalidateQueries({ queryKey: ["messages"] })
}, [queryClient])
const {
isRefreshing,
@ -329,93 +482,12 @@ export function useEmailListData({
}, [isRefreshing, handleRefreshMessages, setIsRefreshing])
const markEmailSeen = useCallback((id: string) => {
mailActions.markSeen(id)
}, [mailActions])
const folderFilterCtx = useMemo(
() => ({
starredEmailIds: starredEmails,
importantEmailIds: importantEmails,
}),
[starredEmails, importantEmails]
)
useMailStore.getState().markSeen(id)
}, [])
const filteredEmails = useMemo(() => {
const hiddenSet = new Set(hiddenEmailIds)
const subtreeIdsCache = new Map<string, string[] | null>()
let visible = allEmails.filter((email) => !hiddenSet.has(email.id))
const hasLabelEdits =
labelEdits &&
(Object.keys(labelEdits.additions).length > 0 ||
Object.keys(labelEdits.removals).length > 0)
if (hasLabelEdits || notSpamEmailIds.length > 0) {
visible = visible.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
}
if (isSearchMode && searchParams) {
return filterEmailsBySearchParams(visible, searchParams, {
starredIds: starredEmails,
importantIds: importantEmails,
})
}
let rows = visible.filter((email) =>
emailMatchesFolder(
email,
selectedFolder,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
if (selectedFolder === "inbox") {
const tab = normalizeInboxTabSegment(inboxTab)
if (tab === "primary") {
rows = rows.filter((email) =>
emailMatchesInboxPrimaryTab(
email,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
} else if (tab !== INBOX_ALL_TAB) {
rows = rows.filter(
(email) =>
emailMatchesFolder(
email,
"inbox",
folderFilterCtx,
navMaps,
subtreeIdsCache
) &&
emailMatchesFolder(
email,
tab,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
}
}
return rows
}, [
selectedFolder,
inboxTab,
hiddenEmailIds,
folderFilterCtx,
labelEdits,
notSpamEmailIds,
allEmails,
navMaps,
isSearchMode,
searchParams,
starredEmails,
importantEmails,
])
return allEmails
}, [allEmails])
const displayListEmails = useMemo(() => {
let rows = filteredEmails
@ -426,9 +498,9 @@ export function useEmailListData({
rows,
inboxSort,
{
readOverrides,
starredIds: starredEmails,
importantIds: importantEmails,
readOverrides: {},
starredIds: [],
importantIds: [],
},
{ conversationMode, byId: emailById }
)
@ -436,9 +508,6 @@ export function useEmailListData({
filteredEmails,
conversationMode,
inboxSort,
readOverrides,
starredEmails,
importantEmails,
emailById,
])
@ -453,11 +522,8 @@ export function useEmailListData({
)
const mobileUnreadCount = useMemo(
() =>
displayListEmails.filter(
(e) => !isListRowRead(e, readOverrides, emailById, conversationMode)
).length,
[displayListEmails, readOverrides, emailById, conversationMode]
() => displayListEmails.filter((e) => !e.read).length,
[displayListEmails]
)
const mobileFolderLabel = useMemo(() => {
@ -474,15 +540,24 @@ export function useEmailListData({
isSearchMode,
])
const paginationTotal = useMemo(() => {
if (isSearchMode) return searchQuery.data?.pagination?.total
if (effectiveApiFolder === "__local__") return allEmails.length
return messagesQuery.data?.pagination?.total
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data, allEmails.length])
const totalPages = useMemo(
() => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)),
[displayListEmails.length]
() => Math.max(1, Math.ceil((paginationTotal ?? displayListEmails.length) / LIST_PAGE_SIZE)),
[paginationTotal, displayListEmails.length]
)
const pagedEmails = useMemo(() => {
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
return displayListEmails
}
const start = (listPage - 1) * LIST_PAGE_SIZE
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
}, [displayListEmails, listPage])
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode])
const listEmails = useMemo(() => {
if (isXs && !isViewMode) {
@ -493,6 +568,14 @@ export function useEmailListData({
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
const folderFilterCtx = useMemo(
() => ({
starredEmailIds: [] as string[],
importantEmailIds: [] as string[],
}),
[]
)
const listRowExtras = useMemo(() => {
const invitationById = new Map<
string,
@ -575,27 +658,7 @@ export function useEmailListData({
currentFolderId: selectedFolder,
})
const folderUnreadCounts = useMemo(
() =>
computeFolderUnreadCounts(
allEmails,
folderFilterCtx,
hiddenEmailIds,
readOverrides,
navMaps,
labelEdits,
notSpamEmailIds
),
[
folderFilterCtx,
hiddenEmailIds,
readOverrides,
allEmails,
navMaps,
labelEdits,
notSpamEmailIds,
]
)
const folderUnreadCounts = useMemo<Record<string, number>>(() => ({}), [])
const seenSerialized = useMemo(
() => [...seenEmailIds].sort().join(","),
@ -606,35 +669,11 @@ export function useEmailListData({
const seen = new Set(
seenSerialized.length > 0 ? seenSerialized.split(",") : []
)
const hidden = new Set(hiddenEmailIds)
const visible = allEmails
.filter((email) => !hidden.has(email.id))
.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
const inboxPool = visible.filter((e) =>
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
)
const inboxPool = allEmails.filter((e) => !seen.has(e.id))
const counts: Record<string, number> = {}
const preview: Record<string, string> = {}
const tabCache = new Map<string, string[] | null>()
for (const tab of inboxTabBarItems) {
const rows = inboxPool.filter((e) => {
if (tab.id === "primary") {
return (
emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
)
}
if (tab.id === INBOX_ALL_TAB) {
return !seen.has(e.id)
}
return (
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) &&
emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
)
})
const rows = inboxPool.filter((e) => !seen.has(e.id))
counts[tab.id] = rows.length
if (inboxTabShowsInactiveMeta(tab.id)) {
const chain: string[] = []
@ -650,7 +689,7 @@ export function useEmailListData({
}
}
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
}, [seenSerialized, allEmails, inboxTabBarItems])
useEffect(() => {
onFolderUnreadCountsChange?.(folderUnreadCounts)
@ -667,28 +706,25 @@ export function useEmailListData({
const listRowsDep = listEmails.map((e) => e.id).join(",")
const effectiveRead = useCallback(
(email: Email) =>
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read,
[readOverrides]
(email: Email) => email.read,
[]
)
const effectiveStarred = useCallback(
(email: Email) =>
starredEmails.includes(email.id) || email.starred,
[starredEmails]
(email: Email) => email.starred,
[]
)
const markAllInViewAsRead = useCallback(() => {
setReadOverrides((prev) => {
const next = { ...prev }
for (const e of displayListEmails) {
for (const id of readStateTargets(e, conversationMode)) {
next[id] = true
if (e.read) continue
const msg = apiMessagesById.get(e.id)
if (!msg) continue
if (!msg.flags.includes("read")) {
updateFlags.mutate({ id: e.id, flags: [...msg.flags, "read"] })
}
}
return next
})
}, [displayListEmails, conversationMode, setReadOverrides])
}, [displayListEmails, apiMessagesById, updateFlags])
return {
selectedFolder,
@ -779,6 +815,9 @@ export function useEmailListData({
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
isLoading,
error,
isFetching,
}
}

View File

@ -3,11 +3,6 @@
import { useCallback, useMemo } from "react"
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
import { resolveLabelPickerVisual } from "@/lib/label-picker-visual"
import {
effectiveLabels,
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
import {
LABEL_PICKER_EXCLUDE,
@ -21,8 +16,6 @@ export function useEmailListLabels(data: EmailListData) {
const {
allEmails,
sidebarNav,
labelEdits,
notSpamEmailIds,
setLabelEdits,
mailActions,
} = data
@ -52,20 +45,16 @@ export function useEmailListLabels(data: EmailListData) {
for (const id of emailIds) {
const email = allEmails.find((e) => e.id === id)
const currentLabels = effectiveLabels(email, nextAdd, nextRem)
const currentLabels = email?.labels ?? []
if (isSystemTarget) {
if (targetId === "inbox") {
for (const lab of currentLabels) {
if (allFolderLabels.has(lab.toLowerCase())) {
const cur = nextRem[id] ?? []
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
if (!cur.some((l: string) => l.toLowerCase() === lab.toLowerCase())) {
nextRem[id] = [...cur, lab]
}
if (nextAdd[id]?.length) {
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
if (nextAdd[id].length === 0) delete nextAdd[id]
}
}
}
}
@ -73,22 +62,14 @@ export function useEmailListLabels(data: EmailListData) {
for (const lab of currentLabels) {
if (allFolderLabels.has(lab.toLowerCase()) && lab.toLowerCase() !== folderLabel.toLowerCase()) {
const cur = nextRem[id] ?? []
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
if (!cur.some((l: string) => l.toLowerCase() === lab.toLowerCase())) {
nextRem[id] = [...cur, lab]
}
if (nextAdd[id]?.length) {
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
if (nextAdd[id].length === 0) delete nextAdd[id]
}
}
}
if (!currentLabels.some((l) => l.toLowerCase() === folderLabel.toLowerCase())) {
nextAdd[id] = [...(nextAdd[id] ?? []), folderLabel]
}
if (nextRem[id]?.length) {
nextRem[id] = nextRem[id].filter((l) => l.toLowerCase() !== folderLabel.toLowerCase())
if (nextRem[id].length === 0) delete nextRem[id]
}
const inboxIdx = currentLabels.findIndex((l) => l.toLowerCase() === "inbox")
if (inboxIdx >= 0 || !email?.labels?.length || email.labels.includes("inbox")) {
const cur = nextRem[id] ?? []
@ -118,16 +99,12 @@ export function useEmailListLabels(data: EmailListData) {
for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l)
for (const row of sidebarNav.labelRows) s.add(row.label)
for (const e of allEmails) {
const eff = mergeEmailNotSpam(
mergeEmailLabelEdits(e, labelEdits),
notSpamEmailIds
)
for (const lab of eff.labels ?? []) {
for (const lab of e.labels ?? []) {
if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab)
}
}
return [...s].sort((a, b) => a.localeCompare(b, "fr"))
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails])
const resolveLabelVisual = useCallback(
(label: string) =>
@ -162,15 +139,9 @@ export function useEmailListLabels(data: EmailListData) {
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
for (const id of ids) {
if (nextRem[id]?.length) {
nextRem[id] = nextRem[id].filter(
(x) => x.toLowerCase() !== resolved.toLowerCase()
)
if (nextRem[id].length === 0) delete nextRem[id]
}
const base = allEmails.find((e) => e.id === id)
const merged = effectiveLabels(base, nextAdd, nextRem)
if (merged.some((x) => x.toLowerCase() === resolved.toLowerCase())) {
const currentLabels = base?.labels ?? []
if (currentLabels.some((x: string) => x.toLowerCase() === resolved.toLowerCase())) {
continue
}
nextAdd[id] = [...(nextAdd[id] ?? []), resolved]
@ -189,14 +160,14 @@ export function useEmailListLabels(data: EmailListData) {
let n = 0
for (const id of ids) {
const e = allEmails.find((x) => x.id === id)
const eff = effectiveLabels(e, labelEdits.additions, labelEdits.removals)
if (eff.some((l) => l.toLowerCase() === lc)) n++
const labels = e?.labels ?? []
if (labels.some((l: string) => l.toLowerCase() === lc)) n++
}
if (n === 0) return "none"
if (n === ids.length) return "all"
return "some"
},
[allEmails, labelEdits, resolveLabelCasing]
[allEmails, resolveLabelCasing]
)
const toggleLabelOnEmails = useCallback(
@ -208,8 +179,8 @@ export function useEmailListLabels(data: EmailListData) {
const presence = (id: string) => {
const e = allEmails.find((x) => x.id === id)
if (!e) return false
return effectiveLabels(e, prev.additions, prev.removals).some(
(l) => l.toLowerCase() === resolved.toLowerCase()
return (e.labels ?? []).some(
(l: string) => l.toLowerCase() === resolved.toLowerCase()
)
}
const allHave = ids.every((id) => presence(id))
@ -218,30 +189,7 @@ export function useEmailListLabels(data: EmailListData) {
if (allHave) {
for (const id of ids) {
if (nextAdd[id]?.length) {
const filtered = nextAdd[id].filter(
(l) => l.toLowerCase() !== resolved.toLowerCase()
)
if (filtered.length) nextAdd[id] = filtered
else delete nextAdd[id]
}
const e = allEmails.find((x) => x.id === id)
if (!e) continue
const still = effectiveLabels(e, nextAdd, nextRem).some(
(l) => l.toLowerCase() === resolved.toLowerCase()
)
if (still) {
const cur = nextRem[id] ?? []
if (!cur.some((l) => l.toLowerCase() === resolved.toLowerCase())) {
nextRem[id] = [...cur, resolved]
}
} else if (nextRem[id]?.length) {
const fr = nextRem[id].filter(
(l) => l.toLowerCase() !== resolved.toLowerCase()
)
if (fr.length) nextRem[id] = fr
else delete nextRem[id]
}
nextRem[id] = [...(nextRem[id] ?? []), resolved]
}
} else {
const anyMissing = ids.some((id) => !presence(id))
@ -249,23 +197,8 @@ export function useEmailListLabels(data: EmailListData) {
queueMicrotask(() => sidebarNav.ensureLabelRowForLabelText(resolved))
}
for (const id of ids) {
const e = allEmails.find((x) => x.id === id)
if (!e) continue
const had = effectiveLabels(e, prev.additions, prev.removals).some(
(l) => l.toLowerCase() === resolved.toLowerCase()
)
if (nextRem[id]?.length) {
const fr = nextRem[id].filter(
(l) => l.toLowerCase() !== resolved.toLowerCase()
)
if (fr.length) nextRem[id] = fr
else delete nextRem[id]
}
if (!had) {
if (!nextAdd[id]) nextAdd[id] = []
if (!nextAdd[id].some((l) => l.toLowerCase() === resolved.toLowerCase())) {
nextAdd[id] = [...nextAdd[id], resolved]
}
if (!presence(id)) {
nextAdd[id] = [...(nextAdd[id] ?? []), resolved]
}
}
}

View File

@ -11,10 +11,6 @@ import type { Email } from "@/lib/email-data"
import { readStateTargets } from "@/lib/mail-thread"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email"
import {
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import {
DEFAULT_INBOX_TAB,
} from "@/lib/mail-url"
@ -59,9 +55,6 @@ export function useEmailListReading(
listRowsDep,
listViewportRef,
conversationMode,
labelEdits,
notSpamEmailIds,
readOverrides,
setReadOverrides,
markEmailSeen,
mailActions,
@ -82,20 +75,12 @@ export function useEmailListReading(
)
if (!resolved) return null
if (resolved.email.labels?.includes("scheduled")) return null
const email = mergeEmailNotSpam(
mergeEmailLabelEdits(resolved.email, labelEdits),
notSpamEmailIds
)
const threadRoot = mergeEmailNotSpam(
mergeEmailLabelEdits(resolved.threadRoot, labelEdits),
notSpamEmailIds
)
return {
email,
threadRoot,
email: resolved.email,
threadRoot: resolved.threadRoot,
isSingleMessageView: resolved.isSingleMessageView,
}
}, [openMailId, labelEdits, allEmails, notSpamEmailIds, conversationMode])
}, [openMailId, allEmails, conversationMode])
const openEmail = openEmailView?.email ?? null
const openEmailThreadRoot = openEmailView?.threadRoot ?? null
@ -116,15 +101,11 @@ export function useEmailListReading(
markEmailSeen(id)
}
setReadOverrides((prev) => {
let changed = false
const next = { ...prev }
for (const id of targets) {
if (next[id] === undefined) {
next[id] = true
changed = true
}
}
return changed ? next : prev
return next
})
}, [openMailId, markEmailSeen, emailById, conversationMode, setReadOverrides])
@ -211,8 +192,6 @@ export function useEmailListReading(
(emailRow: Email) => {
void data.requestRestoreSnoozedToInbox(emailRow)
if (emailRow.id.startsWith("snz-")) {
const baseId = emailRow.id.slice(4)
if (baseId.length > 0) mailActions.unhideEmail(baseId)
onSelectFolder?.("inbox")
} else {
onSelectFolder?.("scheduled")
@ -221,7 +200,6 @@ export function useEmailListReading(
},
[
data,
mailActions,
closeViewIfShowingEmail,
onSelectFolder,
]
@ -288,7 +266,7 @@ export function useEmailListReading(
if (openMailIndex > 0) {
const id = displayListEmails[openMailIndex - 1]!.id
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
setReadOverrides(() => ({ [id]: true }))
navigateToMail(id)
}
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides])
@ -297,7 +275,7 @@ export function useEmailListReading(
if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) {
const id = displayListEmails[openMailIndex + 1]!.id
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
setReadOverrides(() => ({ [id]: true }))
navigateToMail(id)
}
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides])
@ -307,7 +285,7 @@ export function useEmailListReading(
const em = allEmails.find((e) => e.id === id)
if (em?.labels?.includes("scheduled")) return
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
setReadOverrides(() => ({ [id]: true }))
navigateToMail(id)
},
[navigateToMail, markEmailSeen, allEmails, setReadOverrides]
@ -316,7 +294,7 @@ export function useEmailListReading(
const openDraftInCompose = useCallback(
(email: Email) => {
markEmailSeen(email.id)
setReadOverrides((prev) => ({ ...prev, [email.id]: true }))
setReadOverrides(() => ({ [email.id]: true }))
const to: Contact[] = email.senderEmail
? [{ name: email.sender.trim(), email: email.senderEmail }]
: []
@ -350,10 +328,8 @@ export function useEmailListReading(
const viewModeIsRead = useMemo(() => {
if (!openEmail) return true
return readOverrides[openEmail.id] !== undefined
? readOverrides[openEmail.id]!
: openEmail.read
}, [openEmail, readOverrides])
return openEmail.read
}, [openEmail])
const afterSingleMessageRemoved = useCallback(
(removedId: string) => {
@ -394,7 +370,8 @@ export function useEmailListReading(
const singleToggleRead = useCallback(() => {
if (!openMailId) return
setReadOverrides((prev) => ({ ...prev, [openMailId]: !viewModeIsRead }))
const next = !viewModeIsRead
setReadOverrides(() => ({ [openMailId]: next }))
}, [openMailId, viewModeIsRead, setReadOverrides])
const singleMoveTo = useCallback(

View File

@ -1,15 +1,12 @@
"use client"
import { useCallback } from "react"
import type { Email } from "@/lib/email-data"
import { useMailStore } from "@/lib/stores/mail-store"
export type ListMailIndex = {
emailById: Map<string, Email>
scheduledIds: Set<string>
}
/** O(n) index for list row logic — avoids repeated `allEmails.some` / `find` per row. */
export function buildListMailIndex(emails: Email[]): ListMailIndex {
const emailById = new Map<string, Email>()
const scheduledIds = new Set<string>()
@ -26,24 +23,10 @@ export type MailRowFlags = {
isImportant: boolean
}
/**
* Per-row mail UI flags from the persisted mail store.
* Use inside a keyed `memo` row component (not a plain `.map` callback).
*/
export function useMailRowFlags(email: Email): MailRowFlags {
const id = email.id
const readOverride = useMailStore(
useCallback((s) => s.readOverrides[id], [id])
)
const starred = useMailStore(
useCallback((s) => s.starredIds.includes(id), [id])
)
const important = useMailStore(
useCallback((s) => s.importantIds.includes(id), [id])
)
return {
isRead: readOverride !== undefined ? readOverride : email.read,
isStarred: starred || email.starred,
isImportant: important || email.important,
isRead: email.read,
isStarred: email.starred,
isImportant: email.important,
}
}

View File

@ -6,14 +6,10 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
} from "react"
import { Star, Reply, ReplyAll, Forward } from "lucide-react"
import { Reply, ReplyAll, Forward } from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import {
@ -21,8 +17,16 @@ import {
cleanSenderName,
senderInitial,
} from "@/lib/sender-display"
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
import type { Email, EmailAttachment } from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { useMessage, useThread } from "@/lib/api/hooks/use-mail-queries"
import {
useToggleStar,
useMarkRead,
useUpdateFlags,
useUpdateLabels,
} from "@/lib/api/hooks/use-mail-mutations"
import {
useComposeActions,
useComposeDrafts,
@ -52,36 +56,55 @@ import {
SpamWhyBanner,
} from "@/components/gmail/email-view/email-view-messages"
function apiToLegacyEmail(
msg: ApiMessageSummary,
full?: ApiMessageFull | null,
thread?: ApiMessageFull[] | null
): Email {
const senderName = msg.from[0]?.name ?? ""
return {
id: msg.id,
sender: senderName,
senderEmail: msg.from[0]?.address,
subject: msg.subject,
preview: msg.snippet,
body: full?.body_html ?? full?.body_text,
date: msg.date,
read: msg.flags.includes("read"),
starred: msg.flags.includes("starred"),
important: msg.flags.includes("important"),
spam: msg.flags.includes("spam") || msg.labels.includes("spam"),
labels: msg.labels,
hasAttachment: msg.has_attachments,
conversation: thread
?.filter((m) => m.id !== msg.id)
.map((m) => ({
id: m.id,
sender: m.from[0]?.name ?? "",
senderEmail: m.from[0]?.address ?? "",
date: m.date,
body: m.body_html ?? m.body_text ?? "",
preview: m.snippet,
})),
}
}
interface EmailViewProps {
email: Email
onToggleStar: (id: string) => void
isStarred: boolean
email: ApiMessageSummary
onNavigateToLabel?: (label: string) => void
/** Message spam : bannière + pastille sujet ; bouton « non-spam » */
onNotSpam?: () => void
/** Si défini, les pastilles libellé dont la fonction retourne false sont masquées (préférences barre latérale). */
showLabelChip?: (label: string) => boolean
labelBgByText?: Map<string, string>
emailLabelToSidebarFolderId?: Record<string, string>
getNavItemPrefs?: (id: string) => { messages: string }
folderTree?: FolderTreeNode[]
labelRows?: readonly LabelRowItem[]
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
currentFolderId?: string
/** Fil complet (mode message isolé hors conversation). */
threadRoot?: Email | null
/** Affiche uniquement le message courant avec option douvrir le fil. */
isSingleMessageView?: boolean
}
/* ── Main EmailView component ── */
export function EmailView({
email,
onToggleStar,
isStarred,
onNavigateToLabel,
onNotSpam,
showLabelChip,
labelBgByText,
emailLabelToSidebarFolderId = {},
@ -89,47 +112,82 @@ export function EmailView({
folderTree,
labelRows,
currentFolderId,
threadRoot = null,
isSingleMessageView = false,
}: EmailViewProps) {
const { data: fullMessage } = useMessage(email.id)
const { data: threadMessages } = useThread(email.thread_id ?? null)
const toggleStar = useToggleStar()
const markRead = useMarkRead()
const updateFlags = useUpdateFlags()
const updateLabels = useUpdateLabels()
const flags = fullMessage?.flags ?? email.flags
const isStarred = flags.includes("starred")
const isSpam = flags.includes("spam") || email.labels.includes("spam")
const initialFlagsRef = useRef(flags)
useEffect(() => {
initialFlagsRef.current = email.flags
}, [email.id, email.flags])
useEffect(() => {
if (!initialFlagsRef.current.includes("read")) {
markRead.mutate({ id: email.id, flags: initialFlagsRef.current })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email.id])
const body =
fullMessage?.body_html ??
fullMessage?.body_text ??
`<p style="color:var(--muted-foreground);">${email.snippet}</p>`
const [showFullThread, setShowFullThread] = useState(false)
const threadForReplies = threadRoot ?? email
const priorCount = Math.max(
0,
(threadForReplies.threadMessageIds?.length ?? 1) - 1
)
const priorMessages = useMemo(() => {
if (!threadMessages) return []
return threadMessages.filter((m) => m.id !== email.id)
}, [threadMessages, email.id])
const priorCount = priorMessages.length
const showRepliesCta =
isSingleMessageView && !showFullThread && priorCount > 0
const conversation =
isSingleMessageView && !showFullThread
? []
: (showFullThread ? threadForReplies.conversation : email.conversation) ?? []
isSingleMessageView && !showFullThread ? [] : priorMessages
const hasConversation = conversation.length > 0
const isSpamMessage = email.spam === true
// Track which conversation messages are expanded (by index).
// By default all previous messages are collapsed, only the last (main) is expanded.
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const toggleExpanded = (msgId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(msgId)) {
next.delete(msgId)
} else {
next.add(msgId)
}
if (next.has(msgId)) next.delete(msgId)
else next.add(msgId)
return next
})
}
const mainSenderName = cleanSenderName(email.sender)
const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
const mainSenderName = cleanSenderName(email.from[0]?.name ?? "")
const mainSenderAddr =
email.from[0]?.address ??
`${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
const legacyEmail = useMemo(
() => apiToLegacyEmail(email, fullMessage, threadMessages),
[email, fullMessage, threadMessages]
)
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
if (email.has_attachments)
return [{ name: "Pièce jointe", kind: "other" }]
return []
}, [email.has_attachments])
const { composeWindows } = useComposeWindows()
const { savedThreadReplyDrafts } = useComposeDrafts()
const { openComposeWithInitial } = useComposeActions()
const inlineCompose = useMemo(
() =>
composeWindows.find(
@ -138,13 +196,6 @@ export function EmailView({
[composeWindows, email.id]
)
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
if (email.attachments && email.attachments.length > 0) return email.attachments
if (email.hasAttachment) return [{ name: "Pièce jointe", kind: "other" }]
return []
}, [email.attachments, email.hasAttachment])
const savedThreadDraft = savedThreadReplyDrafts[email.id]
const hasInlineForThread = Boolean(inlineCompose)
const showReplyForwardBar = !inlineCompose
@ -174,41 +225,64 @@ export function EmailView({
[openComposeWithInitial, scrollThreadComposeIntoView]
)
const savedThreadDraft = savedThreadReplyDrafts[email.id]
useEffect(() => {
if (!savedThreadDraft || hasInlineForThread) return
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
}, [
email.id,
savedThreadDraft,
hasInlineForThread,
openThreadCompose,
])
}, [email.id, savedThreadDraft, hasInlineForThread, openThreadCompose])
const startThreadCompose = useCallback(
(kind: ThreadComposeKind) => {
openThreadCompose(buildThreadComposePreset(email, kind))
openThreadCompose(buildThreadComposePreset(legacyEmail, kind))
},
[email, openThreadCompose]
[legacyEmail, openThreadCompose]
)
const selfIdentity = DEFAULT_IDENTITIES[0]
const selfName = cleanSenderName(selfIdentity.name)
const calendarInvitation = useMemo(
() => resolveParsedCalendarInvitation(email),
[email]
() => resolveParsedCalendarInvitation(legacyEmail),
[legacyEmail]
)
const handleToggleStar = useCallback(() => {
toggleStar.mutate({ id: email.id, flags, starred: isStarred })
}, [email.id, flags, isStarred, toggleStar])
const handleNotSpam = useCallback(() => {
if (flags.includes("spam")) {
updateFlags.mutate({
id: email.id,
flags: flags.filter((f) => f !== "spam"),
})
}
if (email.labels.includes("spam")) {
updateLabels.mutate({
id: email.id,
labels: email.labels.filter((l) => l !== "spam"),
})
}
}, [email.id, flags, email.labels, updateFlags, updateLabels])
const handlePrint = useCallback(() => {
openConversationPrint(legacyEmail)
}, [legacyEmail])
return (
<TooltipProvider delayDuration={400}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
{/* Spacer for floating nav buttons on xs */}
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden />
<div
className="h-[52px] shrink-0 bg-mail-surface sm:hidden"
aria-hidden
/>
<EmailViewSubjectHeader
email={email}
isSpamMessage={isSpamMessage}
onNotSpam={onNotSpam}
isSpamMessage={isSpam}
onNotSpam={isSpam ? handleNotSpam : undefined}
onPrint={handlePrint}
onNavigateToLabel={onNavigateToLabel}
showLabelChip={showLabelChip}
labelBgByText={labelBgByText}
@ -223,7 +297,7 @@ export function EmailView({
<CalendarInvitationPreview invitation={calendarInvitation} />
) : null}
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
{isSpam && <SpamWhyBanner onNotSpam={handleNotSpam} />}
{showRepliesCta ? (
<div className="border-b border-border px-6 py-3 max-sm:px-4">
@ -239,25 +313,23 @@ export function EmailView({
</div>
) : null}
{/* Conversation messages */}
{/* Previous messages in conversation */}
{hasConversation && conversation.map((msg) => {
{hasConversation &&
conversation.map((msg) => {
const isExpanded = expandedIds.has(msg.id)
if (isExpanded) {
return (
<div key={msg.id} className="border-b border-border">
<ExpandedMessage
sender={msg.sender}
senderEmail={msg.senderEmail}
sender={msg.from[0]?.name ?? ""}
senderEmail={msg.from[0]?.address ?? ""}
dateIso={msg.date}
body={msg.body}
body={msg.body_html ?? msg.body_text ?? ""}
isSpam={false}
isLast={false}
starred={false}
attachments={msg.attachments ?? []}
starred={msg.flags.includes("starred")}
onCollapse={() => toggleExpanded(msg.id)}
onPrintConversation={() => openConversationPrint(email)}
onPrintConversation={handlePrint}
/>
</div>
)
@ -273,18 +345,17 @@ export function EmailView({
)
})}
{/* Last / main message — always expanded */}
<ExpandedMessage
sender={mainSenderName}
senderEmail={mainSenderAddr}
dateIso={email.date}
body={email.body || `<p style="color:var(--muted-foreground);">${email.preview}</p>`}
isSpam={email.spam === true}
body={body}
isSpam={isSpam}
isLast={true}
starred={isStarred}
attachments={mainMessageAttachments}
onToggleStar={() => onToggleStar(email.id)}
onPrintConversation={() => openConversationPrint(email)}
onToggleStar={handleToggleStar}
onPrintConversation={handlePrint}
/>
{showReplyForwardBar ? (
@ -300,7 +371,10 @@ export function EmailView({
onClick={() => startThreadCompose("reply")}
className={MAIL_REPLY_BUTTON_CLASS}
>
<Reply className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
<Reply
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
strokeWidth={1.5}
/>
Répondre
</button>
<button
@ -308,7 +382,10 @@ export function EmailView({
onClick={() => startThreadCompose("replyAll")}
className={MAIL_REPLY_BUTTON_CLASS}
>
<ReplyAll className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
<ReplyAll
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
strokeWidth={1.5}
/>
Répondre à tous
</button>
<button
@ -316,14 +393,20 @@ export function EmailView({
onClick={() => startThreadCompose("forward")}
className={MAIL_REPLY_BUTTON_CLASS}
>
<Forward className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
<Forward
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
strokeWidth={1.5}
/>
Transférer
</button>
</div>
) : null}
{inlineCompose ? (
<div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4">
<div
ref={threadComposeAnchorRef}
className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4"
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
@ -336,13 +419,12 @@ export function EmailView({
<ComposeWindow
key={inlineCompose.id}
compose={inlineCompose}
threadSourceEmail={email}
threadSourceEmail={legacyEmail}
/>
</div>
</div>
</div>
) : null}
</div>
</div>
</TooltipProvider>

View File

@ -9,9 +9,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { Email } from "@/lib/email-data"
import type { ApiMessageSummary } from "@/lib/api/types"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { openConversationPrint } from "@/lib/print-conversation"
import { MailLabelPillStrip } from "@/components/gmail/mail-label-pills"
import {
MAIL_ICON_BTN,
@ -81,9 +80,10 @@ const LABEL_DISPLAY_NAMES: Record<string, string> = {
}
export interface EmailViewSubjectHeaderProps {
email: Email
email: ApiMessageSummary
isSpamMessage: boolean
onNotSpam?: () => void
onPrint?: () => void
onNavigateToLabel?: (label: string) => void
showLabelChip?: (label: string) => boolean
labelBgByText?: Map<string, string>
@ -98,6 +98,7 @@ export function EmailViewSubjectHeader({
email,
isSpamMessage,
onNotSpam,
onPrint,
onNavigateToLabel,
showLabelChip,
labelBgByText,
@ -120,7 +121,7 @@ export function EmailViewSubjectHeader({
{labelBgByText && onNavigateToLabel ? (
<MailLabelPillStrip
variant="header"
labels={email.labels ?? ["inbox"]}
labels={email.labels}
labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs}
@ -147,7 +148,7 @@ export function EmailViewSubjectHeader({
size="icon"
className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Imprimer"
onClick={() => openConversationPrint(email)}
onClick={() => onPrint?.()}
>
<Printer className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>

View File

@ -13,10 +13,8 @@ import {
senderInitial,
} from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text"
import type {
ConversationMessage,
EmailAttachment,
} from "@/lib/email-data"
import type { ApiMessageFull } from "@/lib/api/types"
import type { EmailAttachment } from "@/lib/email-data"
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
@ -30,10 +28,12 @@ export function CollapsedMessage({
message,
onClick,
}: {
message: ConversationMessage
message: ApiMessageFull
onClick: () => void
}) {
const name = cleanSenderName(message.sender)
const senderName = message.from[0]?.name ?? ""
const senderAddr = message.from[0]?.address ?? ""
const name = cleanSenderName(senderName)
const color = avatarColor(name)
return (
@ -57,7 +57,7 @@ export function CollapsedMessage({
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="flex min-w-0 items-center justify-between gap-2">
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
<ContactHoverCard displayName={senderName} email={senderAddr} className="min-w-0">
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard>
<div className="flex shrink-0 items-center gap-1">
@ -72,7 +72,7 @@ export function CollapsedMessage({
/>
</div>
</div>
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.preview}</p>
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.snippet}</p>
</div>
</div>
)

View File

@ -183,7 +183,7 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
variant="ghost"
size="icon-lg"
className="size-11 overflow-hidden rounded-full p-0"
aria-label={`Compte : ${activeAccount.email}`}
aria-label={`Compte : ${activeAccount?.email ?? ""}`}
aria-expanded={accountMenuOpen}
aria-haspopup="dialog"
onClick={() => {
@ -191,7 +191,7 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
setAppsMenuOpen(false)
}}
>
<AccountAvatar account={activeAccount} size="md" />
{activeAccount && <AccountAvatar account={activeAccount} size="md" />}
</Button>
<AccountSwitcherDropdown
open={accountMenuOpen}

View File

@ -19,15 +19,13 @@ import {
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { emails } from "@/lib/email-data"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import {
matchContacts,
matchEmails,
bestCompletion,
type SearchSuggestion,
type ContactSuggestion,
} from "@/lib/mail-search/search-engine"
import {
parseSearchParams,
@ -61,7 +59,6 @@ export function MailSearchBar({
[urlSearchParams]
)
const account = useActiveAccount()
const contacts = useContactsStore((s) => s.contacts)
const inputValue = useMailSearchStore((s) => s.inputValue)
const dropdownOpen = useMailSearchStore((s) => s.dropdownOpen)
@ -71,6 +68,8 @@ export function MailSearchBar({
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
const { data: searchContactResults } = useSearchContacts(inputValue)
const {
setInputValue,
setDropdownOpen,
@ -94,13 +93,23 @@ export function MailSearchBar({
}, [currentSearchParams?.q])
const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim()) return []
const contactHits = matchContacts(inputValue, contacts, 5)
const emailHits = matchEmails(inputValue, emails, 5)
const seen = new Set(contactHits.map((c) => c.email))
const unique = emailHits.filter((e) => !seen.has(e.email))
return [...contactHits, ...unique]
}, [inputValue, contacts])
if (!inputValue.trim() || !searchContactResults?.length) return []
return searchContactResults.slice(0, 8).map<ContactSuggestion>((c) => ({
kind: "contact",
contact: {
id: c.uid,
firstName: c.full_name.split(" ")[0] ?? "",
lastName: c.full_name.split(" ").slice(1).join(" "),
emails: c.email ? [{ value: c.email, label: "primary" }] : [],
phones: [],
createdAt: 0,
updatedAt: 0,
},
email: c.email ?? "",
displayName: c.full_name,
score: 1,
}))
}, [inputValue, searchContactResults])
const ghostText = useMemo(
() => bestCompletion(inputValue, suggestions),
@ -116,7 +125,7 @@ export function MailSearchBar({
chipAttachment,
chipLast7Days,
chipFromMe,
fromEmail: account.email,
fromEmail: account?.email ?? "",
})
if (!Object.keys(params).length) return
submitMailSearch(router, params, {
@ -126,7 +135,7 @@ export function MailSearchBar({
},
})
},
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router]
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account?.email, router]
)
const selectSuggestion = useCallback(
@ -135,7 +144,7 @@ export function MailSearchBar({
chipAttachment,
chipLast7Days,
chipFromMe,
fromEmail: account.email,
fromEmail: account?.email ?? "",
})
submitMailSearch(router, params, {
onAfter: () => {
@ -145,7 +154,7 @@ export function MailSearchBar({
},
})
},
[chipAttachment, chipLast7Days, chipFromMe, account.email, router]
[chipAttachment, chipLast7Days, chipFromMe, account?.email, router]
)
const handleKeyDown = useCallback(

View File

@ -21,14 +21,12 @@ import {
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
import { emails } from "@/lib/email-data"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
import { useActiveAccount } from "@/lib/stores/account-store"
import {
matchContacts,
matchEmails,
bestCompletion,
type SearchSuggestion,
type ContactSuggestion,
} from "@/lib/mail-search/search-engine"
import {
buildQuickSearchParams,
@ -53,13 +51,14 @@ interface MobileSearchOverlayProps {
export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: MobileSearchOverlayProps) {
const router = useRouter()
const account = useActiveAccount()
const contacts = useContactsStore((s) => s.contacts)
const inputValue = useMailSearchStore((s) => s.inputValue)
const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
const { data: searchContactResults } = useSearchContacts(inputValue)
const {
setInputValue,
setSelectedIndex,
@ -85,13 +84,23 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
}, [open, initialQuery, setInputValue, reset])
const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim()) return []
const contactHits = matchContacts(inputValue, contacts, 4)
const emailHits = matchEmails(inputValue, emails, 4)
const seen = new Set(contactHits.map((c) => c.email))
const unique = emailHits.filter((e) => !seen.has(e.email))
return [...contactHits, ...unique]
}, [inputValue, contacts])
if (!inputValue.trim() || !searchContactResults?.length) return []
return searchContactResults.slice(0, 6).map<ContactSuggestion>((c) => ({
kind: "contact",
contact: {
id: c.uid,
firstName: c.full_name.split(" ")[0] ?? "",
lastName: c.full_name.split(" ").slice(1).join(" "),
emails: c.email ? [{ value: c.email, label: "primary" }] : [],
phones: [],
createdAt: 0,
updatedAt: 0,
},
email: c.email ?? "",
displayName: c.full_name,
score: 1,
}))
}, [inputValue, searchContactResults])
const ghostText = useMemo(
() => bestCompletion(inputValue, suggestions),
@ -107,12 +116,12 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
chipAttachment,
chipLast7Days,
chipFromMe,
fromEmail: account.email,
fromEmail: account?.email ?? "",
})
if (!Object.keys(params).length) return
submitMailSearch(router, params, { onAfter: onClose })
},
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
)
const selectSuggestion = useCallback(
@ -121,11 +130,11 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
chipAttachment,
chipLast7Days,
chipFromMe,
fromEmail: account.email,
fromEmail: account?.email ?? "",
})
submitMailSearch(router, params, { onAfter: onClose })
},
[chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
[chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
)
const handleKeyDown = useCallback(

View File

@ -0,0 +1,94 @@
"use client"
import { useNetworkStatus } from "@/lib/api/use-network-status"
import { useEffect, useState, useCallback } from "react"
import { Icon } from "@iconify/react"
import { getPendingCount, flush } from "@/lib/api/offline-queue"
import { cn } from "@/lib/utils"
type SyncState = "idle" | "offline" | "syncing"
export function SyncStatusBar() {
const { isOnline } = useNetworkStatus()
const [syncState, setSyncState] = useState<SyncState>("idle")
const [pendingCount, setPendingCount] = useState(0)
const refreshCount = useCallback(async () => {
const count = await getPendingCount()
setPendingCount(count)
return count
}, [])
useEffect(() => {
if (!isOnline) {
setSyncState("offline")
return
}
let cancelled = false
const syncOnReconnect = async () => {
const count = await refreshCount()
if (cancelled) return
if (count > 0) {
setSyncState("syncing")
await flush()
if (cancelled) return
await refreshCount()
setSyncState("idle")
} else {
setSyncState("idle")
}
}
syncOnReconnect()
return () => {
cancelled = true
}
}, [isOnline, refreshCount])
useEffect(() => {
if (syncState !== "offline") return
const interval = setInterval(() => refreshCount(), 2000)
return () => clearInterval(interval)
}, [syncState, refreshCount])
const visible = syncState !== "idle"
return (
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-in-out",
visible ? "max-h-8 opacity-100" : "max-h-0 opacity-0"
)}
>
<div
className={cn(
"flex h-8 items-center justify-center gap-2 px-3 text-xs font-medium",
syncState === "offline" &&
"bg-amber-50 text-amber-800 dark:bg-amber-950/50 dark:text-amber-200",
syncState === "syncing" &&
"bg-blue-50 text-blue-800 dark:bg-blue-950/50 dark:text-blue-200"
)}
>
{syncState === "offline" && (
<>
<Icon icon="mdi:wifi-off" className="size-3.5" />
<span>Offline changes will sync when reconnected</span>
{pendingCount > 0 && (
<span className="rounded-full bg-amber-200/60 px-1.5 py-0.5 text-[10px] dark:bg-amber-800/40">
{pendingCount} pending
</span>
)}
</>
)}
{syncState === "syncing" && (
<>
<Icon icon="mdi:sync" className="size-3.5 animate-spin" />
<span>Syncing {pendingCount} changes</span>
</>
)}
</div>
</div>
)
}

158
lib/api/adapters.ts Normal file
View File

@ -0,0 +1,158 @@
import type { ApiContact } from './types'
import type { FullContact, ContactAddress } from '@/lib/contacts/types'
interface VCardFields {
fn?: string
emails: { value: string; type: string }[]
phones: { value: string; type: string }[]
org?: string
title?: string
bday?: string
note?: string
nickname?: string
addresses: { street?: string; city?: string; region?: string; postalCode?: string; country?: string; type: string }[]
}
function parseVCard(raw: string): VCardFields {
const fields: VCardFields = { emails: [], phones: [], addresses: [] }
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)
}
}
for (const line of lines) {
const colonIdx = line.indexOf(':')
if (colonIdx === -1) continue
const rawKey = line.slice(0, colonIdx)
const value = line.slice(colonIdx + 1).trim()
if (!value) continue
const keyParts = rawKey.split(';')
const propName = keyParts[0].toUpperCase()
const params = keyParts.slice(1).join(';').toUpperCase()
const typeMatch = params.match(/TYPE=([^;,]+)/i)
const type = typeMatch?.[1]?.toLowerCase() ?? 'other'
switch (propName) {
case 'FN':
fields.fn = value
break
case 'EMAIL':
fields.emails.push({ value, type })
break
case 'TEL':
fields.phones.push({ value, type })
break
case 'ORG':
fields.org = value.split(';')[0]
break
case 'TITLE':
fields.title = value
break
case 'BDAY': {
fields.bday = value
break
}
case 'NOTE':
fields.note = value
break
case 'NICKNAME':
fields.nickname = value
break
case 'ADR': {
const parts = value.split(';')
fields.addresses.push({
street: parts[2] || undefined,
city: parts[3] || undefined,
region: parts[4] || undefined,
postalCode: parts[5] || undefined,
country: parts[6] || undefined,
type,
})
break
}
}
}
return fields
}
function parseBday(raw: string): { day?: number; month?: number; year?: number } | undefined {
const m = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})$/)
if (m) {
return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) }
}
const partial = raw.match(/^--(\d{2})-?(\d{2})$/)
if (partial) {
return { month: Number(partial[1]), day: Number(partial[2]) }
}
return undefined
}
function splitName(fullName: string): { firstName: string; lastName: string } {
const parts = fullName.trim().split(/\s+/)
if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
export function apiContactToFullContact(api: ApiContact): FullContact {
const vcard = api.raw_vcard ? parseVCard(api.raw_vcard) : null
const { firstName, lastName } = splitName(vcard?.fn ?? api.full_name ?? '')
const emails: { value: string; label: string }[] = vcard?.emails.length
? vcard.emails.map((e) => ({ value: e.value, label: e.type }))
: api.email
? [{ value: api.email, label: 'personal' }]
: []
const phones: { value: string; label: string }[] = vcard?.phones.length
? vcard.phones.map((p) => ({ value: p.value, label: p.type }))
: api.phone
? [{ value: api.phone, label: 'mobile' }]
: []
const addresses: ContactAddress[] | undefined = vcard?.addresses.length
? vcard.addresses.map((a) => ({
street: a.street,
city: a.city,
region: a.region,
postalCode: a.postalCode,
country: a.country,
label: a.type,
}))
: undefined
const birthday = vcard?.bday ? parseBday(vcard.bday) : undefined
return {
id: api.uid,
firstName,
lastName,
emails,
phones,
addresses,
company: vcard?.org ?? api.org,
jobTitle: vcard?.title,
birthday,
notes: vcard?.note,
nicknames: vcard?.nickname ? [vcard.nickname] : undefined,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
export function fullContactToApiContact(contact: FullContact): Partial<ApiContact> {
return {
uid: contact.id,
full_name: `${contact.firstName} ${contact.lastName}`.trim(),
email: contact.emails[0]?.value,
phone: contact.phones[0]?.value,
org: contact.company,
}
}

42
lib/api/auth-store.ts Normal file
View File

@ -0,0 +1,42 @@
"use client"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
interface AuthState {
accessToken: string | null
refreshToken: string | null
expiresAt: number | null
login: (accessToken: string, refreshToken: string, expiresAt: number) => void
logout: () => void
isAuthenticated: () => boolean
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
expiresAt: null,
login: (accessToken, refreshToken, expiresAt) =>
set({ accessToken, refreshToken, expiresAt }),
logout: () =>
set({ accessToken: null, refreshToken: null, expiresAt: null }),
isAuthenticated: () => {
const { accessToken, expiresAt } = get()
if (!accessToken || !expiresAt) return false
return Date.now() < expiresAt
},
}),
{
name: "ultimail-auth",
storage: debouncedPersistJSONStorage,
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
expiresAt: state.expiresAt,
}),
}
)
)

151
lib/api/client.ts Normal file
View File

@ -0,0 +1,151 @@
import { useAuthStore } from "./auth-store"
import type { ApiError } from "./types"
export class OfflineError extends Error {
constructor() {
super("Device is offline")
this.name = "OfflineError"
}
}
export class ApiRequestError extends Error {
code: string
details?: unknown
status: number
constructor(status: number, code: string, message: string, details?: unknown) {
super(message)
this.name = "ApiRequestError"
this.status = status
this.code = code
this.details = details
}
}
const DEFAULT_TIMEOUT = 30_000
const DEFAULT_RETRIES = 3
const BASE_DELAY = 1000
class ApiClient {
constructor(private baseUrl: string) {}
private getHeaders(): HeadersInit {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const token = useAuthStore.getState().accessToken
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
return headers
}
private async request<T>(
method: string,
path: string,
opts?: {
body?: unknown
params?: Record<string, string | undefined>
timeout?: number
retries?: number
}
): Promise<T> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = new URL(path, this.baseUrl.startsWith("http") ? this.baseUrl : `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`)
if (opts?.params) {
for (const [key, value] of Object.entries(opts.params)) {
if (value !== undefined) {
url.searchParams.set(key, value)
}
}
}
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
let lastError: Error | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
const delay = BASE_DELAY * Math.pow(2, attempt - 1)
await new Promise((resolve) => setTimeout(resolve, delay))
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url.toString(), {
method,
headers: this.getHeaders(),
body: opts?.body ? JSON.stringify(opts.body) : undefined,
signal: controller.signal,
})
clearTimeout(timer)
if (!response.ok) {
let errorBody: ApiError | undefined
try {
errorBody = await response.json()
} catch {}
const err = new ApiRequestError(
response.status,
errorBody?.code ?? "UNKNOWN",
errorBody?.message ?? response.statusText,
errorBody?.details
)
if (response.status >= 400 && response.status < 500) {
throw err
}
lastError = err
continue
}
if (response.status === 204) {
return undefined as T
}
return await response.json()
} catch (err) {
clearTimeout(timer)
if (err instanceof ApiRequestError && err.status >= 400 && err.status < 500) {
throw err
}
lastError = err instanceof Error ? err : new Error(String(err))
if (err instanceof DOMException && err.name === "AbortError") {
lastError = new Error("Request timed out")
}
}
}
throw lastError ?? new Error("Request failed")
}
async get<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
return this.request<T>("GET", path, { params })
}
async post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("POST", path, { body })
}
async put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PUT", path, { body })
}
async delete(path: string): Promise<void> {
await this.request<void>("DELETE", path)
}
}
export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL ?? "/api/v1")

View File

@ -0,0 +1,218 @@
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import type { Recipient, ApiOutboxMessage, PaginatedResponse } from '../types'
export interface SendMessagePayload {
account_id: string
to: Recipient[]
cc?: Recipient[]
bcc?: Recipient[]
subject: string
body_html: string
in_reply_to?: string
idempotency_key: string
scheduled_at?: string
}
export type DraftPayload = Omit<SendMessagePayload, 'idempotency_key' | 'scheduled_at'>
export function useSendMessage() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: SendMessagePayload) => {
try {
return await apiClient.post<ApiOutboxMessage>('/mail/send', payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: payload.idempotency_key,
timestamp: Date.now(),
type: 'send_message',
payload,
retries: 0,
})
return null
}
throw err
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['messages', 'sent'] })
},
})
}
export function useCreateDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: DraftPayload) => {
try {
return await apiClient.post<ApiOutboxMessage>('/mail/drafts', payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `draft-create-${Date.now()}`,
timestamp: Date.now(),
type: 'create_draft',
payload,
retries: 0,
})
return null
}
throw err
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['messages', 'drafts'] })
},
})
}
export function useUpdateDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: DraftPayload & { id: string }) => {
try {
return await apiClient.put<ApiOutboxMessage>(`/mail/drafts/${id}`, payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `draft-update-${id}-${Date.now()}`,
timestamp: Date.now(),
type: 'update_draft',
payload: { draft_id: id, ...payload },
retries: 0,
})
return null
}
throw err
}
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['messages', 'drafts'] })
queryClient.invalidateQueries({ queryKey: ['message', variables.id] })
},
})
}
export function useDeleteDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
try {
await apiClient.delete(`/mail/drafts/${id}`)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `draft-delete-${id}-${Date.now()}`,
timestamp: Date.now(),
type: 'delete_draft',
payload: { draft_id: id },
retries: 0,
})
return
}
throw err
}
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ['messages', 'drafts'] })
const previous = queryClient.getQueriesData<PaginatedResponse<ApiOutboxMessage>>({
queryKey: ['messages', 'drafts'],
})
queryClient.setQueriesData<PaginatedResponse<ApiOutboxMessage>>(
{ queryKey: ['messages', 'drafts'] },
(old) => {
if (!old) return old
return { ...old, data: old.data.filter((m) => m.id !== id) }
}
)
return { previous }
},
onError: (_err, _vars, context) => {
context?.previous?.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['messages', 'drafts'] })
},
})
}
export function useScheduleSend() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: SendMessagePayload & { scheduled_at: string }) => {
try {
return await apiClient.post<ApiOutboxMessage>('/mail/send', payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: payload.idempotency_key,
timestamp: Date.now(),
type: 'schedule_send',
payload,
retries: 0,
})
return null
}
throw err
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
},
})
}
export function useRescheduleSend() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, scheduled_at }: { id: string; scheduled_at: string }) => {
return await apiClient.post<ApiOutboxMessage>(`/mail/outbox/${id}/reschedule`, {
scheduled_at,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
},
})
}
export function useCancelScheduled() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
return await apiClient.post<ApiOutboxMessage>(`/mail/outbox/${id}/cancel`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
},
})
}
export function useSendNow() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
return await apiClient.post<ApiOutboxMessage>(`/mail/outbox/${id}/send-now`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
queryClient.invalidateQueries({ queryKey: ['messages', 'sent'] })
},
})
}

View File

@ -0,0 +1,114 @@
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import type { ApiContact } from '../types'
export function useCreateContact() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) =>
apiClient.post<ApiContact>(`/contacts/books/${vars.bookId}`, vars.contact),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
},
onError: (err, vars) => {
if (err instanceof OfflineError) {
enqueue({
id: crypto.randomUUID(),
timestamp: Date.now(),
type: 'create_contact',
payload: { bookId: vars.bookId, ...vars.contact },
retries: 0,
})
}
},
})
}
export function useUpdateContact() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: {
path: string
contact: Partial<ApiContact>
etag?: string
}) => apiClient.put<ApiContact>(`/contacts/${vars.path}`, {
...vars.contact,
etag: vars.etag,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
},
onError: (err, vars) => {
if (err instanceof OfflineError) {
enqueue({
id: crypto.randomUUID(),
timestamp: Date.now(),
type: 'update_contact',
payload: { path: vars.path, ...vars.contact },
retries: 0,
})
}
},
})
}
export function useDeleteContact() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: { path: string; bookId?: string }) =>
apiClient.delete(`/contacts/${vars.path}`),
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey: ['contacts'] })
const queries = queryClient.getQueriesData<ApiContact[]>({ queryKey: ['contacts'] })
const snapshots: [readonly unknown[], ApiContact[] | undefined][] = []
for (const [key, data] of queries) {
if (data) {
snapshots.push([key, data])
queryClient.setQueryData(
key,
data.filter((c) => c.path !== vars.path && c.uid !== vars.path)
)
}
}
return { snapshots }
},
onError: (err, vars, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data)
}
}
if (err instanceof OfflineError) {
enqueue({
id: crypto.randomUUID(),
timestamp: Date.now(),
type: 'delete_contact',
payload: { path: vars.path, uid: vars.path },
retries: 0,
})
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
},
})
}
export function useMergeDuplicates() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: { bookId: string }) =>
apiClient.post(`/contacts/books/${vars.bookId}/merge-duplicates`),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
},
})
}

View File

@ -0,0 +1,74 @@
'use client'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import type { ApiContact, ApiContactSyncResponse } from '../types'
export function useContacts(bookId?: string) {
return useQuery({
queryKey: ['contacts', bookId],
queryFn: () => apiClient.get<ApiContact[]>(`/contacts/books/${bookId}`),
enabled: !!bookId,
staleTime: 5 * 60_000,
})
}
export function useContactBooks() {
return useQuery({
queryKey: ['contact-books'],
queryFn: () => apiClient.get<{ id: string; name: string }[]>('/contacts/books'),
staleTime: 10 * 60_000,
})
}
export function useSyncContacts(bookId?: string, syncToken?: string) {
return useQuery({
queryKey: ['contacts-sync', bookId, syncToken],
queryFn: () =>
apiClient.get<ApiContactSyncResponse>(`/contacts/books/${bookId}/sync`, {
sync_token: syncToken,
}),
enabled: !!bookId && !!syncToken,
})
}
export function useSearchContacts(query: string) {
const queryClient = useQueryClient()
return useQuery({
queryKey: ['contacts-search', query],
queryFn: async () => {
try {
return await apiClient.get<ApiContact[]>('/contacts/search', { q: query })
} catch (err) {
if (err instanceof OfflineError) {
const cached = queryClient.getQueriesData<ApiContact[]>({
queryKey: ['contacts'],
})
const allContacts: ApiContact[] = []
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)
)
}
throw err
}
},
enabled: query.length >= 2,
staleTime: 30_000,
})
}
export function useContactInteractions(email?: string) {
return useQuery({
queryKey: ['contact-interactions', email],
queryFn: () => apiClient.get<unknown>('/contacts/interactions', { email }),
enabled: !!email,
})
}

View File

@ -0,0 +1,80 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../client'
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
export function useFolders(accountId?: string) {
return useQuery({
queryKey: ['folders', accountId],
queryFn: () =>
apiClient.get<ApiFolder[]>('/mail/folders', { account_id: accountId }),
enabled: !!accountId,
staleTime: 5 * 60_000,
})
}
export function useLabels() {
return useQuery({
queryKey: ['labels'],
queryFn: () => apiClient.get<ApiLabel[]>('/mail/labels'),
staleTime: 5 * 60_000,
})
}
export function useCreateLabel() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { name: string; color: string }) =>
apiClient.post<ApiLabel>('/mail/labels', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
},
})
}
export function useUpdateLabel() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; color?: string }) =>
apiClient.put<ApiLabel>(`/mail/labels/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
},
})
}
export function useDeleteLabel() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiClient.delete(`/mail/labels/${id}`),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['labels'] })
const previous = queryClient.getQueryData<ApiLabel[]>(['labels'])
queryClient.setQueryData<ApiLabel[]>(['labels'], (old) =>
old?.filter((l) => l.id !== id),
)
return { previous }
},
onError: (_err, _id, context) => {
if (context?.previous) {
queryClient.setQueryData(['labels'], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
},
})
}
export function useIdentities(accountId?: string) {
return useQuery({
queryKey: ['identities', accountId],
queryFn: () =>
apiClient.get<ApiIdentity[]>(`/mail/accounts/${accountId}/identities`),
enabled: !!accountId,
})
}

View File

@ -0,0 +1,200 @@
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import type { PaginatedResponse, ApiMessageSummary, ApiMessageFull } from '../types'
export function useUpdateFlags() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, flags }: { id: string; flags: string[] }) => {
try {
return await apiClient.put<ApiMessageFull>(`/mail/messages/${id}/flags`, { flags })
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `flags-${id}-${Date.now()}`,
timestamp: Date.now(),
type: 'update_flags',
payload: { message_id: id, flags },
retries: 0,
})
return undefined
}
throw err
}
},
onMutate: async ({ id, flags }) => {
await queryClient.cancelQueries({ queryKey: ['messages'] })
await queryClient.cancelQueries({ queryKey: ['message', id] })
const previousMessages = queryClient.getQueriesData<PaginatedResponse<ApiMessageSummary>>({
queryKey: ['messages'],
})
queryClient.setQueriesData<PaginatedResponse<ApiMessageSummary>>(
{ queryKey: ['messages'] },
(old) => {
if (!old) return old
return { ...old, data: old.data.map((m) => (m.id === id ? { ...m, flags } : m)) }
}
)
queryClient.setQueryData<ApiMessageFull>(['message', id], (old) =>
old ? { ...old, flags } : old
)
return { previousMessages }
},
onError: (_err, _vars, context) => {
context?.previousMessages?.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['messages'] })
},
})
}
export function useUpdateLabels() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, labels }: { id: string; labels: string[] }) => {
try {
return await apiClient.put<ApiMessageFull>(`/mail/messages/${id}/labels`, { labels })
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `labels-${id}-${Date.now()}`,
timestamp: Date.now(),
type: 'update_labels',
payload: { message_id: id, labels },
retries: 0,
})
return undefined
}
throw err
}
},
onMutate: async ({ id, labels }) => {
await queryClient.cancelQueries({ queryKey: ['messages'] })
await queryClient.cancelQueries({ queryKey: ['message', id] })
const previousMessages = queryClient.getQueriesData<PaginatedResponse<ApiMessageSummary>>({
queryKey: ['messages'],
})
queryClient.setQueriesData<PaginatedResponse<ApiMessageSummary>>(
{ queryKey: ['messages'] },
(old) => {
if (!old) return old
return { ...old, data: old.data.map((m) => (m.id === id ? { ...m, labels } : m)) }
}
)
queryClient.setQueryData<ApiMessageFull>(['message', id], (old) =>
old ? { ...old, labels } : old
)
return { previousMessages }
},
onError: (_err, _vars, context) => {
context?.previousMessages?.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['messages'] })
},
})
}
export function useDeleteMessage() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
try {
await apiClient.delete(`/mail/messages/${id}`)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `delete-${id}-${Date.now()}`,
timestamp: Date.now(),
type: 'delete_message',
payload: { message_id: id },
retries: 0,
})
return
}
throw err
}
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ['messages'] })
const previousMessages = queryClient.getQueriesData<PaginatedResponse<ApiMessageSummary>>({
queryKey: ['messages'],
})
queryClient.setQueriesData<PaginatedResponse<ApiMessageSummary>>(
{ queryKey: ['messages'] },
(old) => {
if (!old) return old
return { ...old, data: old.data.filter((m) => m.id !== id) }
}
)
queryClient.removeQueries({ queryKey: ['message', id] })
return { previousMessages }
},
onError: (_err, _vars, context) => {
context?.previousMessages?.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['messages'] })
},
})
}
export function useToggleStar() {
const updateFlags = useUpdateFlags()
return useMutation({
mutationFn: async ({ id, flags, starred }: { id: string; flags: string[]; starred: boolean }) => {
const newFlags = starred ? flags.filter((f) => f !== 'starred') : [...flags, 'starred']
return updateFlags.mutateAsync({ id, flags: newFlags })
},
})
}
export function useToggleImportant() {
const updateFlags = useUpdateFlags()
return useMutation({
mutationFn: async ({
id,
flags,
important,
}: {
id: string
flags: string[]
important: boolean
}) => {
const newFlags = important ? flags.filter((f) => f !== 'important') : [...flags, 'important']
return updateFlags.mutateAsync({ id, flags: newFlags })
},
})
}
export function useMarkRead() {
const updateFlags = useUpdateFlags()
return useMutation({
mutationFn: async ({ id, flags }: { id: string; flags: string[] }) => {
if (flags.includes('read')) return
return updateFlags.mutateAsync({ id, flags: [...flags, 'read'] })
},
})
}

View File

@ -0,0 +1,112 @@
'use client'
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import type {
PaginatedResponse,
ApiMessageSummary,
ApiMessageFull,
ApiMailAccount,
MessageSearchFilter,
} from '../types'
export function useMessages(folder: string, accountId?: string, page?: number) {
return useQuery({
queryKey: ['messages', folder, accountId, page],
queryFn: () =>
apiClient.get<PaginatedResponse<ApiMessageSummary>>('/mail/messages', {
folder,
account_id: accountId,
page: String(page ?? 1),
page_size: '50',
}),
placeholderData: keepPreviousData,
staleTime: 60_000,
})
}
export function useMessage(messageId: string | null) {
return useQuery({
queryKey: ['message', messageId],
queryFn: () => apiClient.get<ApiMessageFull>(`/mail/messages/${messageId}`),
enabled: !!messageId,
})
}
export function useThread(threadId: string | null) {
return useQuery({
queryKey: ['thread', threadId],
queryFn: () => apiClient.get<ApiMessageFull[]>(`/mail/threads/${threadId}`),
enabled: !!threadId,
})
}
export function useMailAccounts() {
return useQuery({
queryKey: ['accounts'],
queryFn: () => apiClient.get<ApiMailAccount[]>('/mail/accounts'),
staleTime: 5 * 60_000,
})
}
export function useMailSearch(filter: MessageSearchFilter | null) {
const queryClient = useQueryClient()
return useQuery({
queryKey: ['mail-search', filter],
queryFn: async () => {
const params: Record<string, string | undefined> = {}
if (filter) {
if (filter.q) params.q = filter.q
if (filter.from) params.from = filter.from
if (filter.label) params.label = filter.label
if (filter.account_id) params.account_id = filter.account_id
if (filter.date_from) params.date_from = filter.date_from
if (filter.date_to) params.date_to = filter.date_to
if (filter.has_attachment !== undefined) params.has_attachment = String(filter.has_attachment)
}
try {
return await apiClient.get<PaginatedResponse<ApiMessageSummary>>('/mail/search', params)
} catch (err) {
if (err instanceof OfflineError) {
const cached = queryClient.getQueriesData<PaginatedResponse<ApiMessageSummary>>({
queryKey: ['messages'],
})
const allMessages: ApiMessageSummary[] = []
for (const [, data] of cached) {
if (data?.data) allMessages.push(...data.data)
}
const q = filter?.q?.toLowerCase()
const filtered = allMessages.filter((m) => {
if (q) {
const matchSubject = m.subject.toLowerCase().includes(q)
const matchSnippet = m.snippet.toLowerCase().includes(q)
const matchFrom = m.from.some(
(r) => r.address.toLowerCase().includes(q) || r.name.toLowerCase().includes(q)
)
if (!matchSubject && !matchSnippet && !matchFrom) return false
}
if (filter?.from) {
const fromMatch = m.from.some(
(r) =>
r.address.toLowerCase().includes(filter.from!.toLowerCase()) ||
r.name.toLowerCase().includes(filter.from!.toLowerCase())
)
if (!fromMatch) return false
}
if (filter?.label) {
if (!m.labels.includes(filter.label)) return false
}
return true
})
return { data: filtered, pagination: { page: 1, page_size: filtered.length } }
}
throw err
}
},
enabled: !!(filter?.q || filter?.from || filter?.label),
})
}

106
lib/api/offline-queue.ts Normal file
View File

@ -0,0 +1,106 @@
import { openDB, type IDBPDatabase } from "idb"
import { apiClient } from "./client"
export interface PendingMutation {
id: string
timestamp: number
type:
| "send_message"
| "update_flags"
| "update_labels"
| "delete_message"
| "create_draft"
| "update_draft"
| "schedule_send"
| "create_contact"
| "update_contact"
| "delete_draft"
| "delete_contact"
payload: unknown
retries: number
}
const DB_NAME = "ultimail-offline-queue"
const STORE_NAME = "mutations"
let dbPromise: Promise<IDBPDatabase> | null = null
function getDb() {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE_NAME, { keyPath: "id" })
},
})
}
return dbPromise
}
export async function enqueue(mutation: PendingMutation): Promise<void> {
const db = await getDb()
await db.put(STORE_NAME, mutation)
}
export async function getAll(): Promise<PendingMutation[]> {
const db = await getDb()
return db.getAll(STORE_NAME)
}
export async function remove(id: string): Promise<void> {
const db = await getDb()
await db.delete(STORE_NAME, id)
}
export async function getPendingCount(): Promise<number> {
const db = await getDb()
return db.count(STORE_NAME)
}
const MUTATION_ENDPOINTS: Record<PendingMutation["type"], { method: "post" | "put" | "delete"; path: (p: any) => string }> = {
send_message: { method: "post", path: () => "/outbox" },
update_flags: { method: "put", path: (p) => `/messages/${p.message_id}/flags` },
update_labels: { method: "put", path: (p) => `/messages/${p.message_id}/labels` },
delete_message: { method: "delete", path: (p) => `/messages/${p.message_id}` },
create_draft: { method: "post", path: () => "/drafts" },
update_draft: { method: "put", path: (p) => `/drafts/${p.draft_id}` },
schedule_send: { method: "post", path: () => "/outbox/schedule" },
delete_draft: { method: "delete", path: (p) => `/drafts/${p.draft_id}` },
create_contact: { method: "post", path: () => "/contacts" },
update_contact: { method: "put", path: (p) => `/contacts/${p.uid}` },
delete_contact: { method: "delete", path: (p) => `/contacts/${p.uid}` },
}
export async function flush(): Promise<void> {
const mutations = await getAll()
const sorted = mutations.sort((a, b) => a.timestamp - b.timestamp)
for (const mutation of sorted) {
try {
const endpoint = MUTATION_ENDPOINTS[mutation.type]
const path = endpoint.path(mutation.payload)
switch (endpoint.method) {
case "post":
await apiClient.post(path, mutation.payload)
break
case "put":
await apiClient.put(path, mutation.payload)
break
case "delete":
await apiClient.delete(path)
break
}
await remove(mutation.id)
} catch {
const db = await getDb()
await db.put(STORE_NAME, { ...mutation, retries: mutation.retries + 1 })
}
}
}
if (typeof window !== "undefined") {
window.addEventListener("online", () => {
flush()
})
}

View File

@ -0,0 +1,67 @@
"use client"
import { useState } from "react"
import { QueryClient } from "@tanstack/react-query"
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"
import { openDB, type IDBPDatabase } from "idb"
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"
const DB_NAME = "ultimail-query-cache"
const STORE_NAME = "query-cache"
let dbPromise: Promise<IDBPDatabase> | null = null
function getDb() {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE_NAME)
},
})
}
return dbPromise
}
const idbPersister: Persister = {
persistClient: async (client: PersistedClient) => {
const db = await getDb()
await db.put(STORE_NAME, client, "cache")
},
restoreClient: async (): Promise<PersistedClient | undefined> => {
const db = await getDb()
return db.get(STORE_NAME, "cache")
},
removeClient: async () => {
const db = await getDb()
await db.delete(STORE_NAME, "cache")
},
}
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * 5,
networkMode: "offlineFirst",
retry: 3,
},
mutations: {
networkMode: "offlineFirst",
},
},
})
}
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(makeQueryClient)
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: idbPersister }}
>
{children}
</PersistQueryClientProvider>
)
}

127
lib/api/types.ts Normal file
View File

@ -0,0 +1,127 @@
export interface PaginatedResponse<T> {
data: T[]
pagination: { page: number; page_size: number; total?: number }
}
export interface Recipient {
name: string
address: string
}
export interface ApiMessageSummary {
id: string
message_id: string
thread_id?: string
account_id: string
subject: string
from: Recipient[]
to: Recipient[]
date: string
snippet: string
flags: string[]
labels: string[]
has_attachments: boolean
}
export interface ApiMessageFull extends ApiMessageSummary {
cc?: Recipient[]
body_text?: string
body_html?: string
in_reply_to?: string
references?: string
}
export interface ApiMailAccount {
id: string
name: string
email: string
provider: string
imap_host: string
smtp_host: string
is_active: boolean
last_sync_at?: string
created_at: string
}
export interface ApiOutboxMessage {
id: string
account_id: string
status: 'draft' | 'queued' | 'scheduled' | 'sending' | 'sent' | 'failed' | 'cancelled'
to: Recipient[]
cc?: Recipient[]
bcc?: Recipient[]
subject: string
body_html: string
scheduled_at?: string
created_at: string
}
export interface MessageSearchFilter {
q?: string
from?: string
label?: string
account_id?: string
date_from?: string
date_to?: string
has_attachment?: boolean
}
export interface ApiContact {
uid: string
full_name: string
email?: string
phone?: string
org?: string
path?: string
etag?: string
raw_vcard?: string
}
export interface ApiContactSyncResponse {
sync_token: string
contacts: ApiContact[]
deleted: string[]
}
export interface ApiFolder {
id: string
account_id: string
name: string
remote_name: string
folder_type: 'inbox' | 'sent' | 'drafts' | 'trash' | 'archive' | 'spam' | 'custom'
message_count: number
unread_count: number
}
export interface ApiLabel {
id: string
name: string
color: string
created_at: string
}
export interface ApiIdentity {
id: string
account_id: string
email: string
name: string
is_default: boolean
signature_html?: string
reply_to_addrs?: string[]
}
export type WsEventType = 'mail.created' | 'mail.updated' | 'mail.deleted' | 'outbox.updated' | 'contact.updated'
export interface WsEvent {
type: WsEventType
seq: number
account_id?: string
message_id?: string
data?: unknown
}
export interface ApiError {
code: string
message: string
details?: unknown
}

View File

@ -0,0 +1,25 @@
"use client"
import { useSyncExternalStore } from "react"
function subscribe(callback: () => void) {
window.addEventListener("online", callback)
window.addEventListener("offline", callback)
return () => {
window.removeEventListener("online", callback)
window.removeEventListener("offline", callback)
}
}
function getSnapshot() {
return navigator.onLine
}
function getServerSnapshot() {
return true
}
export function useNetworkStatus() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
return { isOnline }
}

129
lib/api/ws.ts Normal file
View File

@ -0,0 +1,129 @@
"use client"
import { useEffect } from "react"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import type { WsEvent } from "./types"
import { useAuthStore } from "./auth-store"
class WebSocketManager {
private ws: WebSocket | null = null
private reconnectAttempts = 0
private maxReconnectDelay = 30_000
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private lastSeq = 0
private queryClient: QueryClient | null = null
init(queryClient: QueryClient) {
this.queryClient = queryClient
this.loadLastSeq()
}
connect(token: string) {
if (this.ws?.readyState === WebSocket.OPEN) return
const baseUrl =
process.env.NEXT_PUBLIC_WS_URL ??
(typeof window !== "undefined"
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
: "")
const url = `${baseUrl}?token=${encodeURIComponent(token)}&since=${this.lastSeq}`
this.ws = new WebSocket(url)
this.ws.onopen = () => {
this.reconnectAttempts = 0
}
this.ws.onmessage = (event) => this.handleMessage(event)
this.ws.onclose = () => this.scheduleReconnect(token)
this.ws.onerror = () => {}
}
disconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.ws?.close()
this.ws = null
}
private handleMessage(event: MessageEvent) {
try {
const evt: WsEvent = JSON.parse(event.data)
if (evt.seq) {
this.lastSeq = evt.seq
this.saveLastSeq()
}
this.handleEvent(evt)
} catch {}
}
private handleEvent(evt: WsEvent) {
if (!this.queryClient) return
switch (evt.type) {
case "mail.created":
this.queryClient.invalidateQueries({ queryKey: ["messages"] })
break
case "mail.updated":
this.queryClient.invalidateQueries({ queryKey: ["messages"] })
if (evt.message_id) {
this.queryClient.invalidateQueries({
queryKey: ["message", evt.message_id],
})
}
break
case "mail.deleted":
this.queryClient.invalidateQueries({ queryKey: ["messages"] })
if (evt.message_id) {
this.queryClient.removeQueries({
queryKey: ["message", evt.message_id],
})
}
break
case "outbox.updated":
this.queryClient.invalidateQueries({ queryKey: ["outbox"] })
break
case "contact.updated":
this.queryClient.invalidateQueries({ queryKey: ["contacts"] })
break
}
}
private scheduleReconnect(token: string) {
const delay = Math.min(
1000 * 2 ** this.reconnectAttempts,
this.maxReconnectDelay
)
this.reconnectAttempts++
this.reconnectTimer = setTimeout(() => this.connect(token), delay)
}
private loadLastSeq() {
if (typeof window === "undefined") return
const stored = localStorage.getItem("ultimail-ws-seq")
if (stored) this.lastSeq = parseInt(stored, 10) || 0
}
private saveLastSeq() {
if (typeof window === "undefined") return
localStorage.setItem("ultimail-ws-seq", String(this.lastSeq))
}
}
export const wsManager = new WebSocketManager()
export function useWebSocket() {
const queryClient = useQueryClient()
const accessToken = useAuthStore((s) => s.accessToken)
useEffect(() => {
wsManager.init(queryClient)
}, [queryClient])
useEffect(() => {
if (accessToken) {
wsManager.connect(accessToken)
} else {
wsManager.disconnect()
}
return () => wsManager.disconnect()
}, [accessToken])
}

View File

@ -3,18 +3,10 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import {
findDuplicatePairs,
mergePairKey,
normalizePhone,
type DuplicateMatchReason,
} from "./duplicate-detection"
import { MOCK_FULL_CONTACTS } from "./mock-data"
import type { FullContact } from "./types"
type ContactsView = "list" | "view" | "create" | "edit"
/** Prefill for "Nouveau contact" opened from hover card / elsewhere. */
export type ContactCreateDraft = {
firstName?: string
lastName?: string
@ -27,20 +19,7 @@ export interface DeletedContact {
reason: string
}
export interface MergeSuggestion {
contactA: FullContact
contactB: FullContact
reason: DuplicateMatchReason
}
export interface CoordinateSuggestion {
contact: FullContact
suggestedField: string
suggestedValue: string
}
interface ContactsState {
contacts: FullContact[]
deletedContacts: DeletedContact[]
ignoredMergePairs: string[]
panelOpen: boolean
@ -62,61 +41,17 @@ interface ContactsActions {
showContactsList: () => void
setSearchQuery: (q: string) => void
setSearchMode: (active: boolean) => void
addContact: (
contact: Omit<FullContact, "id" | "createdAt" | "updatedAt">
) => string
addContacts: (
contacts: Omit<FullContact, "id" | "createdAt" | "updatedAt">[]
) => number
updateContact: (id: string, patch: Partial<FullContact>) => void
deleteContact: (id: string) => void
softDeleteContact: (id: string, reason?: string) => void
softDeleteContact: (contact: FullContact, reason?: string) => void
restoreContact: (id: string) => void
emptyTrash: () => void
mergeContacts: (keepId: string, mergeId: string) => void
ignoreMergePair: (idA: string, idB: string) => void
getMergeSuggestions: () => MergeSuggestion[]
getCoordinateSuggestions: () => CoordinateSuggestion[]
}
export type ContactsStore = ContactsState & ContactsActions
function computeCoordinateSuggestions(contacts: FullContact[]): CoordinateSuggestion[] {
const suggestions: CoordinateSuggestion[] = []
const emailDomains = new Map<string, { company?: string; jobTitle?: string }>()
for (const c of contacts) {
if (c.company) {
for (const e of c.emails) {
const domain = e.value.split("@")[1]?.toLowerCase()
if (domain && !domain.includes("gmail") && !domain.includes("outlook") && !domain.includes("yahoo") && !domain.includes("proton")) {
emailDomains.set(domain, { company: c.company, jobTitle: c.jobTitle })
}
}
}
}
for (const c of contacts) {
if (c.company) continue
for (const e of c.emails) {
const domain = e.value.split("@")[1]?.toLowerCase()
if (domain && emailDomains.has(domain)) {
const info = emailDomains.get(domain)!
if (info.company) {
suggestions.push({ contact: c, suggestedField: "company", suggestedValue: info.company })
break
}
}
}
if (suggestions.length >= 20) break
}
return suggestions
}
export const useContactsStore = create<ContactsStore>()(
persist(
(set, get) => ({
contacts: MOCK_FULL_CONTACTS,
(set) => ({
deletedContacts: [],
ignoredMergePairs: [],
panelOpen: false,
@ -191,148 +126,38 @@ export const useContactsStore = create<ContactsStore>()(
setSearchMode: (searchMode) =>
set(searchMode ? { searchMode } : { searchMode, searchQuery: "" }),
addContact: (contact) => {
const id = `contact-${crypto.randomUUID()}`
const now = Date.now()
const full: FullContact = { ...contact, id, createdAt: now, updatedAt: now }
set((s) => ({ contacts: [...s.contacts, full] }))
return id
},
addContacts: (incoming) => {
if (incoming.length === 0) return 0
const now = Date.now()
const added = incoming.map((contact) => ({
...contact,
id: `contact-${crypto.randomUUID()}`,
createdAt: now,
updatedAt: now,
}))
set((s) => ({ contacts: [...s.contacts, ...added] }))
return added.length
},
updateContact: (id, patch) =>
softDeleteContact: (contact, reason = "Supprimé manuellement") =>
set((s) => ({
contacts: s.contacts.map((c) =>
c.id === id ? { ...c, ...patch, updatedAt: Date.now() } : c
),
})),
deleteContact: (id) =>
set((s) => ({
contacts: s.contacts.filter((c) => c.id !== id),
activeContactId: s.activeContactId === id ? null : s.activeContactId,
view: s.activeContactId === id ? "list" : s.view,
})),
softDeleteContact: (id, reason = "Supprimé manuellement") =>
set((s) => {
const contact = s.contacts.find((c) => c.id === id)
if (!contact) return s
return {
contacts: s.contacts.filter((c) => c.id !== id),
deletedContacts: [
...s.deletedContacts,
{ contact, deletedAt: Date.now(), reason },
],
activeContactId: s.activeContactId === id ? null : s.activeContactId,
view: s.activeContactId === id ? "list" : s.view,
}
}),
activeContactId: s.activeContactId === contact.id ? null : s.activeContactId,
view: s.activeContactId === contact.id ? "list" : s.view,
})),
restoreContact: (id) =>
set((s) => {
const entry = s.deletedContacts.find((d) => d.contact.id === id)
if (!entry) return s
return {
contacts: [...s.contacts, entry.contact],
deletedContacts: s.deletedContacts.filter((d) => d.contact.id !== id),
}
}),
emptyTrash: () => set({ deletedContacts: [] }),
mergeContacts: (keepId, mergeId) =>
set((s) => {
const keep = s.contacts.find((c) => c.id === keepId)
const merge = s.contacts.find((c) => c.id === mergeId)
if (!keep || !merge) return s
const mergedEmails = [...keep.emails]
for (const e of merge.emails) {
if (!mergedEmails.some((me) => me.value.toLowerCase() === e.value.toLowerCase())) {
mergedEmails.push(e)
}
}
const mergedPhones = [...keep.phones]
for (const p of merge.phones) {
const norm = normalizePhone(p.value)
if (
!mergedPhones.some(
(mp) => normalizePhone(mp.value) === norm && norm.length > 0
)
) {
mergedPhones.push(p)
}
}
const mergedLabels = [
...new Set([...(keep.labels ?? []), ...(merge.labels ?? [])]),
]
const merged: FullContact = {
...keep,
firstName: keep.firstName || merge.firstName,
lastName: keep.lastName || merge.lastName,
emails: mergedEmails,
phones: mergedPhones,
labels: mergedLabels.length ? mergedLabels : undefined,
company: keep.company || merge.company,
jobTitle: keep.jobTitle || merge.jobTitle,
department: keep.department || merge.department,
birthday: keep.birthday || merge.birthday,
avatarUrl: keep.avatarUrl || merge.avatarUrl,
notes: [keep.notes, merge.notes].filter(Boolean).join("\n") || undefined,
updatedAt: Date.now(),
}
const pairKey = mergePairKey(keepId, mergeId)
return {
contacts: s.contacts
.filter((c) => c.id !== mergeId)
.map((c) => (c.id === keepId ? merged : c)),
ignoredMergePairs: s.ignoredMergePairs.includes(pairKey)
? s.ignoredMergePairs
: [...s.ignoredMergePairs, pairKey],
}
}),
ignoreMergePair: (idA, idB) =>
set((s) => {
const key = mergePairKey(idA, idB)
const key = [idA, idB].sort().join("::")
if (s.ignoredMergePairs.includes(key)) return s
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
}),
getMergeSuggestions: () => {
const s = get()
const ignored = new Set(s.ignoredMergePairs)
return findDuplicatePairs(s.contacts, ignored).map((p) => ({
contactA: p.contactA,
contactB: p.contactB,
reason: p.reason,
}))
},
getCoordinateSuggestions: () => computeCoordinateSuggestions(get().contacts),
}),
{
name: "contacts-store",
storage: debouncedPersistJSONStorage,
partialize: (state) => ({
contacts: state.contacts,
deletedContacts: state.deletedContacts,
ignoredMergePairs: state.ignoredMergePairs,
}),

View File

@ -1,5 +1,4 @@
export { type FullContact, fullContactDisplayName, toComposeContact } from "./types"
export { MOCK_FULL_CONTACTS } from "./mock-data"
export { type FullContact, type MergeSuggestion, type CoordinateSuggestion, fullContactDisplayName, toComposeContact } from "./types"
export { useContactsStore, type ContactsStore } from "./contacts-store"
export { searchContacts } from "./fuzzy-search"
export {
@ -32,6 +31,5 @@ export {
export type {
ContactCreateDraft,
DeletedContact,
MergeSuggestion,
CoordinateSuggestion,
} from "./contacts-store"
export type { DuplicateMatchReason } from "./duplicate-detection"

View File

@ -35,6 +35,18 @@ export interface FullContact {
updatedAt: number
}
export interface MergeSuggestion {
contactA: FullContact
contactB: FullContact
reason: import("./duplicate-detection").DuplicateMatchReason
}
export interface CoordinateSuggestion {
contact: FullContact
suggestedField: string
suggestedValue: string
}
export function fullContactDisplayName(c: FullContact): string {
return `${c.firstName} ${c.lastName}`.trim()
}

View File

@ -0,0 +1,10 @@
'use client'
import { useContacts } from '@/lib/api/hooks/use-contact-queries'
import { apiContactToFullContact } from '@/lib/api/adapters'
export function useContactsList(bookId?: string) {
const { data: apiContacts, ...rest } = useContacts(bookId)
const contacts = apiContacts?.map(apiContactToFullContact) ?? []
return { contacts, ...rest }
}

View File

@ -1,5 +1,9 @@
import type { Email } from "@/lib/email-data"
import type { LabelEditState } from "@/lib/stores/mail-store"
export type LabelEditState = {
additions: Record<string, string[]>
removals: Record<string, string[]>
}
export function effectiveLabels(
email: Email | undefined,

View File

@ -1,6 +1,6 @@
import type { Email } from "@/lib/email-data"
import { effectiveLabels } from "@/lib/label-edits"
import type { LabelEditState } from "@/lib/stores/mail-store"
import type { LabelEditState } from "@/lib/label-edits"
/** Libellés système exclus du picker « Ajouter le libellé ». */
export const LABEL_PICKER_EXCLUDE = new Set([

View File

@ -1,11 +1,11 @@
import type { Email } from "@/lib/email-data"
import { applyLabelEditsToEmails, mergeEmailNotSpam } from "@/lib/label-edits"
import type { LabelEditState } from "@/lib/label-edits"
import {
emailMatchesFolder,
type MailFolderFilterCtx,
type MailNavFolderMaps,
} from "@/lib/mail-folder-filter"
import type { LabelEditState } from "@/lib/stores/mail-store"
import {
folderTree as defaultFolderTree,
sidebarNavFolderIdToLabel,

View File

@ -4,21 +4,28 @@ import {
createContext,
useContext,
useMemo,
useCallback,
type ReactNode,
} from "react"
import type { Email } from "@/lib/email-data"
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
import {
useScheduledStore,
type ScheduleSendPayload,
type OutboxEntry,
} from "@/lib/stores/scheduled-store"
import {
useScheduleSend,
useRescheduleSend,
useCancelScheduled,
useSendNow,
} from "@/lib/api/hooks/use-compose-mutations"
import { useActiveAccount } from "@/lib/stores/account-store"
export type { ScheduleSendPayload } from "@/lib/stores/scheduled-store"
export type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
type ScheduledMailContextValue = {
scheduledEmails: Email[]
scheduledEmails: OutboxEntry[]
snoozedEmails: Email[]
sentPlaceholderEmails: Email[]
refreshAll: () => Promise<void>
scheduleSend: (payload: ScheduleSendPayload) => Promise<{ id: string }>
removeScheduledLocal: (id: string) => void
requestDeleteScheduled: (id: string) => Promise<void>
@ -35,38 +42,171 @@ type ScheduledMailContextValue = {
const ScheduledMailContext = createContext<ScheduledMailContextValue | null>(null)
const noop = async () => {}
export function ScheduledMailProvider({ children }: { children: ReactNode }) {
const scheduledEmails = useScheduledStore((s) => s.scheduledEmails)
const snoozedEmails = useScheduledStore((s) => s.snoozedEmails)
const sentPlaceholderEmails = useScheduledStore((s) => s.sentPlaceholderEmails)
const account = useActiveAccount()
const value = useMemo<ScheduledMailContextValue>(() => {
const actions = useScheduledStore.getState()
const scheduleSendMutation = useScheduleSend()
const rescheduleMutation = useRescheduleSend()
const cancelMutation = useCancelScheduled()
const sendNowMutation = useSendNow()
const scheduleSend = useCallback(
async (payload: ScheduleSendPayload): Promise<{ id: string }> => {
const accountId = account?.id ?? ""
const result = await scheduleSendMutation.mutateAsync({
account_id: accountId,
to: payload.to.map((r) => ({ name: r.name, address: r.email })),
subject: payload.subject,
body_html: payload.bodyHtml,
idempotency_key: `sched-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
scheduled_at: payload.sendAtIso,
})
const id = result?.id ?? `local-${Date.now()}`
const entry: OutboxEntry = {
id,
account_id: accountId,
status: "scheduled",
subject: payload.subject,
to: payload.to.map((r) => ({ name: r.name, address: r.email })),
scheduled_at: payload.sendAtIso,
created_at: new Date().toISOString(),
}
useScheduledStore.getState().addScheduledEmail(entry)
return { id }
},
[scheduleSendMutation, account?.id]
)
const removeScheduledLocal = useCallback((id: string) => {
useScheduledStore.getState().removeScheduled(id)
}, [])
const requestDeleteScheduled = useCallback(
async (id: string) => {
await cancelMutation.mutateAsync({ id })
useScheduledStore.getState().removeScheduled(id)
},
[cancelMutation]
)
const requestArchiveScheduled = useCallback(
async (id: string) => {
await cancelMutation.mutateAsync({ id })
useScheduledStore.getState().removeScheduled(id)
},
[cancelMutation]
)
const requestSnoozeScheduled = useCallback(
async (id: string) => {
await cancelMutation.mutateAsync({ id })
useScheduledStore.getState().removeScheduled(id)
},
[cancelMutation]
)
const requestToggleReadScheduled = useCallback(
async (_id: string, _read: boolean) => {},
[]
)
const requestRescheduleScheduled = useCallback(
async (id: string, sendAtIso: string) => {
await rescheduleMutation.mutateAsync({ id, scheduled_at: sendAtIso })
const store = useScheduledStore.getState()
const existing = store.scheduledEmails.find((e) => e.id === id)
if (existing) {
store.addScheduledEmail({ ...existing, scheduled_at: sendAtIso })
}
},
[rescheduleMutation]
)
const requestGetScheduledEditPayload = useCallback(
async (id: string): Promise<ScheduleSendPayload | null> => {
const entry = useScheduledStore.getState().scheduledEmails.find((e) => e.id === id)
if (!entry) return null
return {
sendAtIso: entry.scheduled_at ?? new Date().toISOString(),
to: entry.to.map((r) => ({ name: r.name, email: r.address })),
subject: entry.subject,
previewText: "",
bodyHtml: "",
}
},
[]
)
const requestUpdateScheduledSend = useCallback(
async (id: string, payload: ScheduleSendPayload) => {
await rescheduleMutation.mutateAsync({ id, scheduled_at: payload.sendAtIso })
const entry: OutboxEntry = {
id,
account_id: account?.id ?? "",
status: "scheduled",
subject: payload.subject,
to: payload.to.map((r) => ({ name: r.name, address: r.email })),
scheduled_at: payload.sendAtIso,
created_at: new Date().toISOString(),
}
useScheduledStore.getState().addScheduledEmail(entry)
},
[rescheduleMutation, account?.id]
)
const requestSendScheduledNow = useCallback(
async (id: string) => {
await sendNowMutation.mutateAsync({ id })
useScheduledStore.getState().removeScheduled(id)
},
[sendNowMutation]
)
const requestSnoozeMailboxEmail = useCallback(async (row: Email) => {
useScheduledStore.getState().snoozeMailboxEmail(row)
}, [])
const requestRestoreSnoozedToInbox = useCallback(async (row: Email) => {
useScheduledStore.getState().restoreSnoozedToInbox(row)
}, [])
const value = useMemo<ScheduledMailContextValue>(
() => ({
scheduledEmails,
snoozedEmails,
sentPlaceholderEmails,
refreshAll: noop,
scheduleSend: async (payload) => actions.createScheduledSend(payload),
removeScheduledLocal: (id) => actions.removeScheduledLocal(id),
requestDeleteScheduled: async (id) => { actions.deleteScheduledSend(id) },
requestArchiveScheduled: async (id) => { actions.archiveScheduledSend(id) },
requestSnoozeScheduled: async (id) => { actions.snoozeScheduledSend(id) },
requestToggleReadScheduled: async (id, read) => { actions.markScheduledReadState(id, read) },
requestRescheduleScheduled: async (id, sendAtIso) => { actions.rescheduleScheduledSend(id, sendAtIso) },
requestGetScheduledEditPayload: async (id) => actions.getScheduledEditPayload(id),
requestUpdateScheduledSend: async (id, payload) => { actions.updateScheduledSend(id, payload) },
requestSendScheduledNow: async (id) => { actions.sendScheduledNow(id) },
requestSnoozeMailboxEmail: async (row) => {
actions.snoozeMailboxEmail(row)
},
requestRestoreSnoozedToInbox: async (row) => {
actions.restoreSnoozedToInbox(row)
},
}
}, [scheduledEmails, snoozedEmails, sentPlaceholderEmails])
scheduleSend,
removeScheduledLocal,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestGetScheduledEditPayload,
requestUpdateScheduledSend,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
}),
[
scheduledEmails,
snoozedEmails,
scheduleSend,
removeScheduledLocal,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestGetScheduledEditPayload,
requestUpdateScheduledSend,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
]
)
return (
<ScheduledMailContext.Provider value={value}>

View File

@ -1,54 +1,40 @@
"use client"
'use client'
import { create } from "zustand"
import { persist } from "zustand/middleware"
import {
DEFAULT_ACCOUNT_ID,
MOCK_USER_ACCOUNTS,
} from "@/lib/accounts/mock-accounts"
import type { UserAccount } from "@/lib/accounts/types"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/lib/api/auth-store'
import { useMailAccounts } from '@/lib/api/hooks/use-mail-queries'
import { debouncedPersistJSONStorage } from '@/lib/stores/debounced-json-storage'
import type { ApiMailAccount } from '@/lib/api/types'
type AccountStoreState = {
activeAccountId: string
activeAccountId: string | null
otherAccountsExpanded: boolean
}
type AccountStoreActions = {
setActiveAccount: (id: string) => void
setActiveAccountId: (id: string | null) => void
setOtherAccountsExpanded: (expanded: boolean) => void
toggleOtherAccountsExpanded: () => void
signOutAll: () => void
}
export function getAccountById(id: string): UserAccount | undefined {
return MOCK_USER_ACCOUNTS.find((a) => a.id === id)
}
export function useActiveAccount(): UserAccount {
const activeAccountId = useAccountStore((s) => s.activeAccountId)
return getAccountById(activeAccountId) ?? MOCK_USER_ACCOUNTS[0]!
}
export const useAccountStore = create<AccountStoreState & AccountStoreActions>()(
persist(
(set) => ({
activeAccountId: DEFAULT_ACCOUNT_ID,
activeAccountId: null,
otherAccountsExpanded: true,
setActiveAccount: (id) => set({ activeAccountId: id }),
setActiveAccountId: (id) => set({ activeAccountId: id }),
setOtherAccountsExpanded: (expanded) =>
set({ otherAccountsExpanded: expanded }),
toggleOtherAccountsExpanded: () =>
set((s) => ({ otherAccountsExpanded: !s.otherAccountsExpanded })),
signOutAll: () =>
set({ activeAccountId: DEFAULT_ACCOUNT_ID, otherAccountsExpanded: true }),
}),
{
name: "ultimail-accounts",
name: 'ultimail-accounts',
storage: debouncedPersistJSONStorage,
partialize: (s) => ({
activeAccountId: s.activeAccountId,
@ -57,3 +43,19 @@ export const useAccountStore = create<AccountStoreState & AccountStoreActions>()
},
),
)
export function useActiveAccount(): ApiMailAccount | null {
const activeAccountId = useAccountStore((s) => s.activeAccountId)
const { data: accounts } = useMailAccounts()
return accounts?.find((a) => a.id === activeAccountId) ?? accounts?.[0] ?? null
}
export function useSignOutAll() {
const queryClient = useQueryClient()
return () => {
useAuthStore.getState().logout()
queryClient.clear()
useAccountStore.setState({ activeAccountId: null, otherAccountsExpanded: true })
}
}

View File

@ -4,48 +4,14 @@ import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
/**
* Persistent mail store survives across navigations and page reloads.
* Tracks user-driven mutations on top of the static `emails` array from email-data.ts.
* Designed for future server sync: every action is a discrete delta.
*/
export type LabelEditState = {
additions: Record<string, string[]>
removals: Record<string, string[]>
}
type MailStoreState = {
readOverrides: Record<string, boolean>
starredIds: string[]
importantIds: string[]
labelEdits: LabelEditState
hiddenEmailIds: string[]
seenEmailIds: string[]
/** Ids marqués comme non-spam (réintégration boîte de réception dans lUI). */
notSpamEmailIds: string[]
recentMoveTargets: string[]
/** Dernières boîtes visitées (clés `mailNavVisitKey`), la plus récente en tête. */
recentFolderVisits: string[]
}
type MailStoreActions = {
setReadOverride: (id: string, read: boolean) => void
setReadOverrides: (overrides: Record<string, boolean>) => void
toggleStar: (id: string) => void
setStar: (id: string, starred: boolean) => void
toggleImportant: (id: string) => void
setImportant: (id: string, important: boolean) => void
addLabel: (emailId: string, label: string) => void
removeLabel: (emailId: string, label: string) => void
setLabelEdits: (updater: (prev: LabelEditState) => LabelEditState) => void
hideEmail: (id: string) => void
hideEmails: (ids: string[]) => void
unhideEmail: (id: string) => void
markSeen: (id: string) => void
/** Réintègre le message comme non-spam (liste / boîte de réception). */
markNotSpam: (id: string) => void
resetHidden: () => void
pushRecentMoveTarget: (targetId: string) => void
pushRecentFolderVisit: (visitKey: string) => void
}
@ -53,115 +19,10 @@ type MailStoreActions = {
export const useMailStore = create<MailStoreState & MailStoreActions>()(
persist(
(set) => ({
readOverrides: {},
starredIds: [],
importantIds: [],
labelEdits: { additions: {}, removals: {} },
hiddenEmailIds: [],
seenEmailIds: [],
notSpamEmailIds: [],
recentMoveTargets: [],
recentFolderVisits: [],
setReadOverride: (id, read) =>
set((s) => ({ readOverrides: { ...s.readOverrides, [id]: read } })),
setReadOverrides: (overrides) =>
set((s) => ({ readOverrides: { ...s.readOverrides, ...overrides } })),
toggleStar: (id) =>
set((s) => ({
starredIds: s.starredIds.includes(id)
? s.starredIds.filter((x) => x !== id)
: [...s.starredIds, id],
})),
setStar: (id, starred) =>
set((s) => ({
starredIds: starred
? s.starredIds.includes(id) ? s.starredIds : [...s.starredIds, id]
: s.starredIds.filter((x) => x !== id),
})),
toggleImportant: (id) =>
set((s) => ({
importantIds: s.importantIds.includes(id)
? s.importantIds.filter((x) => x !== id)
: [...s.importantIds, id],
})),
setImportant: (id, important) =>
set((s) => ({
importantIds: important
? s.importantIds.includes(id) ? s.importantIds : [...s.importantIds, id]
: s.importantIds.filter((x) => x !== id),
})),
addLabel: (emailId, label) =>
set((s) => {
const curr = s.labelEdits.additions[emailId] ?? []
if (curr.some((l) => l.toLowerCase() === label.toLowerCase())) return s
return {
labelEdits: {
additions: { ...s.labelEdits.additions, [emailId]: [...curr, label] },
removals: {
...s.labelEdits.removals,
[emailId]: (s.labelEdits.removals[emailId] ?? []).filter(
(r) => r.toLowerCase() !== label.toLowerCase()
),
},
},
}
}),
removeLabel: (emailId, label) =>
set((s) => {
const curr = s.labelEdits.removals[emailId] ?? []
if (curr.some((l) => l.toLowerCase() === label.toLowerCase())) return s
return {
labelEdits: {
removals: { ...s.labelEdits.removals, [emailId]: [...curr, label] },
additions: {
...s.labelEdits.additions,
[emailId]: (s.labelEdits.additions[emailId] ?? []).filter(
(a) => a.toLowerCase() !== label.toLowerCase()
),
},
},
}
}),
setLabelEdits: (updater) =>
set((s) => ({ labelEdits: updater(s.labelEdits) })),
hideEmail: (id) =>
set((s) => ({
hiddenEmailIds: s.hiddenEmailIds.includes(id)
? s.hiddenEmailIds
: [...s.hiddenEmailIds, id],
})),
hideEmails: (ids) =>
set((s) => {
const existing = new Set(s.hiddenEmailIds)
const toAdd = ids.filter((id) => !existing.has(id))
return toAdd.length > 0
? { hiddenEmailIds: [...s.hiddenEmailIds, ...toAdd] }
: s
}),
unhideEmail: (id) =>
set((s) => ({
hiddenEmailIds: s.hiddenEmailIds.filter((x) => x !== id),
})),
markNotSpam: (id) =>
set((s) =>
s.notSpamEmailIds.includes(id)
? s
: { notSpamEmailIds: [...s.notSpamEmailIds, id] }
),
markSeen: (id) =>
set((s) => ({
seenEmailIds: s.seenEmailIds.includes(id)
@ -169,8 +30,6 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
: [...s.seenEmailIds, id],
})),
resetHidden: () => set({ hiddenEmailIds: [] }),
pushRecentMoveTarget: (targetId) =>
set((s) => {
const MAX = 5
@ -188,16 +47,14 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
{
name: "ultimail-mail-state",
storage: debouncedPersistJSONStorage,
version: 3,
migrate: (persisted, version) => {
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] }
if (version < 2) {
return { ...state, recentFolderVisits: [], notSpamEmailIds: [] }
version: 4,
migrate: (persisted) => {
const state = persisted as Record<string, unknown>
return {
seenEmailIds: (state.seenEmailIds as string[]) ?? [],
recentMoveTargets: (state.recentMoveTargets as string[]) ?? [],
recentFolderVisits: (state.recentFolderVisits as string[]) ?? [],
}
if (version < 3) {
return { ...state, notSpamEmailIds: state.notSpamEmailIds ?? [] }
}
return state
},
}
)

View File

@ -5,182 +5,48 @@ import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import type { Email } from "@/lib/email-data"
export type ScheduleSendPayload = {
sendAtIso: string
to: { name: string; email: string }[]
export interface OutboxEntry {
id: string
account_id: string
status: "queued" | "scheduled" | "sending" | "sent" | "failed" | "cancelled"
subject: string
previewText: string
bodyHtml: string
to: { name: string; address: string }[]
scheduled_at?: string
created_at: string
}
type ScheduledStoreState = {
scheduledEmails: Email[]
scheduledEmails: OutboxEntry[]
snoozedEmails: Email[]
sentPlaceholderEmails: Email[]
}
function rowToSchedulePayload(row: Email): ScheduleSendPayload {
const email = row.senderEmail?.trim() ?? ""
const name = row.scheduledToName ?? row.sender
return {
sendAtIso: row.scheduledSendAt ?? new Date().toISOString(),
to: email ? [{ name, email }] : [],
subject: row.subject,
previewText: row.preview,
bodyHtml: row.body ?? `<p></p>`,
}
}
type ScheduledStoreActions = {
createScheduledSend: (payload: ScheduleSendPayload) => { id: string }
deleteScheduledSend: (id: string) => void
archiveScheduledSend: (id: string) => void
snoozeScheduledSend: (id: string) => void
rescheduleScheduledSend: (id: string, sendAtIso: string) => void
markScheduledReadState: (id: string, read: boolean) => void
getScheduledEditPayload: (id: string) => ScheduleSendPayload | null
updateScheduledSend: (id: string, payload: ScheduleSendPayload) => void
sendScheduledNow: (id: string) => void
removeScheduledLocal: (id: string) => void
/** Mettre en attente depuis la boîte (clone id `snz-…` dans En attente ; lappelant masque lid source). */
addScheduledEmail: (entry: OutboxEntry) => void
updateScheduledStatus: (id: string, status: OutboxEntry["status"]) => void
removeScheduled: (id: string) => void
snoozeMailboxEmail: (row: Email) => void
/** Quitter « En attente » : réaffiche dans la Boîte (snz-) ou parmi Planifiés (ex-envoi différé snoozé). */
restoreSnoozedToInbox: (row: Email) => void
}
export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActions>()(
persist(
(set, get) => ({
(set) => ({
scheduledEmails: [],
snoozedEmails: [],
sentPlaceholderEmails: [],
createScheduledSend: (payload) => {
const id = `sched-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const first = payload.to[0]
const toName = first?.name?.trim() || first?.email || "Destinataire"
const row: Email = {
id,
sender: toName,
senderEmail: first?.email,
subject: payload.subject.trim() || "(Sans objet)",
preview: payload.previewText.slice(0, 200),
body: payload.bodyHtml,
date: payload.sendAtIso,
read: true,
starred: false,
important: false,
labels: ["scheduled"],
scheduledSendAt: payload.sendAtIso,
scheduledToName: toName,
}
addScheduledEmail: (entry) =>
set((s) => ({
scheduledEmails: [row, ...s.scheduledEmails.filter((e) => e.id !== id)],
}))
return { id }
},
deleteScheduledSend: (id) =>
set((s) => ({
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
scheduledEmails: [entry, ...s.scheduledEmails.filter((e) => e.id !== entry.id)],
})),
archiveScheduledSend: (id) =>
set((s) => ({
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
})),
snoozeScheduledSend: (id) =>
set((s) => {
const row = s.scheduledEmails.find((e) => e.id === id)
if (!row) return s
const wake = new Date(Date.now() + 24 * 60 * 60 * 1000)
return {
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
snoozedEmails: [
{
...row,
labels: ["snoozed"],
scheduledSendAt: undefined,
scheduledToName: undefined,
snoozeWakeAt: wake.toISOString(),
sender: row.scheduledToName ?? row.sender,
read: true,
},
...s.snoozedEmails,
],
}
}),
rescheduleScheduledSend: (id, sendAtIso) =>
updateScheduledStatus: (id, status) =>
set((s) => ({
scheduledEmails: s.scheduledEmails.map((e) =>
e.id === id ? { ...e, scheduledSendAt: sendAtIso } : e
e.id === id ? { ...e, status } : e
),
})),
markScheduledReadState: (id, read) =>
set((s) => ({
scheduledEmails: s.scheduledEmails.map((e) =>
e.id === id ? { ...e, read } : e
),
})),
getScheduledEditPayload: (id) => {
const row = get().scheduledEmails.find((e) => e.id === id)
if (!row) return null
return rowToSchedulePayload(row)
},
updateScheduledSend: (id, payload) =>
set((s) => {
const first = payload.to[0]
const toName = first?.name?.trim() || first?.email || "Destinataire"
return {
scheduledEmails: s.scheduledEmails.map((e) =>
e.id === id
? {
...e,
sender: toName,
senderEmail: first?.email,
subject: payload.subject.trim() || "(Sans objet)",
preview: payload.previewText.slice(0, 200),
body: payload.bodyHtml,
scheduledSendAt: payload.sendAtIso,
scheduledToName: toName,
}
: e
),
}
}),
sendScheduledNow: (id) =>
set((s) => {
const row = s.scheduledEmails.find((e) => e.id === id)
if (!row) return s
const now = new Date()
return {
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
sentPlaceholderEmails: [
{
id: `sent-now-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`,
sender: row.scheduledToName ?? row.sender,
senderEmail: row.senderEmail,
subject: row.subject,
preview: row.preview,
body: row.body,
date: now.toISOString(),
read: true,
starred: false,
important: false,
labels: ["sent"],
},
...s.sentPlaceholderEmails,
],
}
}),
removeScheduledLocal: (id) =>
removeScheduled: (id) =>
set((s) => ({
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
})),
@ -208,36 +74,21 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
}),
restoreSnoozedToInbox: (row) =>
set((s) => {
const nextSnoozed = s.snoozedEmails.filter((e) => e.id !== row.id)
if (row.id.startsWith("snz-")) {
return { snoozedEmails: nextSnoozed }
}
const resumeAt =
row.snoozeWakeAt ??
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const back: Email = {
...row,
labels: ["scheduled"],
scheduledSendAt: resumeAt,
scheduledToName: row.sender,
snoozeWakeAt: undefined,
date: "",
read: true,
}
return {
snoozedEmails: nextSnoozed,
scheduledEmails: [
back,
...s.scheduledEmails.filter((e) => e.id !== row.id),
],
}
}),
set((s) => ({
snoozedEmails: s.snoozedEmails.filter((e) => e.id !== row.id),
})),
}),
{
name: "ultimail-scheduled-state",
storage: debouncedPersistJSONStorage,
version: 1,
version: 2,
migrate: (persisted) => {
const state = persisted as Record<string, unknown>
return {
scheduledEmails: [],
snoozedEmails: Array.isArray(state.snoozedEmails) ? state.snoozedEmails : [],
}
},
}
)
)

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -53,6 +53,8 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@tanstack/react-query": "^5.100.13",
"@tanstack/react-query-persist-client": "^5.100.13",
"@tiptap/core": "^3.23.2",
"@tiptap/extension-color": "^3.23.2",
"@tiptap/extension-link": "^3.23.2",
@ -73,6 +75,7 @@
"embla-carousel-react": "8.6.0",
"emoji-mart": "^5.6.0",
"fuse.js": "^7.3.0",
"idb": "^8.0.3",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"next": "16.2.6",

View File

@ -116,6 +116,12 @@ importers:
'@radix-ui/react-tooltip':
specifier: 1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/react-query':
specifier: ^5.100.13
version: 5.100.13(react@19.2.4)
'@tanstack/react-query-persist-client':
specifier: ^5.100.13
version: 5.100.13(@tanstack/react-query@5.100.13(react@19.2.4))(react@19.2.4)
'@tiptap/core':
specifier: ^3.23.2
version: 3.23.2(@tiptap/pm@3.23.2)
@ -176,6 +182,9 @@ importers:
fuse.js:
specifier: ^7.3.0
version: 7.3.0
idb:
specifier: ^8.0.3
version: 8.0.3
input-otp:
specifier: 1.4.2
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1277,6 +1286,23 @@ packages:
'@tailwindcss/postcss@4.2.0':
resolution: {integrity: sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==}
'@tanstack/query-core@5.100.13':
resolution: {integrity: sha512-mlKVKMTzZWGTKAC1CKOgt7axAjJ921emkEvYIp27I/PdP1yEYL/BteLY8iK35gn8hoYeKB4mgJ/ve3lrDI6/Fw==}
'@tanstack/query-persist-client-core@5.100.13':
resolution: {integrity: sha512-y0er+wfRn+TL3uNQ9mUSJcoSv+DTkKN0QFFy+CLM+zZVwuQ/CCgR+ApAp7aAaU7XzPILuhM0XSgnDyMlwMIrvQ==}
'@tanstack/react-query-persist-client@5.100.13':
resolution: {integrity: sha512-1Mvlkc4ay9sbdI9CuV4G3rbhSMk1lqST2lQZ0v7aLQzAEzARI9Kqz956PDhHIAVoKc6qTmwHoL7OauflcSCkNw==}
peerDependencies:
'@tanstack/react-query': ^5.100.13
react: ^18 || ^19
'@tanstack/react-query@5.100.13':
resolution: {integrity: sha512-HSBr8CycQEAoXsJR7KNDawBnINJEJ96Eme8oE0hCXjyodE2I97vg3IDzDJBDu18LsbzpVVJcKo80eqLfVCykxw==}
peerDependencies:
react: ^18 || ^19
'@tiptap/core@3.23.2':
resolution: {integrity: sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==}
peerDependencies:
@ -1679,6 +1705,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
idb@8.0.3:
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies:
@ -3084,6 +3113,23 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.2.0
'@tanstack/query-core@5.100.13': {}
'@tanstack/query-persist-client-core@5.100.13':
dependencies:
'@tanstack/query-core': 5.100.13
'@tanstack/react-query-persist-client@5.100.13(@tanstack/react-query@5.100.13(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/query-persist-client-core': 5.100.13
'@tanstack/react-query': 5.100.13(react@19.2.4)
react: 19.2.4
'@tanstack/react-query@5.100.13(react@19.2.4)':
dependencies:
'@tanstack/query-core': 5.100.13
react: 19.2.4
'@tiptap/core@3.23.2(@tiptap/pm@3.23.2)':
dependencies:
'@tiptap/pm': 3.23.2
@ -3468,6 +3514,8 @@ snapshots:
graceful-fs@4.2.11: {}
idb@8.0.3: {}
input-otp@1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4

File diff suppressed because one or more lines are too long