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 './globals.css'
import { ThemeInitScript } from '@/components/theme-init-script' import { ThemeInitScript } from '@/components/theme-init-script'
import { FirstLaunchSplash } from '@/components/first-launch-splash' import { FirstLaunchSplash } from '@/components/first-launch-splash'
import { QueryProvider } from '@/lib/api/query-provider'
const _geist = Geist({ subsets: ["latin"] }); const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ 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"> <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"> <body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
<ThemeInitScript /> <ThemeInitScript />
<QueryProvider>
<FirstLaunchSplash>{children}</FirstLaunchSplash> <FirstLaunchSplash>{children}</FirstLaunchSplash>
</QueryProvider>
{process.env.NODE_ENV === 'production' && <Analytics />} {process.env.NODE_ENV === 'production' && <Analytics />}
</body> </body>
</html> </html>

View File

@ -1,12 +1,11 @@
"use client" "use client"
import { useState } from "react" import type { ApiMailAccount } from "@/lib/api/types"
import type { UserAccount } from "@/lib/accounts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface AccountAvatarProps { interface AccountAvatarProps {
account: UserAccount account: Pick<ApiMailAccount, "name" | "email">
size?: "sm" | "md" | "lg" size?: "sm" | "md" | "lg"
className?: string className?: string
} }
@ -22,24 +21,9 @@ export function AccountAvatar({
size = "md", size = "md",
className, className,
}: AccountAvatarProps) { }: AccountAvatarProps) {
const [imageFailed, setImageFailed] = useState(false) const displayName = account.name || account.email
const initial = senderInitial(account.displayName) const initial = senderInitial(displayName)
const color = avatarColor(account.displayName) const color = avatarColor(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)}
/>
)
}
return ( return (
<div <div

View File

@ -1,23 +1,20 @@
"use client" "use client"
import { useEffect, useRef, type RefObject } from "react" 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 { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
import { AccountAvatar } from "@/components/gmail/account-avatar" import { AccountAvatar } from "@/components/gmail/account-avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { MOCK_USER_ACCOUNTS, STORAGE_USAGE } from "@/lib/accounts/mock-accounts" import type { ApiMailAccount } from "@/lib/api/types"
import type { UserAccount } from "@/lib/accounts/types" import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { import {
useAccountStore, useAccountStore,
useActiveAccount, useActiveAccount,
useSignOutAll,
} from "@/lib/stores/account-store" } from "@/lib/stores/account-store"
addCollection(mdiIcons)
interface AccountSwitcherDropdownProps { interface AccountSwitcherDropdownProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
/** Clicks inside this node (e.g. avatar trigger) do not close the panel. */
containerRef: RefObject<HTMLElement | null> containerRef: RefObject<HTMLElement | null>
} }
@ -25,7 +22,7 @@ function AccountRow({
account, account,
onSelect, onSelect,
}: { }: {
account: UserAccount account: ApiMailAccount
onSelect: () => void onSelect: () => void
}) { }) {
return ( return (
@ -37,7 +34,7 @@ function AccountRow({
<AccountAvatar account={account} size="sm" /> <AccountAvatar account={account} size="sm" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{account.displayName} {account.name}
</p> </p>
<p className="truncate text-xs text-muted-foreground">{account.email}</p> <p className="truncate text-xs text-muted-foreground">{account.email}</p>
</div> </div>
@ -54,13 +51,16 @@ export function AccountSwitcherDropdown({
const activeAccount = useActiveAccount() const activeAccount = useActiveAccount()
const activeAccountId = useAccountStore((s) => s.activeAccountId) const activeAccountId = useAccountStore((s) => s.activeAccountId)
const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded) const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded)
const setActiveAccount = useAccountStore((s) => s.setActiveAccount) const setActiveAccountId = useAccountStore((s) => s.setActiveAccountId)
const toggleOtherAccountsExpanded = useAccountStore( const toggleOtherAccountsExpanded = useAccountStore(
(s) => s.toggleOtherAccountsExpanded, (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(() => { useEffect(() => {
if (!open) return if (!open) return
@ -83,10 +83,10 @@ export function AccountSwitcherDropdown({
} }
}, [open, onOpenChange, containerRef]) }, [open, onOpenChange, containerRef])
if (!open) return null if (!open || !activeAccount) return null
const handleSelectAccount = (id: string) => { const handleSelectAccount = (id: string) => {
setActiveAccount(id) setActiveAccountId(id)
onOpenChange(false) onOpenChange(false)
} }
@ -97,7 +97,6 @@ export function AccountSwitcherDropdown({
aria-label="Comptes connectés" 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" 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"> <div className="relative px-4 pb-3 pt-4">
<p className="truncate pr-8 text-center text-sm text-foreground"> <p className="truncate pr-8 text-center text-sm text-foreground">
{activeAccount.email} {activeAccount.email}
@ -121,19 +120,18 @@ export function AccountSwitcherDropdown({
</span> </span>
</div> </div>
<h2 className="mt-3 text-xl font-normal text-foreground"> <h2 className="mt-3 text-xl font-normal text-foreground">
Bonjour {activeAccount.firstName} ! Bonjour {firstName} !
</h2> </h2>
<Button <Button
type="button" type="button"
variant="outline" 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" 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> </Button>
</div> </div>
</div> </div>
{/* Other accounts + actions */}
<div className="px-3 pb-3"> <div className="px-3 pb-3">
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface"> <div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
<button <button
@ -191,19 +189,6 @@ export function AccountSwitcherDropdown({
</div> </div>
</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"> <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"> <button type="button" className="hover:underline">
Règles de confidentialité Règles de confidentialité

View File

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

View File

@ -1,118 +1,23 @@
"use client" "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 { import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_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_PAGE_SECTION_TITLE_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes" } from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export function AddCoordinatesView() { 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 ( return (
<div> <div>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}> <h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
Ajouter des coordonnées ({visible.length}) Ajouter des coordonnées (0)
</h3> </h3>
{visible.length > 0 && (
<Button onClick={handleAddAll} className={CONTACTS_PRIMARY_BTN_CLASS}>
Ajouter tous les détails
</Button>
)}
</div> </div>
{visible.length === 0 && (
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}> <p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucune suggestion disponible Aucune suggestion disponible
</p> </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> </div>
) )
} }

View File

@ -8,8 +8,10 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { parseBulkContactText } from "@/lib/contacts/import-parsers" 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 { import {
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_LINK_BTN_CLASS, CONTACTS_PAGE_LINK_BTN_CLASS,
@ -25,13 +27,28 @@ interface BulkCreateDialogProps {
export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) { export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) {
const [input, setInput] = useState("") const [input, setInput] = useState("")
const addContacts = useContactsStore((s) => s.addContacts) const createContactMutation = useCreateContact()
function handleCreate() { function handleCreate() {
const parsed = parseBulkContactText(input) const parsed = parseBulkContactText(input)
if (parsed.length === 0) return 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("") setInput("")
onOpenChange(false) onOpenChange(false)
} }

View File

@ -41,8 +41,11 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } 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 { fullContactDisplayName } from "@/lib/contacts/types"
import type { FullContact } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -112,7 +115,9 @@ interface ContactCreatePageProps {
} }
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: 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 labelRows = useNavStore((s) => s.labelRows)
const availableLabels = labelRows.filter((r) => r.enabled !== false) const availableLabels = labelRows.filter((r) => r.enabled !== false)
const [starred, setStarred] = useState(false) const [starred, setStarred] = useState(false)
@ -208,10 +213,37 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
} }
if (mode === "create") { if (mode === "create") {
const id = addContact(payload) const tempId = crypto.randomUUID()
onSaved(id) 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) { } 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) onSaved(contactId)
} }
} }

View File

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

View File

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

View File

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

View File

@ -9,8 +9,10 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Info } from "lucide-react" import { Info } from "lucide-react"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { parseContactFile } from "@/lib/contacts/import-parsers" 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 { import {
CONTACTS_HEADING_TEXT, CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
@ -27,7 +29,7 @@ interface ImportDialogProps {
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) { export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const addContacts = useContactsStore((s) => s.addContacts) const createContactMutation = useCreateContact()
const [pendingFile, setPendingFile] = useState<File | null>(null) const [pendingFile, setPendingFile] = useState<File | null>(null)
const [previewCount, setPreviewCount] = useState(0) const [previewCount, setPreviewCount] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -76,11 +78,26 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
setError(null) setError(null)
try { try {
const parsed = await parseContactFile(pendingFile) const parsed = await parseContactFile(pendingFile)
const count = addContacts(parsed) if (parsed.length === 0) {
if (count === 0) {
setError("Aucun contact importé.") setError("Aucun contact importé.")
return 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) handleOpenChange(false)
} catch { } catch {
setError("L'import a échoué. Vérifiez le format du fichier.") setError("L'import a échoué. Vérifiez le format du fichier.")

View File

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

View File

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

View File

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

View File

@ -41,7 +41,10 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { useContactsStore } from "@/lib/contacts/contacts-store" 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 { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import { import {
@ -127,15 +130,15 @@ interface ContactFormViewProps {
export function ContactFormView({ mode, contactId }: ContactFormViewProps) { export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
const { const {
contacts,
addContact,
updateContact,
setView, setView,
showContactsList, showContactsList,
closePanel, closePanel,
createDraft, createDraft,
clearCreateDraft, clearCreateDraft,
} = useContactsStore() } = useContactsStore()
const { contacts } = useContactsList()
const createContactMutation = useCreateContact()
const updateContactMutation = useUpdateContact()
const labelRows = useNavStore((s) => s.labelRows) const labelRows = useNavStore((s) => s.labelRows)
const [starred, setStarred] = useState(false) const [starred, setStarred] = useState(false)
const [nameExpanded, setNameExpanded] = useState(false) const [nameExpanded, setNameExpanded] = useState(false)
@ -309,10 +312,37 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
} }
if (mode === "create") { if (mode === "create") {
const id = addContact(payload) const tempId = crypto.randomUUID()
setView("view", id) 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) { } 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) setView("view", contactId)
} }
} }

View File

@ -6,6 +6,7 @@ import { Search, ExternalLink, X, Plus } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { searchContacts } from "@/lib/contacts/fuzzy-search" import { searchContacts } from "@/lib/contacts/fuzzy-search"
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { import {
@ -26,7 +27,6 @@ import { ContactsPanelLogo } from "./contacts-panel-logo"
export function ContactsListView() { export function ContactsListView() {
const { const {
contacts,
searchMode, searchMode,
searchQuery, searchQuery,
setSearchMode, setSearchMode,
@ -35,6 +35,7 @@ export function ContactsListView() {
showContactsList, showContactsList,
closePanel, closePanel,
} = useContactsStore() } = useContactsStore()
const { contacts } = useContactsList()
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)

View File

@ -1,13 +1,37 @@
"use client" "use client"
import { useMemo } from "react"
import { mailLabelShouldShowInListStrip } from "@/components/gmail/mail-label-pills" import { mailLabelShouldShowInListStrip } from "@/components/gmail/mail-label-pills"
import { EmailView } from "@/components/gmail/email-view" import { EmailView } from "@/components/gmail/email-view"
import { LABEL_PICKER_EXCLUDE } from "@/lib/mail-list/label-actions" 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 { 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 { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection" 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 = { type EmailListEmailViewPaneProps = {
data: EmailListData data: EmailListData
reading: EmailListReading reading: EmailListReading
@ -17,37 +41,31 @@ type EmailListEmailViewPaneProps = {
export function EmailListEmailViewPane({ export function EmailListEmailViewPane({
data, data,
reading, reading,
selection, selection: _selection,
}: EmailListEmailViewPaneProps) { }: EmailListEmailViewPaneProps) {
const { const {
openEmail, openEmail,
openEmailThreadRoot,
isSingleMessageView, isSingleMessageView,
handleNavigateToLabel, handleNavigateToLabel,
singleNotSpam,
} = reading } = reading
const { toggleStar } = selection
const { const {
starredEmails,
listRowLabelBgByTextLower, listRowLabelBgByTextLower,
sidebarNav, sidebarNav,
selectedFolder, selectedFolder,
} = data } = data
if (!openEmail) return null const apiEmail = useMemo(
() => (openEmail ? emailToApiSummary(openEmail) : null),
[openEmail]
)
if (!openEmail || !apiEmail) return null
return ( return (
<EmailView <EmailView
email={openEmail} email={apiEmail}
threadRoot={openEmailThreadRoot}
isSingleMessageView={isSingleMessageView} isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar}
isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel} onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower} labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId} emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs} getNavItemPrefs={sidebarNav.getNavItemPrefs}

View File

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

View File

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

View File

@ -8,12 +8,18 @@ import {
useState, useState,
} from "react" } from "react"
import { useSearchParams, useRouter } from "next/navigation" import { useSearchParams, useRouter } from "next/navigation"
import { useQueryClient } from "@tanstack/react-query"
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills" 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 { import {
isListRowRead,
isThreadHeadMessage, isThreadHeadMessage,
readStateTargets,
} from "@/lib/mail-thread" } from "@/lib/mail-thread"
import { useScheduledMail } from "@/lib/scheduled-mail-context" import { useScheduledMail } from "@/lib/scheduled-mail-context"
import { useMailStore } from "@/lib/stores/mail-store" 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 { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useActiveAccount } from "@/lib/stores/account-store" import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store" import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import { import type { MailNavFolderMaps } from "@/lib/mail-folder-filter"
emailMatchesFolder,
emailMatchesInboxPrimaryTab,
type MailNavFolderMaps,
} from "@/lib/mail-folder-filter"
import { import {
getMailNavFolderLabel, getMailNavFolderLabel,
inboxTabDisplayLabel, inboxTabDisplayLabel,
@ -45,7 +47,6 @@ import {
buildSearchUrl, buildSearchUrl,
type SearchParams, type SearchParams,
} from "@/lib/mail-search/search-params" } from "@/lib/mail-search/search-params"
import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context" import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { useMoveTargets } from "@/components/gmail/move-to-menu-items" import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index" import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
@ -53,18 +54,6 @@ import {
useComposeActions, useComposeActions,
useComposeDrafts, useComposeDrafts,
} from "@/lib/compose-context" } 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 { import {
LIST_PAGE_SIZE, LIST_PAGE_SIZE,
type EmailListProps, type EmailListProps,
@ -75,9 +64,33 @@ import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { attachmentsForEmailList } from "@/lib/attachment-display" import { attachmentsForEmailList } from "@/lib/attachment-display"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs" import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
import type { Email, EmailAttachment } from "@/lib/email-data"
import { cleanSenderName } from "@/lib/sender-display" import { cleanSenderName } from "@/lib/sender-display"
import { threadStoreId } from "@/lib/mail-settings/list-row-id" 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({ export function useEmailListData({
selectedFolder, selectedFolder,
@ -138,7 +151,6 @@ export function useEmailListData({
const { const {
scheduledEmails, scheduledEmails,
snoozedEmails, snoozedEmails,
sentPlaceholderEmails,
requestDeleteScheduled, requestDeleteScheduled,
requestArchiveScheduled, requestArchiveScheduled,
requestSnoozeScheduled, requestSnoozeScheduled,
@ -152,19 +164,100 @@ export function useEmailListData({
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore) const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
const allEmails = useMemo( const accountId = searchAccount?.id
() => const queryClient = useQueryClient()
scheduledPersistHydrated
? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails] const effectiveApiFolder = useMemo(() => {
: emails, if (isSearchMode) return "__search__"
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails] 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( const emailById = useMemo(
() => new Map(allEmails.map((e) => [e.id, e])), () => new Map(allEmails.map((e) => [e.id, e])),
[allEmails] [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 sidebarNav = useSidebarNav()
const navMaps = useMemo<MailNavFolderMaps>( const navMaps = useMemo<MailNavFolderMaps>(
() => ({ () => ({
@ -255,45 +348,105 @@ export function useEmailListData({
pruneInlineComposesToOpenThread, 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 conversationMode = useMailSettingsStore((s) => s.conversationMode)
const inboxSort = useMailSettingsStore((s) => s.inboxSort) const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const density = useMailSettingsStore((s) => s.density) const density = useMailSettingsStore((s) => s.density)
const isMd = useIsMd() 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( const setReadOverrides = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => { (updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
const current = useMailStore.getState().readOverrides const changes = updater({})
const next = updater(current) for (const [id, isRead] of Object.entries(changes)) {
if (next !== current) mailActions.setReadOverrides(next) 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( const setLabelEdits = useCallback(
(updater: (prev: LabelEditState) => LabelEditState) => { (updater: (prev: { additions: Record<string, string[]>; removals: Record<string, string[]> }) => { additions: Record<string, string[]>; removals: Record<string, string[]> }) => {
mailActions.setLabelEdits(updater) const result = updater({ additions: {}, removals: {} })
}, for (const [id, additions] of Object.entries(result.additions)) {
[mailActions] 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(() => { useEffect(() => {
registerNavEmailSync({ registerNavEmailSync({
renameLabel: (from, to) => { renameLabel: (_from, _to) => {
setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to)) queryClient.invalidateQueries({ queryKey: ["messages"] })
}, },
removeLabel: (label) => { removeLabel: (_label) => {
setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label)) queryClient.invalidateQueries({ queryKey: ["messages"] })
}, },
}) })
return () => registerNavEmailSync(null) return () => registerNavEmailSync(null)
}, [allEmails, setLabelEdits]) }, [queryClient])
const [labelPickerQuery, setLabelPickerQuery] = useState("") const [labelPickerQuery, setLabelPickerQuery] = useState("")
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets) const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE) const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
const isXs = useIsXs() const isXs = useIsXs()
@ -303,8 +456,8 @@ export function useEmailListData({
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw]) const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
const handleRefreshMessages = useCallback(async () => { const handleRefreshMessages = useCallback(async () => {
await new Promise((resolve) => setTimeout(resolve, 900)) await queryClient.invalidateQueries({ queryKey: ["messages"] })
}, []) }, [queryClient])
const { const {
isRefreshing, isRefreshing,
@ -329,93 +482,12 @@ export function useEmailListData({
}, [isRefreshing, handleRefreshMessages, setIsRefreshing]) }, [isRefreshing, handleRefreshMessages, setIsRefreshing])
const markEmailSeen = useCallback((id: string) => { const markEmailSeen = useCallback((id: string) => {
mailActions.markSeen(id) useMailStore.getState().markSeen(id)
}, [mailActions]) }, [])
const folderFilterCtx = useMemo(
() => ({
starredEmailIds: starredEmails,
importantEmailIds: importantEmails,
}),
[starredEmails, importantEmails]
)
const filteredEmails = useMemo(() => { const filteredEmails = useMemo(() => {
const hiddenSet = new Set(hiddenEmailIds) return allEmails
const subtreeIdsCache = new Map<string, string[] | null>() }, [allEmails])
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,
])
const displayListEmails = useMemo(() => { const displayListEmails = useMemo(() => {
let rows = filteredEmails let rows = filteredEmails
@ -426,9 +498,9 @@ export function useEmailListData({
rows, rows,
inboxSort, inboxSort,
{ {
readOverrides, readOverrides: {},
starredIds: starredEmails, starredIds: [],
importantIds: importantEmails, importantIds: [],
}, },
{ conversationMode, byId: emailById } { conversationMode, byId: emailById }
) )
@ -436,9 +508,6 @@ export function useEmailListData({
filteredEmails, filteredEmails,
conversationMode, conversationMode,
inboxSort, inboxSort,
readOverrides,
starredEmails,
importantEmails,
emailById, emailById,
]) ])
@ -453,11 +522,8 @@ export function useEmailListData({
) )
const mobileUnreadCount = useMemo( const mobileUnreadCount = useMemo(
() => () => displayListEmails.filter((e) => !e.read).length,
displayListEmails.filter( [displayListEmails]
(e) => !isListRowRead(e, readOverrides, emailById, conversationMode)
).length,
[displayListEmails, readOverrides, emailById, conversationMode]
) )
const mobileFolderLabel = useMemo(() => { const mobileFolderLabel = useMemo(() => {
@ -474,15 +540,24 @@ export function useEmailListData({
isSearchMode, 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( const totalPages = useMemo(
() => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)), () => Math.max(1, Math.ceil((paginationTotal ?? displayListEmails.length) / LIST_PAGE_SIZE)),
[displayListEmails.length] [paginationTotal, displayListEmails.length]
) )
const pagedEmails = useMemo(() => { const pagedEmails = useMemo(() => {
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
return displayListEmails
}
const start = (listPage - 1) * LIST_PAGE_SIZE const start = (listPage - 1) * LIST_PAGE_SIZE
return displayListEmails.slice(start, start + LIST_PAGE_SIZE) return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
}, [displayListEmails, listPage]) }, [displayListEmails, listPage, effectiveApiFolder, isSearchMode])
const listEmails = useMemo(() => { const listEmails = useMemo(() => {
if (isXs && !isViewMode) { if (isXs && !isViewMode) {
@ -493,6 +568,14 @@ export function useEmailListData({
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails]) const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
const folderFilterCtx = useMemo(
() => ({
starredEmailIds: [] as string[],
importantEmailIds: [] as string[],
}),
[]
)
const listRowExtras = useMemo(() => { const listRowExtras = useMemo(() => {
const invitationById = new Map< const invitationById = new Map<
string, string,
@ -575,27 +658,7 @@ export function useEmailListData({
currentFolderId: selectedFolder, currentFolderId: selectedFolder,
}) })
const folderUnreadCounts = useMemo( const folderUnreadCounts = useMemo<Record<string, number>>(() => ({}), [])
() =>
computeFolderUnreadCounts(
allEmails,
folderFilterCtx,
hiddenEmailIds,
readOverrides,
navMaps,
labelEdits,
notSpamEmailIds
),
[
folderFilterCtx,
hiddenEmailIds,
readOverrides,
allEmails,
navMaps,
labelEdits,
notSpamEmailIds,
]
)
const seenSerialized = useMemo( const seenSerialized = useMemo(
() => [...seenEmailIds].sort().join(","), () => [...seenEmailIds].sort().join(","),
@ -606,35 +669,11 @@ export function useEmailListData({
const seen = new Set( const seen = new Set(
seenSerialized.length > 0 ? seenSerialized.split(",") : [] seenSerialized.length > 0 ? seenSerialized.split(",") : []
) )
const hidden = new Set(hiddenEmailIds) const inboxPool = allEmails.filter((e) => !seen.has(e.id))
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 counts: Record<string, number> = {} const counts: Record<string, number> = {}
const preview: Record<string, string> = {} const preview: Record<string, string> = {}
const tabCache = new Map<string, string[] | null>()
for (const tab of inboxTabBarItems) { for (const tab of inboxTabBarItems) {
const rows = inboxPool.filter((e) => { const rows = inboxPool.filter((e) => !seen.has(e.id))
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)
)
})
counts[tab.id] = rows.length counts[tab.id] = rows.length
if (inboxTabShowsInactiveMeta(tab.id)) { if (inboxTabShowsInactiveMeta(tab.id)) {
const chain: string[] = [] const chain: string[] = []
@ -650,7 +689,7 @@ export function useEmailListData({
} }
} }
return { unseenInTabById: counts, tabUnseenSenderLineById: preview } return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems]) }, [seenSerialized, allEmails, inboxTabBarItems])
useEffect(() => { useEffect(() => {
onFolderUnreadCountsChange?.(folderUnreadCounts) onFolderUnreadCountsChange?.(folderUnreadCounts)
@ -667,28 +706,25 @@ export function useEmailListData({
const listRowsDep = listEmails.map((e) => e.id).join(",") const listRowsDep = listEmails.map((e) => e.id).join(",")
const effectiveRead = useCallback( const effectiveRead = useCallback(
(email: Email) => (email: Email) => email.read,
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read, []
[readOverrides]
) )
const effectiveStarred = useCallback( const effectiveStarred = useCallback(
(email: Email) => (email: Email) => email.starred,
starredEmails.includes(email.id) || email.starred, []
[starredEmails]
) )
const markAllInViewAsRead = useCallback(() => { const markAllInViewAsRead = useCallback(() => {
setReadOverrides((prev) => {
const next = { ...prev }
for (const e of displayListEmails) { for (const e of displayListEmails) {
for (const id of readStateTargets(e, conversationMode)) { if (e.read) continue
next[id] = true 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, apiMessagesById, updateFlags])
})
}, [displayListEmails, conversationMode, setReadOverrides])
return { return {
selectedFolder, selectedFolder,
@ -779,6 +815,9 @@ export function useEmailListData({
requestSendScheduledNow, requestSendScheduledNow,
requestSnoozeMailboxEmail, requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox, requestRestoreSnoozedToInbox,
isLoading,
error,
isFetching,
} }
} }

View File

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

View File

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

View File

@ -1,15 +1,12 @@
"use client" "use client"
import { useCallback } from "react"
import type { Email } from "@/lib/email-data" import type { Email } from "@/lib/email-data"
import { useMailStore } from "@/lib/stores/mail-store"
export type ListMailIndex = { export type ListMailIndex = {
emailById: Map<string, Email> emailById: Map<string, Email>
scheduledIds: Set<string> scheduledIds: Set<string>
} }
/** O(n) index for list row logic — avoids repeated `allEmails.some` / `find` per row. */
export function buildListMailIndex(emails: Email[]): ListMailIndex { export function buildListMailIndex(emails: Email[]): ListMailIndex {
const emailById = new Map<string, Email>() const emailById = new Map<string, Email>()
const scheduledIds = new Set<string>() const scheduledIds = new Set<string>()
@ -26,24 +23,10 @@ export type MailRowFlags = {
isImportant: boolean 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 { 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 { return {
isRead: readOverride !== undefined ? readOverride : email.read, isRead: email.read,
isStarred: starred || email.starred, isStarred: email.starred,
isImportant: important || email.important, isImportant: email.important,
} }
} }

View File

@ -6,14 +6,10 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
type CSSProperties,
} from "react" } from "react"
import { Star, Reply, ReplyAll, Forward } from "lucide-react" import { Reply, ReplyAll, Forward } from "lucide-react"
import { import {
Tooltip,
TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
@ -21,8 +17,16 @@ import {
cleanSenderName, cleanSenderName,
senderInitial, senderInitial,
} from "@/lib/sender-display" } from "@/lib/sender-display"
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
import type { Email, EmailAttachment } from "@/lib/email-data" import type { Email, EmailAttachment } from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-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 { import {
useComposeActions, useComposeActions,
useComposeDrafts, useComposeDrafts,
@ -52,36 +56,55 @@ import {
SpamWhyBanner, SpamWhyBanner,
} from "@/components/gmail/email-view/email-view-messages" } 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 { interface EmailViewProps {
email: Email email: ApiMessageSummary
onToggleStar: (id: string) => void
isStarred: boolean
onNavigateToLabel?: (label: string) => void 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 showLabelChip?: (label: string) => boolean
labelBgByText?: Map<string, string> labelBgByText?: Map<string, string>
emailLabelToSidebarFolderId?: Record<string, string> emailLabelToSidebarFolderId?: Record<string, string>
getNavItemPrefs?: (id: string) => { messages: string } getNavItemPrefs?: (id: string) => { messages: string }
folderTree?: FolderTreeNode[] folderTree?: FolderTreeNode[]
labelRows?: readonly LabelRowItem[] labelRows?: readonly LabelRowItem[]
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
currentFolderId?: string currentFolderId?: string
/** Fil complet (mode message isolé hors conversation). */
threadRoot?: Email | null
/** Affiche uniquement le message courant avec option douvrir le fil. */
isSingleMessageView?: boolean isSingleMessageView?: boolean
} }
/* ── Main EmailView component ── */
export function EmailView({ export function EmailView({
email, email,
onToggleStar,
isStarred,
onNavigateToLabel, onNavigateToLabel,
onNotSpam,
showLabelChip, showLabelChip,
labelBgByText, labelBgByText,
emailLabelToSidebarFolderId = {}, emailLabelToSidebarFolderId = {},
@ -89,47 +112,82 @@ export function EmailView({
folderTree, folderTree,
labelRows, labelRows,
currentFolderId, currentFolderId,
threadRoot = null,
isSingleMessageView = false, isSingleMessageView = false,
}: EmailViewProps) { }: 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 [showFullThread, setShowFullThread] = useState(false)
const threadForReplies = threadRoot ?? email
const priorCount = Math.max( const priorMessages = useMemo(() => {
0, if (!threadMessages) return []
(threadForReplies.threadMessageIds?.length ?? 1) - 1 return threadMessages.filter((m) => m.id !== email.id)
) }, [threadMessages, email.id])
const priorCount = priorMessages.length
const showRepliesCta = const showRepliesCta =
isSingleMessageView && !showFullThread && priorCount > 0 isSingleMessageView && !showFullThread && priorCount > 0
const conversation = const conversation =
isSingleMessageView && !showFullThread isSingleMessageView && !showFullThread ? [] : priorMessages
? []
: (showFullThread ? threadForReplies.conversation : email.conversation) ?? []
const hasConversation = conversation.length > 0 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 [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const toggleExpanded = (msgId: string) => { const toggleExpanded = (msgId: string) => {
setExpandedIds((prev) => { setExpandedIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(msgId)) { if (next.has(msgId)) next.delete(msgId)
next.delete(msgId) else next.add(msgId)
} else {
next.add(msgId)
}
return next return next
}) })
} }
const mainSenderName = cleanSenderName(email.sender) const mainSenderName = cleanSenderName(email.from[0]?.name ?? "")
const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com` 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 { composeWindows } = useComposeWindows()
const { savedThreadReplyDrafts } = useComposeDrafts() const { savedThreadReplyDrafts } = useComposeDrafts()
const { openComposeWithInitial } = useComposeActions() const { openComposeWithInitial } = useComposeActions()
const inlineCompose = useMemo( const inlineCompose = useMemo(
() => () =>
composeWindows.find( composeWindows.find(
@ -138,13 +196,6 @@ export function EmailView({
[composeWindows, email.id] [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 hasInlineForThread = Boolean(inlineCompose)
const showReplyForwardBar = !inlineCompose const showReplyForwardBar = !inlineCompose
@ -174,41 +225,64 @@ export function EmailView({
[openComposeWithInitial, scrollThreadComposeIntoView] [openComposeWithInitial, scrollThreadComposeIntoView]
) )
const savedThreadDraft = savedThreadReplyDrafts[email.id]
useEffect(() => { useEffect(() => {
if (!savedThreadDraft || hasInlineForThread) return if (!savedThreadDraft || hasInlineForThread) return
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft)) openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
}, [ }, [email.id, savedThreadDraft, hasInlineForThread, openThreadCompose])
email.id,
savedThreadDraft,
hasInlineForThread,
openThreadCompose,
])
const startThreadCompose = useCallback( const startThreadCompose = useCallback(
(kind: ThreadComposeKind) => { (kind: ThreadComposeKind) => {
openThreadCompose(buildThreadComposePreset(email, kind)) openThreadCompose(buildThreadComposePreset(legacyEmail, kind))
}, },
[email, openThreadCompose] [legacyEmail, openThreadCompose]
) )
const selfIdentity = DEFAULT_IDENTITIES[0] const selfIdentity = DEFAULT_IDENTITIES[0]
const selfName = cleanSenderName(selfIdentity.name) const selfName = cleanSenderName(selfIdentity.name)
const calendarInvitation = useMemo( const calendarInvitation = useMemo(
() => resolveParsedCalendarInvitation(email), () => resolveParsedCalendarInvitation(legacyEmail),
[email] [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 ( return (
<TooltipProvider delayDuration={400}> <TooltipProvider delayDuration={400}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}> <div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
{/* Spacer for floating nav buttons on xs */} <div
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden /> className="h-[52px] shrink-0 bg-mail-surface sm:hidden"
aria-hidden
/>
<EmailViewSubjectHeader <EmailViewSubjectHeader
email={email} email={email}
isSpamMessage={isSpamMessage} isSpamMessage={isSpam}
onNotSpam={onNotSpam} onNotSpam={isSpam ? handleNotSpam : undefined}
onPrint={handlePrint}
onNavigateToLabel={onNavigateToLabel} onNavigateToLabel={onNavigateToLabel}
showLabelChip={showLabelChip} showLabelChip={showLabelChip}
labelBgByText={labelBgByText} labelBgByText={labelBgByText}
@ -223,7 +297,7 @@ export function EmailView({
<CalendarInvitationPreview invitation={calendarInvitation} /> <CalendarInvitationPreview invitation={calendarInvitation} />
) : null} ) : null}
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />} {isSpam && <SpamWhyBanner onNotSpam={handleNotSpam} />}
{showRepliesCta ? ( {showRepliesCta ? (
<div className="border-b border-border px-6 py-3 max-sm:px-4"> <div className="border-b border-border px-6 py-3 max-sm:px-4">
@ -239,25 +313,23 @@ export function EmailView({
</div> </div>
) : null} ) : null}
{/* Conversation messages */} {hasConversation &&
{/* Previous messages in conversation */} conversation.map((msg) => {
{hasConversation && conversation.map((msg) => {
const isExpanded = expandedIds.has(msg.id) const isExpanded = expandedIds.has(msg.id)
if (isExpanded) { if (isExpanded) {
return ( return (
<div key={msg.id} className="border-b border-border"> <div key={msg.id} className="border-b border-border">
<ExpandedMessage <ExpandedMessage
sender={msg.sender} sender={msg.from[0]?.name ?? ""}
senderEmail={msg.senderEmail} senderEmail={msg.from[0]?.address ?? ""}
dateIso={msg.date} dateIso={msg.date}
body={msg.body} body={msg.body_html ?? msg.body_text ?? ""}
isSpam={false} isSpam={false}
isLast={false} isLast={false}
starred={false} starred={msg.flags.includes("starred")}
attachments={msg.attachments ?? []}
onCollapse={() => toggleExpanded(msg.id)} onCollapse={() => toggleExpanded(msg.id)}
onPrintConversation={() => openConversationPrint(email)} onPrintConversation={handlePrint}
/> />
</div> </div>
) )
@ -273,18 +345,17 @@ export function EmailView({
) )
})} })}
{/* Last / main message — always expanded */}
<ExpandedMessage <ExpandedMessage
sender={mainSenderName} sender={mainSenderName}
senderEmail={mainSenderAddr} senderEmail={mainSenderAddr}
dateIso={email.date} dateIso={email.date}
body={email.body || `<p style="color:var(--muted-foreground);">${email.preview}</p>`} body={body}
isSpam={email.spam === true} isSpam={isSpam}
isLast={true} isLast={true}
starred={isStarred} starred={isStarred}
attachments={mainMessageAttachments} attachments={mainMessageAttachments}
onToggleStar={() => onToggleStar(email.id)} onToggleStar={handleToggleStar}
onPrintConversation={() => openConversationPrint(email)} onPrintConversation={handlePrint}
/> />
{showReplyForwardBar ? ( {showReplyForwardBar ? (
@ -300,7 +371,10 @@ export function EmailView({
onClick={() => startThreadCompose("reply")} onClick={() => startThreadCompose("reply")}
className={MAIL_REPLY_BUTTON_CLASS} 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 Répondre
</button> </button>
<button <button
@ -308,7 +382,10 @@ export function EmailView({
onClick={() => startThreadCompose("replyAll")} onClick={() => startThreadCompose("replyAll")}
className={MAIL_REPLY_BUTTON_CLASS} 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 Répondre à tous
</button> </button>
<button <button
@ -316,14 +393,20 @@ export function EmailView({
onClick={() => startThreadCompose("forward")} onClick={() => startThreadCompose("forward")}
className={MAIL_REPLY_BUTTON_CLASS} 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 Transférer
</button> </button>
</div> </div>
) : null} ) : null}
{inlineCompose ? ( {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 items-start gap-3">
<div <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white" 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 <ComposeWindow
key={inlineCompose.id} key={inlineCompose.id}
compose={inlineCompose} compose={inlineCompose}
threadSourceEmail={email} threadSourceEmail={legacyEmail}
/> />
</div> </div>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>

View File

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

View File

@ -13,10 +13,8 @@ import {
senderInitial, senderInitial,
} from "@/lib/sender-display" } from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text" import { MailDateText } from "@/components/gmail/mail-date-text"
import type { import type { ApiMessageFull } from "@/lib/api/types"
ConversationMessage, import type { EmailAttachment } from "@/lib/email-data"
EmailAttachment,
} from "@/lib/email-data"
import { ContactHoverCard } from "@/components/gmail/contact-hover-card" import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar" import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content" import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
@ -30,10 +28,12 @@ export function CollapsedMessage({
message, message,
onClick, onClick,
}: { }: {
message: ConversationMessage message: ApiMessageFull
onClick: () => void 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) const color = avatarColor(name)
return ( return (
@ -57,7 +57,7 @@ export function CollapsedMessage({
</div> </div>
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text> <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"> <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> <span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard> </ContactHoverCard>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
@ -72,7 +72,7 @@ export function CollapsedMessage({
/> />
</div> </div>
</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>
</div> </div>
) )

View File

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

View File

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

View File

@ -21,14 +21,12 @@ import {
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { emails } from "@/lib/email-data" import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useActiveAccount } from "@/lib/stores/account-store" import { useActiveAccount } from "@/lib/stores/account-store"
import { import {
matchContacts,
matchEmails,
bestCompletion, bestCompletion,
type SearchSuggestion, type SearchSuggestion,
type ContactSuggestion,
} from "@/lib/mail-search/search-engine" } from "@/lib/mail-search/search-engine"
import { import {
buildQuickSearchParams, buildQuickSearchParams,
@ -53,13 +51,14 @@ interface MobileSearchOverlayProps {
export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: MobileSearchOverlayProps) { export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: MobileSearchOverlayProps) {
const router = useRouter() const router = useRouter()
const account = useActiveAccount() const account = useActiveAccount()
const contacts = useContactsStore((s) => s.contacts)
const inputValue = useMailSearchStore((s) => s.inputValue) const inputValue = useMailSearchStore((s) => s.inputValue)
const selectedIndex = useMailSearchStore((s) => s.selectedIndex) const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
const chipAttachment = useMailSearchStore((s) => s.chipAttachment) const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days) const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
const chipFromMe = useMailSearchStore((s) => s.chipFromMe) const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
const { data: searchContactResults } = useSearchContacts(inputValue)
const { const {
setInputValue, setInputValue,
setSelectedIndex, setSelectedIndex,
@ -85,13 +84,23 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
}, [open, initialQuery, setInputValue, reset]) }, [open, initialQuery, setInputValue, reset])
const suggestions = useMemo<SearchSuggestion[]>(() => { const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim()) return [] if (!inputValue.trim() || !searchContactResults?.length) return []
const contactHits = matchContacts(inputValue, contacts, 4) return searchContactResults.slice(0, 6).map<ContactSuggestion>((c) => ({
const emailHits = matchEmails(inputValue, emails, 4) kind: "contact",
const seen = new Set(contactHits.map((c) => c.email)) contact: {
const unique = emailHits.filter((e) => !seen.has(e.email)) id: c.uid,
return [...contactHits, ...unique] firstName: c.full_name.split(" ")[0] ?? "",
}, [inputValue, contacts]) 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( const ghostText = useMemo(
() => bestCompletion(inputValue, suggestions), () => bestCompletion(inputValue, suggestions),
@ -107,12 +116,12 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
chipAttachment, chipAttachment,
chipLast7Days, chipLast7Days,
chipFromMe, chipFromMe,
fromEmail: account.email, fromEmail: account?.email ?? "",
}) })
if (!Object.keys(params).length) return if (!Object.keys(params).length) return
submitMailSearch(router, params, { onAfter: onClose }) submitMailSearch(router, params, { onAfter: onClose })
}, },
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose] [inputValue, chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
) )
const selectSuggestion = useCallback( const selectSuggestion = useCallback(
@ -121,11 +130,11 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
chipAttachment, chipAttachment,
chipLast7Days, chipLast7Days,
chipFromMe, chipFromMe,
fromEmail: account.email, fromEmail: account?.email ?? "",
}) })
submitMailSearch(router, params, { onAfter: onClose }) submitMailSearch(router, params, { onAfter: onClose })
}, },
[chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose] [chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
) )
const handleKeyDown = useCallback( 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 { create } from "zustand"
import { persist } from "zustand/middleware" import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage" 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" import type { FullContact } from "./types"
type ContactsView = "list" | "view" | "create" | "edit" type ContactsView = "list" | "view" | "create" | "edit"
/** Prefill for "Nouveau contact" opened from hover card / elsewhere. */
export type ContactCreateDraft = { export type ContactCreateDraft = {
firstName?: string firstName?: string
lastName?: string lastName?: string
@ -27,20 +19,7 @@ export interface DeletedContact {
reason: string reason: string
} }
export interface MergeSuggestion {
contactA: FullContact
contactB: FullContact
reason: DuplicateMatchReason
}
export interface CoordinateSuggestion {
contact: FullContact
suggestedField: string
suggestedValue: string
}
interface ContactsState { interface ContactsState {
contacts: FullContact[]
deletedContacts: DeletedContact[] deletedContacts: DeletedContact[]
ignoredMergePairs: string[] ignoredMergePairs: string[]
panelOpen: boolean panelOpen: boolean
@ -62,61 +41,17 @@ interface ContactsActions {
showContactsList: () => void showContactsList: () => void
setSearchQuery: (q: string) => void setSearchQuery: (q: string) => void
setSearchMode: (active: boolean) => void setSearchMode: (active: boolean) => void
addContact: ( softDeleteContact: (contact: FullContact, reason?: string) => void
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
restoreContact: (id: string) => void restoreContact: (id: string) => void
emptyTrash: () => void emptyTrash: () => void
mergeContacts: (keepId: string, mergeId: string) => void
ignoreMergePair: (idA: string, idB: string) => void ignoreMergePair: (idA: string, idB: string) => void
getMergeSuggestions: () => MergeSuggestion[]
getCoordinateSuggestions: () => CoordinateSuggestion[]
} }
export type ContactsStore = ContactsState & ContactsActions 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>()( export const useContactsStore = create<ContactsStore>()(
persist( persist(
(set, get) => ({ (set) => ({
contacts: MOCK_FULL_CONTACTS,
deletedContacts: [], deletedContacts: [],
ignoredMergePairs: [], ignoredMergePairs: [],
panelOpen: false, panelOpen: false,
@ -191,148 +126,38 @@ export const useContactsStore = create<ContactsStore>()(
setSearchMode: (searchMode) => setSearchMode: (searchMode) =>
set(searchMode ? { searchMode } : { searchMode, searchQuery: "" }), set(searchMode ? { searchMode } : { searchMode, searchQuery: "" }),
addContact: (contact) => { softDeleteContact: (contact, reason = "Supprimé manuellement") =>
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) =>
set((s) => ({ 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: [ deletedContacts: [
...s.deletedContacts, ...s.deletedContacts,
{ contact, deletedAt: Date.now(), reason }, { contact, deletedAt: Date.now(), reason },
], ],
activeContactId: s.activeContactId === id ? null : s.activeContactId, activeContactId: s.activeContactId === contact.id ? null : s.activeContactId,
view: s.activeContactId === id ? "list" : s.view, view: s.activeContactId === contact.id ? "list" : s.view,
} })),
}),
restoreContact: (id) => restoreContact: (id) =>
set((s) => { set((s) => {
const entry = s.deletedContacts.find((d) => d.contact.id === id) const entry = s.deletedContacts.find((d) => d.contact.id === id)
if (!entry) return s if (!entry) return s
return { return {
contacts: [...s.contacts, entry.contact],
deletedContacts: s.deletedContacts.filter((d) => d.contact.id !== id), deletedContacts: s.deletedContacts.filter((d) => d.contact.id !== id),
} }
}), }),
emptyTrash: () => set({ deletedContacts: [] }), 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) => ignoreMergePair: (idA, idB) =>
set((s) => { set((s) => {
const key = mergePairKey(idA, idB) const key = [idA, idB].sort().join("::")
if (s.ignoredMergePairs.includes(key)) return s if (s.ignoredMergePairs.includes(key)) return s
return { ignoredMergePairs: [...s.ignoredMergePairs, key] } 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", name: "contacts-store",
storage: debouncedPersistJSONStorage, storage: debouncedPersistJSONStorage,
partialize: (state) => ({ partialize: (state) => ({
contacts: state.contacts,
deletedContacts: state.deletedContacts, deletedContacts: state.deletedContacts,
ignoredMergePairs: state.ignoredMergePairs, ignoredMergePairs: state.ignoredMergePairs,
}), }),

View File

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

View File

@ -35,6 +35,18 @@ export interface FullContact {
updatedAt: number 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 { export function fullContactDisplayName(c: FullContact): string {
return `${c.firstName} ${c.lastName}`.trim() 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 { 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( export function effectiveLabels(
email: Email | undefined, email: Email | undefined,

View File

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

View File

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

View File

@ -4,21 +4,28 @@ import {
createContext, createContext,
useContext, useContext,
useMemo, useMemo,
useCallback,
type ReactNode, type ReactNode,
} from "react" } from "react"
import type { Email } from "@/lib/email-data" import type { Email } from "@/lib/email-data"
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
import { import {
useScheduledStore, useScheduledStore,
type ScheduleSendPayload, type OutboxEntry,
} from "@/lib/stores/scheduled-store" } 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 = { type ScheduledMailContextValue = {
scheduledEmails: Email[] scheduledEmails: OutboxEntry[]
snoozedEmails: Email[] snoozedEmails: Email[]
sentPlaceholderEmails: Email[]
refreshAll: () => Promise<void>
scheduleSend: (payload: ScheduleSendPayload) => Promise<{ id: string }> scheduleSend: (payload: ScheduleSendPayload) => Promise<{ id: string }>
removeScheduledLocal: (id: string) => void removeScheduledLocal: (id: string) => void
requestDeleteScheduled: (id: string) => Promise<void> requestDeleteScheduled: (id: string) => Promise<void>
@ -35,38 +42,171 @@ type ScheduledMailContextValue = {
const ScheduledMailContext = createContext<ScheduledMailContextValue | null>(null) const ScheduledMailContext = createContext<ScheduledMailContextValue | null>(null)
const noop = async () => {}
export function ScheduledMailProvider({ children }: { children: ReactNode }) { export function ScheduledMailProvider({ children }: { children: ReactNode }) {
const scheduledEmails = useScheduledStore((s) => s.scheduledEmails) const scheduledEmails = useScheduledStore((s) => s.scheduledEmails)
const snoozedEmails = useScheduledStore((s) => s.snoozedEmails) const snoozedEmails = useScheduledStore((s) => s.snoozedEmails)
const sentPlaceholderEmails = useScheduledStore((s) => s.sentPlaceholderEmails) const account = useActiveAccount()
const value = useMemo<ScheduledMailContextValue>(() => { const scheduleSendMutation = useScheduleSend()
const actions = useScheduledStore.getState() 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 { 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, scheduledEmails,
snoozedEmails, snoozedEmails,
sentPlaceholderEmails, scheduleSend,
refreshAll: noop, removeScheduledLocal,
scheduleSend: async (payload) => actions.createScheduledSend(payload), requestDeleteScheduled,
removeScheduledLocal: (id) => actions.removeScheduledLocal(id), requestArchiveScheduled,
requestDeleteScheduled: async (id) => { actions.deleteScheduledSend(id) }, requestSnoozeScheduled,
requestArchiveScheduled: async (id) => { actions.archiveScheduledSend(id) }, requestToggleReadScheduled,
requestSnoozeScheduled: async (id) => { actions.snoozeScheduledSend(id) }, requestRescheduleScheduled,
requestToggleReadScheduled: async (id, read) => { actions.markScheduledReadState(id, read) }, requestGetScheduledEditPayload,
requestRescheduleScheduled: async (id, sendAtIso) => { actions.rescheduleScheduledSend(id, sendAtIso) }, requestUpdateScheduledSend,
requestGetScheduledEditPayload: async (id) => actions.getScheduledEditPayload(id), requestSendScheduledNow,
requestUpdateScheduledSend: async (id, payload) => { actions.updateScheduledSend(id, payload) }, requestSnoozeMailboxEmail,
requestSendScheduledNow: async (id) => { actions.sendScheduledNow(id) }, requestRestoreSnoozedToInbox,
requestSnoozeMailboxEmail: async (row) => { }),
actions.snoozeMailboxEmail(row) [
}, scheduledEmails,
requestRestoreSnoozedToInbox: async (row) => { snoozedEmails,
actions.restoreSnoozedToInbox(row) scheduleSend,
}, removeScheduledLocal,
} requestDeleteScheduled,
}, [scheduledEmails, snoozedEmails, sentPlaceholderEmails]) requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestGetScheduledEditPayload,
requestUpdateScheduledSend,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
]
)
return ( return (
<ScheduledMailContext.Provider value={value}> <ScheduledMailContext.Provider value={value}>

View File

@ -1,54 +1,40 @@
"use client" 'use client'
import { create } from "zustand" import { create } from 'zustand'
import { persist } from "zustand/middleware" import { persist } from 'zustand/middleware'
import { import { useQueryClient } from '@tanstack/react-query'
DEFAULT_ACCOUNT_ID, import { useAuthStore } from '@/lib/api/auth-store'
MOCK_USER_ACCOUNTS, import { useMailAccounts } from '@/lib/api/hooks/use-mail-queries'
} from "@/lib/accounts/mock-accounts" import { debouncedPersistJSONStorage } from '@/lib/stores/debounced-json-storage'
import type { UserAccount } from "@/lib/accounts/types" import type { ApiMailAccount } from '@/lib/api/types'
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
type AccountStoreState = { type AccountStoreState = {
activeAccountId: string activeAccountId: string | null
otherAccountsExpanded: boolean otherAccountsExpanded: boolean
} }
type AccountStoreActions = { type AccountStoreActions = {
setActiveAccount: (id: string) => void setActiveAccountId: (id: string | null) => void
setOtherAccountsExpanded: (expanded: boolean) => void setOtherAccountsExpanded: (expanded: boolean) => void
toggleOtherAccountsExpanded: () => 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>()( export const useAccountStore = create<AccountStoreState & AccountStoreActions>()(
persist( persist(
(set) => ({ (set) => ({
activeAccountId: DEFAULT_ACCOUNT_ID, activeAccountId: null,
otherAccountsExpanded: true, otherAccountsExpanded: true,
setActiveAccount: (id) => set({ activeAccountId: id }), setActiveAccountId: (id) => set({ activeAccountId: id }),
setOtherAccountsExpanded: (expanded) => setOtherAccountsExpanded: (expanded) =>
set({ otherAccountsExpanded: expanded }), set({ otherAccountsExpanded: expanded }),
toggleOtherAccountsExpanded: () => toggleOtherAccountsExpanded: () =>
set((s) => ({ otherAccountsExpanded: !s.otherAccountsExpanded })), set((s) => ({ otherAccountsExpanded: !s.otherAccountsExpanded })),
signOutAll: () =>
set({ activeAccountId: DEFAULT_ACCOUNT_ID, otherAccountsExpanded: true }),
}), }),
{ {
name: "ultimail-accounts", name: 'ultimail-accounts',
storage: debouncedPersistJSONStorage, storage: debouncedPersistJSONStorage,
partialize: (s) => ({ partialize: (s) => ({
activeAccountId: s.activeAccountId, 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 { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage" 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 = { type MailStoreState = {
readOverrides: Record<string, boolean>
starredIds: string[]
importantIds: string[]
labelEdits: LabelEditState
hiddenEmailIds: string[]
seenEmailIds: string[] seenEmailIds: string[]
/** Ids marqués comme non-spam (réintégration boîte de réception dans lUI). */
notSpamEmailIds: string[]
recentMoveTargets: string[] recentMoveTargets: string[]
/** Dernières boîtes visitées (clés `mailNavVisitKey`), la plus récente en tête. */
recentFolderVisits: string[] recentFolderVisits: string[]
} }
type MailStoreActions = { 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 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 pushRecentMoveTarget: (targetId: string) => void
pushRecentFolderVisit: (visitKey: string) => void pushRecentFolderVisit: (visitKey: string) => void
} }
@ -53,115 +19,10 @@ type MailStoreActions = {
export const useMailStore = create<MailStoreState & MailStoreActions>()( export const useMailStore = create<MailStoreState & MailStoreActions>()(
persist( persist(
(set) => ({ (set) => ({
readOverrides: {},
starredIds: [],
importantIds: [],
labelEdits: { additions: {}, removals: {} },
hiddenEmailIds: [],
seenEmailIds: [], seenEmailIds: [],
notSpamEmailIds: [],
recentMoveTargets: [], recentMoveTargets: [],
recentFolderVisits: [], 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) => markSeen: (id) =>
set((s) => ({ set((s) => ({
seenEmailIds: s.seenEmailIds.includes(id) seenEmailIds: s.seenEmailIds.includes(id)
@ -169,8 +30,6 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
: [...s.seenEmailIds, id], : [...s.seenEmailIds, id],
})), })),
resetHidden: () => set({ hiddenEmailIds: [] }),
pushRecentMoveTarget: (targetId) => pushRecentMoveTarget: (targetId) =>
set((s) => { set((s) => {
const MAX = 5 const MAX = 5
@ -188,16 +47,14 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
{ {
name: "ultimail-mail-state", name: "ultimail-mail-state",
storage: debouncedPersistJSONStorage, storage: debouncedPersistJSONStorage,
version: 3, version: 4,
migrate: (persisted, version) => { migrate: (persisted) => {
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] } const state = persisted as Record<string, unknown>
if (version < 2) { return {
return { ...state, recentFolderVisits: [], notSpamEmailIds: [] } 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 { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import type { Email } from "@/lib/email-data" import type { Email } from "@/lib/email-data"
export type ScheduleSendPayload = { export interface OutboxEntry {
sendAtIso: string id: string
to: { name: string; email: string }[] account_id: string
status: "queued" | "scheduled" | "sending" | "sent" | "failed" | "cancelled"
subject: string subject: string
previewText: string to: { name: string; address: string }[]
bodyHtml: string scheduled_at?: string
created_at: string
} }
type ScheduledStoreState = { type ScheduledStoreState = {
scheduledEmails: Email[] scheduledEmails: OutboxEntry[]
snoozedEmails: Email[] 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 = { type ScheduledStoreActions = {
createScheduledSend: (payload: ScheduleSendPayload) => { id: string } addScheduledEmail: (entry: OutboxEntry) => void
deleteScheduledSend: (id: string) => void updateScheduledStatus: (id: string, status: OutboxEntry["status"]) => void
archiveScheduledSend: (id: string) => void removeScheduled: (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). */
snoozeMailboxEmail: (row: Email) => 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 restoreSnoozedToInbox: (row: Email) => void
} }
export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActions>()( export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActions>()(
persist( persist(
(set, get) => ({ (set) => ({
scheduledEmails: [], scheduledEmails: [],
snoozedEmails: [], snoozedEmails: [],
sentPlaceholderEmails: [],
createScheduledSend: (payload) => { addScheduledEmail: (entry) =>
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,
}
set((s) => ({ set((s) => ({
scheduledEmails: [row, ...s.scheduledEmails.filter((e) => e.id !== id)], scheduledEmails: [entry, ...s.scheduledEmails.filter((e) => e.id !== entry.id)],
}))
return { id }
},
deleteScheduledSend: (id) =>
set((s) => ({
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
})), })),
archiveScheduledSend: (id) => updateScheduledStatus: (id, status) =>
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) =>
set((s) => ({ set((s) => ({
scheduledEmails: s.scheduledEmails.map((e) => scheduledEmails: s.scheduledEmails.map((e) =>
e.id === id ? { ...e, scheduledSendAt: sendAtIso } : e e.id === id ? { ...e, status } : e
), ),
})), })),
markScheduledReadState: (id, read) => removeScheduled: (id) =>
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) =>
set((s) => ({ set((s) => ({
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id), scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
})), })),
@ -208,36 +74,21 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
}), }),
restoreSnoozedToInbox: (row) => restoreSnoozedToInbox: (row) =>
set((s) => { set((s) => ({
const nextSnoozed = s.snoozedEmails.filter((e) => e.id !== row.id) snoozedEmails: 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),
],
}
}),
}), }),
{ {
name: "ultimail-scheduled-state", name: "ultimail-scheduled-state",
storage: debouncedPersistJSONStorage, 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" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // 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": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@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/core": "^3.23.2",
"@tiptap/extension-color": "^3.23.2", "@tiptap/extension-color": "^3.23.2",
"@tiptap/extension-link": "^3.23.2", "@tiptap/extension-link": "^3.23.2",
@ -73,6 +75,7 @@
"embla-carousel-react": "8.6.0", "embla-carousel-react": "8.6.0",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"fuse.js": "^7.3.0", "fuse.js": "^7.3.0",
"idb": "^8.0.3",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"next": "16.2.6", "next": "16.2.6",

View File

@ -116,6 +116,12 @@ importers:
'@radix-ui/react-tooltip': '@radix-ui/react-tooltip':
specifier: 1.2.8 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) 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': '@tiptap/core':
specifier: ^3.23.2 specifier: ^3.23.2
version: 3.23.2(@tiptap/pm@3.23.2) version: 3.23.2(@tiptap/pm@3.23.2)
@ -176,6 +182,9 @@ importers:
fuse.js: fuse.js:
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0 version: 7.3.0
idb:
specifier: ^8.0.3
version: 8.0.3
input-otp: input-otp:
specifier: 1.4.2 specifier: 1.4.2
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 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': '@tailwindcss/postcss@4.2.0':
resolution: {integrity: sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==} 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': '@tiptap/core@3.23.2':
resolution: {integrity: sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==} resolution: {integrity: sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==}
peerDependencies: peerDependencies:
@ -1679,6 +1705,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 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: input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies: peerDependencies:
@ -3084,6 +3113,23 @@ snapshots:
postcss: 8.5.6 postcss: 8.5.6
tailwindcss: 4.2.0 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)': '@tiptap/core@3.23.2(@tiptap/pm@3.23.2)':
dependencies: dependencies:
'@tiptap/pm': 3.23.2 '@tiptap/pm': 3.23.2
@ -3468,6 +3514,8 @@ snapshots:
graceful-fs@4.2.11: {} 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): input-otp@1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4

File diff suppressed because one or more lines are too long