feat(api): offline-first mail sync w/ TanStack Query
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
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:
parent
9d0fb2766b
commit
c87670e90f
@ -4,6 +4,7 @@ import { Analytics } from '@vercel/analytics/next'
|
||||
import './globals.css'
|
||||
import { ThemeInitScript } from '@/components/theme-init-script'
|
||||
import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
||||
import { QueryProvider } from '@/lib/api/query-provider'
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] });
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||
@ -32,7 +33,9 @@ export default function RootLayout({
|
||||
<html lang="fr" suppressHydrationWarning className="h-dvh max-h-dvh overflow-hidden">
|
||||
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
|
||||
<ThemeInitScript />
|
||||
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
||||
<QueryProvider>
|
||||
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
||||
</QueryProvider>
|
||||
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { UserAccount } from "@/lib/accounts/types"
|
||||
import type { ApiMailAccount } from "@/lib/api/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface AccountAvatarProps {
|
||||
account: UserAccount
|
||||
account: Pick<ApiMailAccount, "name" | "email">
|
||||
size?: "sm" | "md" | "lg"
|
||||
className?: string
|
||||
}
|
||||
@ -22,24 +21,9 @@ export function AccountAvatar({
|
||||
size = "md",
|
||||
className,
|
||||
}: AccountAvatarProps) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
const initial = senderInitial(account.displayName)
|
||||
const color = avatarColor(account.displayName)
|
||||
|
||||
if (account.avatarUrl && !imageFailed) {
|
||||
return (
|
||||
<img
|
||||
src={account.avatarUrl}
|
||||
alt=""
|
||||
className={cn(
|
||||
"shrink-0 rounded-full object-cover",
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const displayName = account.name || account.email
|
||||
const initial = senderInitial(displayName)
|
||||
const color = avatarColor(displayName)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -1,23 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react"
|
||||
import { Icon, addCollection } from "@iconify/react"
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
|
||||
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { MOCK_USER_ACCOUNTS, STORAGE_USAGE } from "@/lib/accounts/mock-accounts"
|
||||
import type { UserAccount } from "@/lib/accounts/types"
|
||||
import type { ApiMailAccount } from "@/lib/api/types"
|
||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
useAccountStore,
|
||||
useActiveAccount,
|
||||
useSignOutAll,
|
||||
} from "@/lib/stores/account-store"
|
||||
addCollection(mdiIcons)
|
||||
|
||||
interface AccountSwitcherDropdownProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Clicks inside this node (e.g. avatar trigger) do not close the panel. */
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
@ -25,7 +22,7 @@ function AccountRow({
|
||||
account,
|
||||
onSelect,
|
||||
}: {
|
||||
account: UserAccount
|
||||
account: ApiMailAccount
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
@ -37,7 +34,7 @@ function AccountRow({
|
||||
<AccountAvatar account={account} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{account.displayName}
|
||||
{account.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
|
||||
</div>
|
||||
@ -54,13 +51,16 @@ export function AccountSwitcherDropdown({
|
||||
const activeAccount = useActiveAccount()
|
||||
const activeAccountId = useAccountStore((s) => s.activeAccountId)
|
||||
const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded)
|
||||
const setActiveAccount = useAccountStore((s) => s.setActiveAccount)
|
||||
const setActiveAccountId = useAccountStore((s) => s.setActiveAccountId)
|
||||
const toggleOtherAccountsExpanded = useAccountStore(
|
||||
(s) => s.toggleOtherAccountsExpanded,
|
||||
)
|
||||
const signOutAll = useAccountStore((s) => s.signOutAll)
|
||||
const signOutAll = useSignOutAll()
|
||||
|
||||
const otherAccounts = MOCK_USER_ACCOUNTS.filter((a) => a.id !== activeAccountId)
|
||||
const { data: accounts } = useMailAccounts()
|
||||
const otherAccounts = (accounts ?? []).filter((a) => a.id !== activeAccountId)
|
||||
|
||||
const firstName = activeAccount?.name.split(" ")[0] ?? ""
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@ -83,10 +83,10 @@ export function AccountSwitcherDropdown({
|
||||
}
|
||||
}, [open, onOpenChange, containerRef])
|
||||
|
||||
if (!open) return null
|
||||
if (!open || !activeAccount) return null
|
||||
|
||||
const handleSelectAccount = (id: string) => {
|
||||
setActiveAccount(id)
|
||||
setActiveAccountId(id)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@ -97,7 +97,6 @@ export function AccountSwitcherDropdown({
|
||||
aria-label="Comptes connectés"
|
||||
className="absolute right-0 top-12 z-50 w-[min(100vw-1rem,356px)] overflow-hidden rounded-[28px] bg-mail-surface-elevated text-foreground shadow-[0_4px_16px_rgba(0,0,0,0.35)] border border-border"
|
||||
>
|
||||
{/* Current account header */}
|
||||
<div className="relative px-4 pb-3 pt-4">
|
||||
<p className="truncate pr-8 text-center text-sm text-foreground">
|
||||
{activeAccount.email}
|
||||
@ -121,19 +120,18 @@ export function AccountSwitcherDropdown({
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-3 text-xl font-normal text-foreground">
|
||||
Bonjour {activeAccount.firstName} !
|
||||
Bonjour {firstName} !
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary"
|
||||
>
|
||||
Gérer votre compte Google
|
||||
Gérer votre compte
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other accounts + actions */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
|
||||
<button
|
||||
@ -191,19 +189,6 @@ export function AccountSwitcherDropdown({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage */}
|
||||
<div className="mt-3 flex items-center gap-2 rounded-full border border-border bg-mail-surface px-4 py-2.5">
|
||||
<Icon
|
||||
icon="mdi:alert-circle"
|
||||
className="size-5 shrink-0 text-[#e8710a]"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm text-foreground">
|
||||
{STORAGE_USAGE.percentUsed} % utilisé(s) sur {STORAGE_USAGE.totalLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer links */}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-1 pb-2 text-center text-xs text-muted-foreground">
|
||||
<button type="button" className="hover:underline">
|
||||
Règles de confidentialité
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useComposeActions } from "@/lib/compose-context"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import {
|
||||
findContactByEmail,
|
||||
parseDisplayNameToNameParts,
|
||||
@ -55,7 +56,7 @@ export function ContactHoverCard({
|
||||
side = "bottom",
|
||||
}: ContactHoverCardProps) {
|
||||
const { openComposeWithInitial } = useComposeActions()
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
const { contacts } = useContactsList()
|
||||
const openContactDetail = useContactsStore((s) => s.openContactDetail)
|
||||
const openCreateContact = useContactsStore((s) => s.openCreateContact)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@ -1,118 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import {
|
||||
CONTACTS_HEADING_TEXT,
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PAGE_CARD_CLASS,
|
||||
CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS,
|
||||
CONTACTS_PAGE_LINK_BTN_CLASS,
|
||||
CONTACTS_PAGE_SECTION_TITLE_CLASS,
|
||||
CONTACTS_PRIMARY_BTN_CLASS,
|
||||
} from "@/lib/contacts-chrome-classes"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AddCoordinatesView() {
|
||||
const { getCoordinateSuggestions, updateContact } = useContactsStore()
|
||||
const suggestions = useMemo(() => getCoordinateSuggestions(), [getCoordinateSuggestions])
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
|
||||
|
||||
const visible = suggestions.filter((s) => !dismissed.has(s.contact.id))
|
||||
|
||||
function handleAdd(contactId: string, field: string, value: string) {
|
||||
updateContact(contactId, { [field]: value })
|
||||
setDismissed((s) => new Set(s).add(contactId))
|
||||
}
|
||||
|
||||
function handleIgnore(contactId: string) {
|
||||
setDismissed((s) => new Set(s).add(contactId))
|
||||
}
|
||||
|
||||
function handleAddAll() {
|
||||
for (const s of visible) {
|
||||
updateContact(s.contact.id, { [s.suggestedField]: s.suggestedValue })
|
||||
}
|
||||
setDismissed(new Set(suggestions.map((s) => s.contact.id)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
|
||||
Ajouter des coordonnées ({visible.length})
|
||||
Ajouter des coordonnées (0)
|
||||
</h3>
|
||||
{visible.length > 0 && (
|
||||
<Button onClick={handleAddAll} className={CONTACTS_PRIMARY_BTN_CLASS}>
|
||||
Ajouter tous les détails
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 && (
|
||||
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
||||
Aucune suggestion disponible
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{visible.map((suggestion) => {
|
||||
const { contact, suggestedField, suggestedValue } = suggestion
|
||||
const displayName = fullContactDisplayName(contact)
|
||||
const name = displayName || contact.emails[0]?.value || "?"
|
||||
const color = avatarColor(name)
|
||||
const initial = senderInitial(name)
|
||||
|
||||
return (
|
||||
<div key={contact.id} className={CONTACTS_PAGE_CARD_CLASS}>
|
||||
<p className={cn("mb-2 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Contact à modifier</p>
|
||||
<div className="flex items-start gap-3">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}>{name}</p>
|
||||
{contact.emails[0] && (
|
||||
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{contact.emails[0].value}</p>
|
||||
)}
|
||||
{contact.phones[0] && (
|
||||
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>
|
||||
{contact.phones[0].value} ({contact.phones[0].label})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS}>
|
||||
<p className={cn("text-xs font-medium", CONTACTS_MUTED_TEXT)}>Détails à ajouter</p>
|
||||
<p className={cn("mt-1 text-sm", CONTACTS_HEADING_TEXT)}>{suggestedValue}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<button type="button" onClick={() => handleIgnore(contact.id)} className={CONTACTS_PAGE_LINK_BTN_CLASS}>
|
||||
Ignorer
|
||||
</button>
|
||||
<Button
|
||||
onClick={() => handleAdd(contact.id, suggestedField, suggestedValue)}
|
||||
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
|
||||
Aucune suggestion disponible
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,8 +8,10 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { parseBulkContactText } from "@/lib/contacts/import-parsers"
|
||||
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||
import type { FullContact } from "@/lib/contacts/types"
|
||||
import {
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PAGE_LINK_BTN_CLASS,
|
||||
@ -25,13 +27,28 @@ interface BulkCreateDialogProps {
|
||||
|
||||
export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) {
|
||||
const [input, setInput] = useState("")
|
||||
const addContacts = useContactsStore((s) => s.addContacts)
|
||||
const createContactMutation = useCreateContact()
|
||||
|
||||
function handleCreate() {
|
||||
const parsed = parseBulkContactText(input)
|
||||
if (parsed.length === 0) return
|
||||
|
||||
addContacts(parsed)
|
||||
for (const partial of parsed) {
|
||||
const fullContact: FullContact = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...partial,
|
||||
firstName: partial.firstName ?? "",
|
||||
lastName: partial.lastName ?? "",
|
||||
emails: partial.emails ?? [],
|
||||
phones: partial.phones ?? [],
|
||||
}
|
||||
createContactMutation.mutate({
|
||||
bookId: "default",
|
||||
contact: fullContactToApiContact(fullContact),
|
||||
})
|
||||
}
|
||||
setInput("")
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@ -41,8 +41,11 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import type { FullContact } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -112,7 +115,9 @@ interface ContactCreatePageProps {
|
||||
}
|
||||
|
||||
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) {
|
||||
const { contacts, addContact, updateContact } = useContactsStore()
|
||||
const { contacts } = useContactsList()
|
||||
const createContactMutation = useCreateContact()
|
||||
const updateContactMutation = useUpdateContact()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const availableLabels = labelRows.filter((r) => r.enabled !== false)
|
||||
const [starred, setStarred] = useState(false)
|
||||
@ -208,10 +213,37 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
||||
}
|
||||
|
||||
if (mode === "create") {
|
||||
const id = addContact(payload)
|
||||
onSaved(id)
|
||||
const tempId = crypto.randomUUID()
|
||||
const fullContact: FullContact = {
|
||||
id: tempId,
|
||||
...payload,
|
||||
firstName: payload.firstName ?? "",
|
||||
lastName: payload.lastName ?? "",
|
||||
emails: payload.emails ?? [],
|
||||
phones: payload.phones ?? [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
createContactMutation.mutate(
|
||||
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
|
||||
{ onSuccess: (created) => onSaved(created?.uid ?? tempId) },
|
||||
)
|
||||
onSaved(tempId)
|
||||
} else if (contactId) {
|
||||
updateContact(contactId, payload)
|
||||
const fullContact: FullContact = {
|
||||
id: contactId,
|
||||
...payload,
|
||||
firstName: payload.firstName ?? "",
|
||||
lastName: payload.lastName ?? "",
|
||||
emails: payload.emails ?? [],
|
||||
phones: payload.phones ?? [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
updateContactMutation.mutate({
|
||||
path: contactId,
|
||||
contact: fullContactToApiContact(fullContact),
|
||||
})
|
||||
onSaved(contactId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ import {
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
@ -53,7 +55,9 @@ interface ContactDetailPageProps {
|
||||
}
|
||||
|
||||
export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPageProps) {
|
||||
const { contacts, softDeleteContact } = useContactsStore()
|
||||
const { contacts } = useContactsList()
|
||||
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
|
||||
const deleteContactMutation = useDeleteContact()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const contact = contacts.find((c) => c.id === contactId)
|
||||
|
||||
@ -72,7 +76,8 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
|
||||
const primaryEmail = contact.emails[0]?.value
|
||||
|
||||
function handleDelete() {
|
||||
softDeleteContact(contactId, "Supprimé manuellement")
|
||||
if (contact) softDeleteContact(contact, "Supprimé manuellement")
|
||||
deleteContactMutation.mutate({ path: contactId })
|
||||
onBack()
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import {
|
||||
Users,
|
||||
Clock,
|
||||
@ -32,7 +33,9 @@ import {
|
||||
CONTACTS_SIDEBAR_CLASS,
|
||||
} from "@/lib/contacts-chrome-classes"
|
||||
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import type { ContactsPageView } from "./contacts-app-shell"
|
||||
|
||||
@ -63,8 +66,12 @@ export function ContactsSidebar({
|
||||
onBulkCreate,
|
||||
onSelectLabel,
|
||||
}: ContactsSidebarProps) {
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
const mergeSuggestionCount = useContactsStore((s) => s.getMergeSuggestions().length)
|
||||
const { contacts } = useContactsList()
|
||||
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
||||
const mergeSuggestionCount = useMemo(
|
||||
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)).length,
|
||||
[contacts, ignoredMergePairs]
|
||||
)
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
|
||||
const [labelInput, setLabelInput] = useState("")
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import { searchContacts } from "@/lib/contacts/fuzzy-search"
|
||||
import { printContacts } from "@/lib/contacts/print-contacts"
|
||||
@ -53,8 +55,9 @@ interface ContactsTableProps {
|
||||
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
|
||||
const { visibleColumns, columnLabels } = useContactsTableColumns()
|
||||
const gridStyle = contactsTableGridStyle(visibleColumns)
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
const { contacts } = useContactsList()
|
||||
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
|
||||
const deleteContactMutation = useDeleteContact()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
@ -144,7 +147,8 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
||||
function handleDeleteSelected() {
|
||||
if (selectionCount === 0) return
|
||||
for (const contact of selectedContacts) {
|
||||
softDeleteContact(contact.id, "Supprimé manuellement")
|
||||
softDeleteContact(contact, "Supprimé manuellement")
|
||||
deleteContactMutation.mutate({ path: contact.id })
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
@ -9,8 +9,10 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Info } from "lucide-react"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { parseContactFile } from "@/lib/contacts/import-parsers"
|
||||
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||
import type { FullContact } from "@/lib/contacts/types"
|
||||
import {
|
||||
CONTACTS_HEADING_TEXT,
|
||||
CONTACTS_MUTED_TEXT,
|
||||
@ -27,7 +29,7 @@ interface ImportDialogProps {
|
||||
|
||||
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const addContacts = useContactsStore((s) => s.addContacts)
|
||||
const createContactMutation = useCreateContact()
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||
const [previewCount, setPreviewCount] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@ -76,11 +78,26 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
||||
setError(null)
|
||||
try {
|
||||
const parsed = await parseContactFile(pendingFile)
|
||||
const count = addContacts(parsed)
|
||||
if (count === 0) {
|
||||
if (parsed.length === 0) {
|
||||
setError("Aucun contact importé.")
|
||||
return
|
||||
}
|
||||
for (const partial of parsed) {
|
||||
const fullContact: FullContact = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...partial,
|
||||
firstName: partial.firstName ?? "",
|
||||
lastName: partial.lastName ?? "",
|
||||
emails: partial.emails ?? [],
|
||||
phones: partial.phones ?? [],
|
||||
}
|
||||
createContactMutation.mutate({
|
||||
bookId: "default",
|
||||
contact: fullContactToApiContact(fullContact),
|
||||
})
|
||||
}
|
||||
handleOpenChange(false)
|
||||
} catch {
|
||||
setError("L'import a échoué. Vérifiez le format du fichier.")
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore, type MergeSuggestion } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { useMergeDuplicates } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { findDuplicatePairs, type DuplicateMatchReason } from "@/lib/contacts/duplicate-detection"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { fullContactDisplayName, type MergeSuggestion } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { AddCoordinatesView } from "./add-coordinates-view"
|
||||
import {
|
||||
@ -31,26 +33,20 @@ const REASON_LABELS: Record<DuplicateMatchReason, string> = {
|
||||
|
||||
export function MergeDuplicatesView() {
|
||||
const [subView, setSubView] = useState<SubView>("merge")
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
const { contacts } = useContactsList()
|
||||
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
||||
const mergeContacts = useContactsStore((s) => s.mergeContacts)
|
||||
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
|
||||
const getCoordinateSuggestions = useContactsStore((s) => s.getCoordinateSuggestions)
|
||||
const mergeDuplicatesMutation = useMergeDuplicates()
|
||||
|
||||
const mergeSuggestions = useMemo(
|
||||
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)),
|
||||
[contacts, ignoredMergePairs]
|
||||
)
|
||||
|
||||
const coordSuggestions = useMemo(
|
||||
() => getCoordinateSuggestions(),
|
||||
[getCoordinateSuggestions, contacts]
|
||||
)
|
||||
|
||||
const [mergingAll, setMergingAll] = useState(false)
|
||||
|
||||
function handleMerge(suggestion: MergeSuggestion) {
|
||||
mergeContacts(suggestion.contactA.id, suggestion.contactB.id)
|
||||
function handleMerge(_suggestion: MergeSuggestion) {
|
||||
mergeDuplicatesMutation.mutate({ bookId: "default" })
|
||||
}
|
||||
|
||||
function handleIgnore(suggestion: MergeSuggestion) {
|
||||
@ -59,20 +55,10 @@ export function MergeDuplicatesView() {
|
||||
|
||||
function handleMergeAll() {
|
||||
setMergingAll(true)
|
||||
try {
|
||||
let pairs = findDuplicatePairs(
|
||||
useContactsStore.getState().contacts,
|
||||
new Set(useContactsStore.getState().ignoredMergePairs)
|
||||
)
|
||||
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)
|
||||
}
|
||||
mergeDuplicatesMutation.mutate(
|
||||
{ bookId: "default" },
|
||||
{ onSettled: () => setMergingAll(false) },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -108,9 +94,6 @@ export function MergeDuplicatesView() {
|
||||
className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
|
||||
>
|
||||
Ajouter des coordonnées
|
||||
{coordSuggestions.length > 0 && (
|
||||
<span className="ml-2 text-xs">({coordSuggestions.length})</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import {
|
||||
@ -25,6 +26,7 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
export function TrashView() {
|
||||
const { deletedContacts, restoreContact, emptyTrash } = useContactsStore()
|
||||
const deleteContactMutation = useDeleteContact()
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString("fr-FR", {
|
||||
@ -112,7 +114,7 @@ export function TrashView() {
|
||||
Restaurer
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => useContactsStore.getState().deleteContact(contact.id)}
|
||||
onClick={() => deleteContactMutation.mutate({ path: contact.id })}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -17,9 +17,10 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { emails as allEmails } from "@/lib/email-data"
|
||||
import { useMailSearch } from "@/lib/api/hooks/use-mail-queries"
|
||||
import { useComposeActions } from "@/lib/compose-context"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import {
|
||||
@ -66,29 +67,26 @@ function formatEmailDate(iso: string): string {
|
||||
}
|
||||
|
||||
export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
||||
const { contacts, setView, showContactsList, closePanel } = useContactsStore()
|
||||
const { setView, showContactsList, closePanel } = useContactsStore()
|
||||
const { contacts } = useContactsList()
|
||||
const { openComposeWithInitial } = useComposeActions()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
|
||||
const contact = contacts.find((c) => c.id === contactId)
|
||||
|
||||
const primaryContactEmail = contact?.emails[0]?.value
|
||||
const { data: searchResult } = useMailSearch(
|
||||
primaryContactEmail ? { from: primaryContactEmail } : null
|
||||
)
|
||||
const recentInteractions = useMemo(() => {
|
||||
if (!contact) return []
|
||||
const contactEmails = new Set(
|
||||
contact.emails.map((e) => e.value.toLowerCase()).filter(Boolean)
|
||||
)
|
||||
if (contactEmails.size === 0) return []
|
||||
|
||||
return allEmails
|
||||
.filter((email) => {
|
||||
const se = email.senderEmail?.toLowerCase()
|
||||
if (se && contactEmails.has(se)) return true
|
||||
const senderLower = email.sender.toLowerCase()
|
||||
return [...contactEmails].some((ce) => senderLower.includes(ce.split("@")[0] ?? ""))
|
||||
})
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 10)
|
||||
}, [contact])
|
||||
if (!searchResult?.data) return []
|
||||
return searchResult.data.slice(0, 10).map((msg) => ({
|
||||
id: msg.id,
|
||||
subject: msg.subject,
|
||||
preview: msg.snippet,
|
||||
date: msg.date,
|
||||
}))
|
||||
}, [searchResult])
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
|
||||
@ -41,7 +41,10 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||
import { fullContactDisplayName, type FullContact } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import {
|
||||
@ -127,15 +130,15 @@ interface ContactFormViewProps {
|
||||
|
||||
export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
const {
|
||||
contacts,
|
||||
addContact,
|
||||
updateContact,
|
||||
setView,
|
||||
showContactsList,
|
||||
closePanel,
|
||||
createDraft,
|
||||
clearCreateDraft,
|
||||
} = useContactsStore()
|
||||
const { contacts } = useContactsList()
|
||||
const createContactMutation = useCreateContact()
|
||||
const updateContactMutation = useUpdateContact()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const [starred, setStarred] = useState(false)
|
||||
const [nameExpanded, setNameExpanded] = useState(false)
|
||||
@ -309,10 +312,37 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
}
|
||||
|
||||
if (mode === "create") {
|
||||
const id = addContact(payload)
|
||||
setView("view", id)
|
||||
const tempId = crypto.randomUUID()
|
||||
const fullContact: FullContact = {
|
||||
id: tempId,
|
||||
...payload,
|
||||
firstName: payload.firstName ?? "",
|
||||
lastName: payload.lastName ?? "",
|
||||
emails: payload.emails ?? [],
|
||||
phones: payload.phones ?? [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
createContactMutation.mutate(
|
||||
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
|
||||
{ onSuccess: (created) => setView("view", created?.uid ?? tempId) },
|
||||
)
|
||||
setView("view", tempId)
|
||||
} else if (contactId) {
|
||||
updateContact(contactId, payload)
|
||||
const fullContact: FullContact = {
|
||||
id: contactId,
|
||||
...payload,
|
||||
firstName: payload.firstName ?? "",
|
||||
lastName: payload.lastName ?? "",
|
||||
emails: payload.emails ?? [],
|
||||
phones: payload.phones ?? [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
updateContactMutation.mutate({
|
||||
path: contactId,
|
||||
contact: fullContactToApiContact(fullContact),
|
||||
})
|
||||
setView("view", contactId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { Search, ExternalLink, X, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { searchContacts } from "@/lib/contacts/fuzzy-search"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import {
|
||||
@ -26,7 +27,6 @@ import { ContactsPanelLogo } from "./contacts-panel-logo"
|
||||
|
||||
export function ContactsListView() {
|
||||
const {
|
||||
contacts,
|
||||
searchMode,
|
||||
searchQuery,
|
||||
setSearchMode,
|
||||
@ -35,6 +35,7 @@ export function ContactsListView() {
|
||||
showContactsList,
|
||||
closePanel,
|
||||
} = useContactsStore()
|
||||
const { contacts } = useContactsList()
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
|
||||
@ -1,13 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { mailLabelShouldShowInListStrip } from "@/components/gmail/mail-label-pills"
|
||||
import { EmailView } from "@/components/gmail/email-view"
|
||||
import { LABEL_PICKER_EXCLUDE } from "@/lib/mail-list/label-actions"
|
||||
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import type { ApiMessageSummary } from "@/lib/api/types"
|
||||
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
|
||||
import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
|
||||
import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection"
|
||||
|
||||
function emailToApiSummary(email: Email): ApiMessageSummary {
|
||||
const flags: string[] = []
|
||||
if (email.read) flags.push("read")
|
||||
if (email.starred) flags.push("starred")
|
||||
if (email.important) flags.push("important")
|
||||
if (email.spam) flags.push("spam")
|
||||
return {
|
||||
id: email.id,
|
||||
message_id: email.id,
|
||||
thread_id: email.threadHeadId,
|
||||
account_id: "",
|
||||
subject: email.subject,
|
||||
from: [{ name: email.sender, address: email.senderEmail ?? "" }],
|
||||
to: [],
|
||||
date: email.date,
|
||||
snippet: email.preview,
|
||||
flags,
|
||||
labels: email.labels ?? [],
|
||||
has_attachments: email.hasAttachment ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
type EmailListEmailViewPaneProps = {
|
||||
data: EmailListData
|
||||
reading: EmailListReading
|
||||
@ -17,37 +41,31 @@ type EmailListEmailViewPaneProps = {
|
||||
export function EmailListEmailViewPane({
|
||||
data,
|
||||
reading,
|
||||
selection,
|
||||
selection: _selection,
|
||||
}: EmailListEmailViewPaneProps) {
|
||||
const {
|
||||
openEmail,
|
||||
openEmailThreadRoot,
|
||||
isSingleMessageView,
|
||||
handleNavigateToLabel,
|
||||
singleNotSpam,
|
||||
} = reading
|
||||
const { toggleStar } = selection
|
||||
const {
|
||||
starredEmails,
|
||||
listRowLabelBgByTextLower,
|
||||
sidebarNav,
|
||||
selectedFolder,
|
||||
} = data
|
||||
|
||||
if (!openEmail) return null
|
||||
const apiEmail = useMemo(
|
||||
() => (openEmail ? emailToApiSummary(openEmail) : null),
|
||||
[openEmail]
|
||||
)
|
||||
|
||||
if (!openEmail || !apiEmail) return null
|
||||
|
||||
return (
|
||||
<EmailView
|
||||
email={openEmail}
|
||||
threadRoot={openEmailThreadRoot}
|
||||
email={apiEmail}
|
||||
isSingleMessageView={isSingleMessageView}
|
||||
onToggleStar={toggleStar}
|
||||
isStarred={
|
||||
starredEmails.includes(threadStoreId(openEmail)) ||
|
||||
openEmail.starred
|
||||
}
|
||||
onNavigateToLabel={handleNavigateToLabel}
|
||||
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
|
||||
labelBgByText={listRowLabelBgByTextLower}
|
||||
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
||||
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
||||
|
||||
@ -72,7 +72,6 @@ import {
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { readXsMatches } from "@/hooks/use-xs"
|
||||
import type { LabelRowItem, FolderTreeNode } from "@/lib/sidebar-nav-data"
|
||||
import type { LabelEditState } from "@/lib/stores/mail-store"
|
||||
import {
|
||||
contextMenuTargetIdsForRow,
|
||||
formatScheduledDateTimeDisplay,
|
||||
|
||||
@ -144,7 +144,7 @@ export type EmailListToolbarProps = {
|
||||
tabUnseenSenderLineById: Record<string, string>
|
||||
handleCategoryInboxTabClick: (tabId: string) => void
|
||||
searchParams: SearchParams | null
|
||||
searchAccount: { email: string }
|
||||
searchAccount: { email: string } | null
|
||||
allEmails: Email[]
|
||||
setSearchFilter: (patch: Partial<SearchParams>) => void
|
||||
toggleSearchFilter: (key: keyof SearchParams, value: string) => void
|
||||
@ -1202,8 +1202,8 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
||||
<DropdownMenuItem onSelect={() => setSearchFilter({ from: "" })}>
|
||||
N'importe qui
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setSearchFilter({ from: searchAccount.email })}>
|
||||
De moi ({searchAccount.email})
|
||||
<DropdownMenuItem onSelect={() => setSearchFilter({ from: searchAccount?.email ?? "" })}>
|
||||
De moi ({searchAccount?.email})
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{Array.from(new Set(allEmails.map((e) => e.senderEmail).filter(Boolean))).slice(0, 8).map((addr) => (
|
||||
@ -1297,8 +1297,8 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
||||
<DropdownMenuItem onSelect={() => setSearchFilter({ to: "" })}>
|
||||
N'importe qui
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setSearchFilter({ to: searchAccount.email })}>
|
||||
À moi ({searchAccount.email})
|
||||
<DropdownMenuItem onSelect={() => setSearchFilter({ to: searchAccount?.email ?? "" })}>
|
||||
À moi ({searchAccount?.email})
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -8,12 +8,18 @@ import {
|
||||
useState,
|
||||
} from "react"
|
||||
import { useSearchParams, useRouter } from "next/navigation"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills"
|
||||
import { emails } from "@/lib/email-data"
|
||||
import { useMessages, useMailSearch } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
useUpdateFlags,
|
||||
useUpdateLabels,
|
||||
useDeleteMessage,
|
||||
} from "@/lib/api/hooks/use-mail-mutations"
|
||||
import type { ApiMessageSummary, PaginatedResponse } from "@/lib/api/types"
|
||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||
import {
|
||||
isListRowRead,
|
||||
isThreadHeadMessage,
|
||||
readStateTargets,
|
||||
} from "@/lib/mail-thread"
|
||||
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
||||
import { useMailStore } from "@/lib/stores/mail-store"
|
||||
@ -24,11 +30,7 @@ import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
||||
import {
|
||||
emailMatchesFolder,
|
||||
emailMatchesInboxPrimaryTab,
|
||||
type MailNavFolderMaps,
|
||||
} from "@/lib/mail-folder-filter"
|
||||
import type { MailNavFolderMaps } from "@/lib/mail-folder-filter"
|
||||
import {
|
||||
getMailNavFolderLabel,
|
||||
inboxTabDisplayLabel,
|
||||
@ -45,7 +47,6 @@ import {
|
||||
buildSearchUrl,
|
||||
type SearchParams,
|
||||
} from "@/lib/mail-search/search-params"
|
||||
import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine"
|
||||
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
||||
import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
|
||||
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
|
||||
@ -53,18 +54,6 @@ import {
|
||||
useComposeActions,
|
||||
useComposeDrafts,
|
||||
} from "@/lib/compose-context"
|
||||
import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
|
||||
import {
|
||||
mergeEmailLabelEdits,
|
||||
mergeEmailNotSpam,
|
||||
} from "@/lib/label-edits"
|
||||
import type { LabelEditState } from "@/lib/stores/mail-store"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { useTouchNav } from "@/hooks/use-touch-nav"
|
||||
import {
|
||||
applyNavRenameToEdits,
|
||||
applyNavRemoveLabelToEdits,
|
||||
} from "@/lib/mail-list/label-actions"
|
||||
import {
|
||||
LIST_PAGE_SIZE,
|
||||
type EmailListProps,
|
||||
@ -75,9 +64,33 @@ import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
|
||||
import { attachmentsForEmailList } from "@/lib/attachment-display"
|
||||
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
|
||||
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
|
||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||
import { cleanSenderName } from "@/lib/sender-display"
|
||||
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { useTouchNav } from "@/hooks/use-touch-nav"
|
||||
import type { MessageSearchFilter } from "@/lib/api/types"
|
||||
|
||||
function apiMessageToEmail(msg: ApiMessageSummary): Email {
|
||||
const sender = msg.from[0]?.name || msg.from[0]?.address || ""
|
||||
const senderEmail = msg.from[0]?.address || ""
|
||||
return {
|
||||
id: msg.id,
|
||||
sender,
|
||||
senderEmail,
|
||||
subject: msg.subject,
|
||||
preview: msg.snippet,
|
||||
date: msg.date,
|
||||
read: msg.flags.includes("read"),
|
||||
starred: msg.flags.includes("starred"),
|
||||
important: msg.flags.includes("important"),
|
||||
spam: msg.labels.includes("spam"),
|
||||
hasAttachment: msg.has_attachments,
|
||||
labels: msg.labels,
|
||||
threadHeadId: msg.thread_id ?? msg.id,
|
||||
threadMessageIds: [msg.id],
|
||||
isThreadHead: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function useEmailListData({
|
||||
selectedFolder,
|
||||
@ -138,7 +151,6 @@ export function useEmailListData({
|
||||
const {
|
||||
scheduledEmails,
|
||||
snoozedEmails,
|
||||
sentPlaceholderEmails,
|
||||
requestDeleteScheduled,
|
||||
requestArchiveScheduled,
|
||||
requestSnoozeScheduled,
|
||||
@ -152,19 +164,100 @@ export function useEmailListData({
|
||||
|
||||
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
|
||||
|
||||
const allEmails = useMemo(
|
||||
() =>
|
||||
scheduledPersistHydrated
|
||||
? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails]
|
||||
: emails,
|
||||
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
|
||||
const accountId = searchAccount?.id
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const effectiveApiFolder = useMemo(() => {
|
||||
if (isSearchMode) return "__search__"
|
||||
if (selectedFolder === "scheduled" || selectedFolder === "snoozed") return "__local__"
|
||||
if (selectedFolder !== "inbox") return selectedFolder
|
||||
const tab = normalizeInboxTabSegment(inboxTab)
|
||||
if (tab === INBOX_ALL_TAB) return "inbox"
|
||||
return tab
|
||||
}, [selectedFolder, inboxTab, isSearchMode])
|
||||
|
||||
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
|
||||
if (!isSearchMode || !searchParams) return null
|
||||
return {
|
||||
q: searchParams.q || undefined,
|
||||
from: searchParams.from || undefined,
|
||||
label: searchParams.in !== "all" ? searchParams.in : undefined,
|
||||
account_id: accountId,
|
||||
date_from: searchParams.after || undefined,
|
||||
date_to: searchParams.before || undefined,
|
||||
has_attachment: searchParams.has.includes("attachment") ? true : undefined,
|
||||
}
|
||||
}, [isSearchMode, searchParams, accountId])
|
||||
|
||||
const messagesQuery = useMessages(
|
||||
effectiveApiFolder === "__search__" || effectiveApiFolder === "__local__"
|
||||
? "inbox"
|
||||
: effectiveApiFolder,
|
||||
accountId,
|
||||
listPage
|
||||
)
|
||||
|
||||
const searchQuery = useMailSearch(searchFilter)
|
||||
|
||||
const updateFlags = useUpdateFlags()
|
||||
const updateLabels = useUpdateLabels()
|
||||
const deleteMessage = useDeleteMessage()
|
||||
|
||||
const apiMessages: ApiMessageSummary[] = useMemo(() => {
|
||||
if (isSearchMode) return searchQuery.data?.data ?? []
|
||||
if (effectiveApiFolder === "__local__") return []
|
||||
return messagesQuery.data?.data ?? []
|
||||
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data])
|
||||
|
||||
const apiEmails: Email[] = useMemo(
|
||||
() => apiMessages.map(apiMessageToEmail),
|
||||
[apiMessages]
|
||||
)
|
||||
|
||||
const apiMessagesById = useMemo(
|
||||
() => new Map(apiMessages.map((m) => [m.id, m])),
|
||||
[apiMessages]
|
||||
)
|
||||
|
||||
const allEmails = useMemo(() => {
|
||||
if (selectedFolder === "scheduled" && scheduledPersistHydrated) {
|
||||
return scheduledEmails.map<Email>((entry) => ({
|
||||
id: entry.id,
|
||||
sender: entry.to[0]?.name ?? "Destinataire",
|
||||
senderEmail: entry.to[0]?.address,
|
||||
subject: entry.subject || "(Sans objet)",
|
||||
preview: "",
|
||||
body: "",
|
||||
date: entry.scheduled_at ?? entry.created_at,
|
||||
read: true,
|
||||
starred: false,
|
||||
important: false,
|
||||
labels: ["scheduled"],
|
||||
scheduledSendAt: entry.scheduled_at,
|
||||
scheduledToName: entry.to[0]?.name,
|
||||
}))
|
||||
}
|
||||
if (selectedFolder === "snoozed" && scheduledPersistHydrated) {
|
||||
return snoozedEmails
|
||||
}
|
||||
return apiEmails
|
||||
}, [
|
||||
selectedFolder,
|
||||
scheduledPersistHydrated,
|
||||
scheduledEmails,
|
||||
snoozedEmails,
|
||||
apiEmails,
|
||||
])
|
||||
|
||||
const emailById = useMemo(
|
||||
() => new Map(allEmails.map((e) => [e.id, e])),
|
||||
[allEmails]
|
||||
)
|
||||
|
||||
const isLoading = isSearchMode ? searchQuery.isLoading : messagesQuery.isLoading
|
||||
const error = isSearchMode ? searchQuery.error : messagesQuery.error
|
||||
const isFetching = isSearchMode ? searchQuery.isFetching : messagesQuery.isFetching
|
||||
|
||||
const sidebarNav = useSidebarNav()
|
||||
const navMaps = useMemo<MailNavFolderMaps>(
|
||||
() => ({
|
||||
@ -255,45 +348,105 @@ export function useEmailListData({
|
||||
pruneInlineComposesToOpenThread,
|
||||
])
|
||||
|
||||
const starredEmails = useMailStore((s) => s.starredIds)
|
||||
const importantEmails = useMailStore((s) => s.importantIds)
|
||||
const readOverrides = useMailStore((s) => s.readOverrides)
|
||||
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
|
||||
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
|
||||
const density = useMailSettingsStore((s) => s.density)
|
||||
const isMd = useIsMd()
|
||||
const labelEdits = useMailStore((s) => s.labelEdits)
|
||||
const mailActions = useRef(useMailStore.getState()).current
|
||||
|
||||
const readOverrides = useMemo<Record<string, boolean>>(() => ({}), [])
|
||||
const starredEmails = useMemo<string[]>(() => [], [])
|
||||
const importantEmails = useMemo<string[]>(() => [], [])
|
||||
const labelEdits = useMemo(() => ({ additions: {} as Record<string, string[]>, removals: {} as Record<string, string[]> }), [])
|
||||
const hiddenEmailIds = useMemo<string[]>(() => [], [])
|
||||
const notSpamEmailIds = useMemo<string[]>(() => [], [])
|
||||
|
||||
const setReadOverrides = useCallback(
|
||||
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
|
||||
const current = useMailStore.getState().readOverrides
|
||||
const next = updater(current)
|
||||
if (next !== current) mailActions.setReadOverrides(next)
|
||||
const changes = updater({})
|
||||
for (const [id, isRead] of Object.entries(changes)) {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) continue
|
||||
const flags = [...msg.flags]
|
||||
if (isRead && !flags.includes("read")) {
|
||||
updateFlags.mutate({ id, flags: [...flags, "read"] })
|
||||
} else if (!isRead && flags.includes("read")) {
|
||||
updateFlags.mutate({ id, flags: flags.filter((f) => f !== "read") })
|
||||
}
|
||||
}
|
||||
},
|
||||
[mailActions]
|
||||
[apiMessagesById, updateFlags]
|
||||
)
|
||||
|
||||
const setLabelEdits = useCallback(
|
||||
(updater: (prev: LabelEditState) => LabelEditState) => {
|
||||
mailActions.setLabelEdits(updater)
|
||||
(updater: (prev: { additions: Record<string, string[]>; removals: Record<string, string[]> }) => { additions: Record<string, string[]>; removals: Record<string, string[]> }) => {
|
||||
const result = updater({ additions: {}, removals: {} })
|
||||
for (const [id, additions] of Object.entries(result.additions)) {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) continue
|
||||
const newLabels = [...new Set([...msg.labels, ...additions])]
|
||||
const removals = result.removals[id] ?? []
|
||||
const finalLabels = newLabels.filter(
|
||||
(l) => !removals.some((r) => r.toLowerCase() === l.toLowerCase())
|
||||
)
|
||||
updateLabels.mutate({ id, labels: finalLabels })
|
||||
}
|
||||
for (const [id, removals] of Object.entries(result.removals)) {
|
||||
if (result.additions[id]) continue
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) continue
|
||||
const finalLabels = msg.labels.filter(
|
||||
(l) => !removals.some((r) => r.toLowerCase() === l.toLowerCase())
|
||||
)
|
||||
updateLabels.mutate({ id, labels: finalLabels })
|
||||
}
|
||||
},
|
||||
[mailActions]
|
||||
[apiMessagesById, updateLabels]
|
||||
)
|
||||
|
||||
const mailActions = useMemo(() => ({
|
||||
markSeen: (id: string) => useMailStore.getState().markSeen(id),
|
||||
pushRecentMoveTarget: (targetId: string) => useMailStore.getState().pushRecentMoveTarget(targetId),
|
||||
hideEmail: (id: string) => deleteMessage.mutate({ id }),
|
||||
hideEmails: (ids: string[]) => { for (const id of ids) deleteMessage.mutate({ id }) },
|
||||
markNotSpam: (id: string) => {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) return
|
||||
const newLabels = msg.labels.filter((l) => l !== "spam")
|
||||
if (!newLabels.includes("inbox")) newLabels.push("inbox")
|
||||
updateLabels.mutate({ id, labels: newLabels })
|
||||
},
|
||||
unhideEmail: (_id: string) => { /* no-op - API manages visibility */ },
|
||||
toggleStar: (id: string) => {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) return
|
||||
const flags = msg.flags.includes("starred")
|
||||
? msg.flags.filter((f) => f !== "starred")
|
||||
: [...msg.flags, "starred"]
|
||||
updateFlags.mutate({ id, flags })
|
||||
},
|
||||
toggleImportant: (id: string) => {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) return
|
||||
const flags = msg.flags.includes("important")
|
||||
? msg.flags.filter((f) => f !== "important")
|
||||
: [...msg.flags, "important"]
|
||||
updateFlags.mutate({ id, flags })
|
||||
},
|
||||
}), [deleteMessage, updateLabels, updateFlags, apiMessagesById])
|
||||
|
||||
useEffect(() => {
|
||||
registerNavEmailSync({
|
||||
renameLabel: (from, to) => {
|
||||
setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to))
|
||||
renameLabel: (_from, _to) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages"] })
|
||||
},
|
||||
removeLabel: (label) => {
|
||||
setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label))
|
||||
removeLabel: (_label) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["messages"] })
|
||||
},
|
||||
})
|
||||
return () => registerNavEmailSync(null)
|
||||
}, [allEmails, setLabelEdits])
|
||||
}, [queryClient])
|
||||
|
||||
const [labelPickerQuery, setLabelPickerQuery] = useState("")
|
||||
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
|
||||
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
|
||||
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
|
||||
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
|
||||
const isXs = useIsXs()
|
||||
@ -303,8 +456,8 @@ export function useEmailListData({
|
||||
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
|
||||
|
||||
const handleRefreshMessages = useCallback(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 900))
|
||||
}, [])
|
||||
await queryClient.invalidateQueries({ queryKey: ["messages"] })
|
||||
}, [queryClient])
|
||||
|
||||
const {
|
||||
isRefreshing,
|
||||
@ -329,93 +482,12 @@ export function useEmailListData({
|
||||
}, [isRefreshing, handleRefreshMessages, setIsRefreshing])
|
||||
|
||||
const markEmailSeen = useCallback((id: string) => {
|
||||
mailActions.markSeen(id)
|
||||
}, [mailActions])
|
||||
|
||||
const folderFilterCtx = useMemo(
|
||||
() => ({
|
||||
starredEmailIds: starredEmails,
|
||||
importantEmailIds: importantEmails,
|
||||
}),
|
||||
[starredEmails, importantEmails]
|
||||
)
|
||||
useMailStore.getState().markSeen(id)
|
||||
}, [])
|
||||
|
||||
const filteredEmails = useMemo(() => {
|
||||
const hiddenSet = new Set(hiddenEmailIds)
|
||||
const subtreeIdsCache = new Map<string, string[] | null>()
|
||||
let visible = allEmails.filter((email) => !hiddenSet.has(email.id))
|
||||
const hasLabelEdits =
|
||||
labelEdits &&
|
||||
(Object.keys(labelEdits.additions).length > 0 ||
|
||||
Object.keys(labelEdits.removals).length > 0)
|
||||
if (hasLabelEdits || notSpamEmailIds.length > 0) {
|
||||
visible = visible.map((e) =>
|
||||
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
|
||||
)
|
||||
}
|
||||
|
||||
if (isSearchMode && searchParams) {
|
||||
return filterEmailsBySearchParams(visible, searchParams, {
|
||||
starredIds: starredEmails,
|
||||
importantIds: importantEmails,
|
||||
})
|
||||
}
|
||||
|
||||
let rows = visible.filter((email) =>
|
||||
emailMatchesFolder(
|
||||
email,
|
||||
selectedFolder,
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
subtreeIdsCache
|
||||
)
|
||||
)
|
||||
if (selectedFolder === "inbox") {
|
||||
const tab = normalizeInboxTabSegment(inboxTab)
|
||||
if (tab === "primary") {
|
||||
rows = rows.filter((email) =>
|
||||
emailMatchesInboxPrimaryTab(
|
||||
email,
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
subtreeIdsCache
|
||||
)
|
||||
)
|
||||
} else if (tab !== INBOX_ALL_TAB) {
|
||||
rows = rows.filter(
|
||||
(email) =>
|
||||
emailMatchesFolder(
|
||||
email,
|
||||
"inbox",
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
subtreeIdsCache
|
||||
) &&
|
||||
emailMatchesFolder(
|
||||
email,
|
||||
tab,
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
subtreeIdsCache
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}, [
|
||||
selectedFolder,
|
||||
inboxTab,
|
||||
hiddenEmailIds,
|
||||
folderFilterCtx,
|
||||
labelEdits,
|
||||
notSpamEmailIds,
|
||||
allEmails,
|
||||
navMaps,
|
||||
isSearchMode,
|
||||
searchParams,
|
||||
starredEmails,
|
||||
importantEmails,
|
||||
])
|
||||
return allEmails
|
||||
}, [allEmails])
|
||||
|
||||
const displayListEmails = useMemo(() => {
|
||||
let rows = filteredEmails
|
||||
@ -426,9 +498,9 @@ export function useEmailListData({
|
||||
rows,
|
||||
inboxSort,
|
||||
{
|
||||
readOverrides,
|
||||
starredIds: starredEmails,
|
||||
importantIds: importantEmails,
|
||||
readOverrides: {},
|
||||
starredIds: [],
|
||||
importantIds: [],
|
||||
},
|
||||
{ conversationMode, byId: emailById }
|
||||
)
|
||||
@ -436,9 +508,6 @@ export function useEmailListData({
|
||||
filteredEmails,
|
||||
conversationMode,
|
||||
inboxSort,
|
||||
readOverrides,
|
||||
starredEmails,
|
||||
importantEmails,
|
||||
emailById,
|
||||
])
|
||||
|
||||
@ -453,11 +522,8 @@ export function useEmailListData({
|
||||
)
|
||||
|
||||
const mobileUnreadCount = useMemo(
|
||||
() =>
|
||||
displayListEmails.filter(
|
||||
(e) => !isListRowRead(e, readOverrides, emailById, conversationMode)
|
||||
).length,
|
||||
[displayListEmails, readOverrides, emailById, conversationMode]
|
||||
() => displayListEmails.filter((e) => !e.read).length,
|
||||
[displayListEmails]
|
||||
)
|
||||
|
||||
const mobileFolderLabel = useMemo(() => {
|
||||
@ -474,15 +540,24 @@ export function useEmailListData({
|
||||
isSearchMode,
|
||||
])
|
||||
|
||||
const paginationTotal = useMemo(() => {
|
||||
if (isSearchMode) return searchQuery.data?.pagination?.total
|
||||
if (effectiveApiFolder === "__local__") return allEmails.length
|
||||
return messagesQuery.data?.pagination?.total
|
||||
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data, allEmails.length])
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)),
|
||||
[displayListEmails.length]
|
||||
() => Math.max(1, Math.ceil((paginationTotal ?? displayListEmails.length) / LIST_PAGE_SIZE)),
|
||||
[paginationTotal, displayListEmails.length]
|
||||
)
|
||||
|
||||
const pagedEmails = useMemo(() => {
|
||||
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
|
||||
return displayListEmails
|
||||
}
|
||||
const start = (listPage - 1) * LIST_PAGE_SIZE
|
||||
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
|
||||
}, [displayListEmails, listPage])
|
||||
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode])
|
||||
|
||||
const listEmails = useMemo(() => {
|
||||
if (isXs && !isViewMode) {
|
||||
@ -493,6 +568,14 @@ export function useEmailListData({
|
||||
|
||||
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
|
||||
|
||||
const folderFilterCtx = useMemo(
|
||||
() => ({
|
||||
starredEmailIds: [] as string[],
|
||||
importantEmailIds: [] as string[],
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const listRowExtras = useMemo(() => {
|
||||
const invitationById = new Map<
|
||||
string,
|
||||
@ -575,27 +658,7 @@ export function useEmailListData({
|
||||
currentFolderId: selectedFolder,
|
||||
})
|
||||
|
||||
const folderUnreadCounts = useMemo(
|
||||
() =>
|
||||
computeFolderUnreadCounts(
|
||||
allEmails,
|
||||
folderFilterCtx,
|
||||
hiddenEmailIds,
|
||||
readOverrides,
|
||||
navMaps,
|
||||
labelEdits,
|
||||
notSpamEmailIds
|
||||
),
|
||||
[
|
||||
folderFilterCtx,
|
||||
hiddenEmailIds,
|
||||
readOverrides,
|
||||
allEmails,
|
||||
navMaps,
|
||||
labelEdits,
|
||||
notSpamEmailIds,
|
||||
]
|
||||
)
|
||||
const folderUnreadCounts = useMemo<Record<string, number>>(() => ({}), [])
|
||||
|
||||
const seenSerialized = useMemo(
|
||||
() => [...seenEmailIds].sort().join(","),
|
||||
@ -606,35 +669,11 @@ export function useEmailListData({
|
||||
const seen = new Set(
|
||||
seenSerialized.length > 0 ? seenSerialized.split(",") : []
|
||||
)
|
||||
const hidden = new Set(hiddenEmailIds)
|
||||
const visible = allEmails
|
||||
.filter((email) => !hidden.has(email.id))
|
||||
.map((e) =>
|
||||
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
|
||||
)
|
||||
const inboxPool = visible.filter((e) =>
|
||||
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
|
||||
)
|
||||
const inboxPool = allEmails.filter((e) => !seen.has(e.id))
|
||||
const counts: Record<string, number> = {}
|
||||
const preview: Record<string, string> = {}
|
||||
const tabCache = new Map<string, string[] | null>()
|
||||
for (const tab of inboxTabBarItems) {
|
||||
const rows = inboxPool.filter((e) => {
|
||||
if (tab.id === "primary") {
|
||||
return (
|
||||
emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) &&
|
||||
!seen.has(e.id)
|
||||
)
|
||||
}
|
||||
if (tab.id === INBOX_ALL_TAB) {
|
||||
return !seen.has(e.id)
|
||||
}
|
||||
return (
|
||||
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) &&
|
||||
emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) &&
|
||||
!seen.has(e.id)
|
||||
)
|
||||
})
|
||||
const rows = inboxPool.filter((e) => !seen.has(e.id))
|
||||
counts[tab.id] = rows.length
|
||||
if (inboxTabShowsInactiveMeta(tab.id)) {
|
||||
const chain: string[] = []
|
||||
@ -650,7 +689,7 @@ export function useEmailListData({
|
||||
}
|
||||
}
|
||||
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
|
||||
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
|
||||
}, [seenSerialized, allEmails, inboxTabBarItems])
|
||||
|
||||
useEffect(() => {
|
||||
onFolderUnreadCountsChange?.(folderUnreadCounts)
|
||||
@ -667,28 +706,25 @@ export function useEmailListData({
|
||||
const listRowsDep = listEmails.map((e) => e.id).join(",")
|
||||
|
||||
const effectiveRead = useCallback(
|
||||
(email: Email) =>
|
||||
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read,
|
||||
[readOverrides]
|
||||
(email: Email) => email.read,
|
||||
[]
|
||||
)
|
||||
|
||||
const effectiveStarred = useCallback(
|
||||
(email: Email) =>
|
||||
starredEmails.includes(email.id) || email.starred,
|
||||
[starredEmails]
|
||||
(email: Email) => email.starred,
|
||||
[]
|
||||
)
|
||||
|
||||
const markAllInViewAsRead = useCallback(() => {
|
||||
setReadOverrides((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const e of displayListEmails) {
|
||||
for (const id of readStateTargets(e, conversationMode)) {
|
||||
next[id] = true
|
||||
}
|
||||
for (const e of displayListEmails) {
|
||||
if (e.read) continue
|
||||
const msg = apiMessagesById.get(e.id)
|
||||
if (!msg) continue
|
||||
if (!msg.flags.includes("read")) {
|
||||
updateFlags.mutate({ id: e.id, flags: [...msg.flags, "read"] })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [displayListEmails, conversationMode, setReadOverrides])
|
||||
}
|
||||
}, [displayListEmails, apiMessagesById, updateFlags])
|
||||
|
||||
return {
|
||||
selectedFolder,
|
||||
@ -779,6 +815,9 @@ export function useEmailListData({
|
||||
requestSendScheduledNow,
|
||||
requestSnoozeMailboxEmail,
|
||||
requestRestoreSnoozedToInbox,
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,6 @@
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
|
||||
import { resolveLabelPickerVisual } from "@/lib/label-picker-visual"
|
||||
import {
|
||||
effectiveLabels,
|
||||
mergeEmailLabelEdits,
|
||||
mergeEmailNotSpam,
|
||||
} from "@/lib/label-edits"
|
||||
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
|
||||
import {
|
||||
LABEL_PICKER_EXCLUDE,
|
||||
@ -21,8 +16,6 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
const {
|
||||
allEmails,
|
||||
sidebarNav,
|
||||
labelEdits,
|
||||
notSpamEmailIds,
|
||||
setLabelEdits,
|
||||
mailActions,
|
||||
} = data
|
||||
@ -52,20 +45,16 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
|
||||
for (const id of emailIds) {
|
||||
const email = allEmails.find((e) => e.id === id)
|
||||
const currentLabels = effectiveLabels(email, nextAdd, nextRem)
|
||||
const currentLabels = email?.labels ?? []
|
||||
|
||||
if (isSystemTarget) {
|
||||
if (targetId === "inbox") {
|
||||
for (const lab of currentLabels) {
|
||||
if (allFolderLabels.has(lab.toLowerCase())) {
|
||||
const cur = nextRem[id] ?? []
|
||||
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
|
||||
if (!cur.some((l: string) => l.toLowerCase() === lab.toLowerCase())) {
|
||||
nextRem[id] = [...cur, lab]
|
||||
}
|
||||
if (nextAdd[id]?.length) {
|
||||
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
|
||||
if (nextAdd[id].length === 0) delete nextAdd[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -73,22 +62,14 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
for (const lab of currentLabels) {
|
||||
if (allFolderLabels.has(lab.toLowerCase()) && lab.toLowerCase() !== folderLabel.toLowerCase()) {
|
||||
const cur = nextRem[id] ?? []
|
||||
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
|
||||
if (!cur.some((l: string) => l.toLowerCase() === lab.toLowerCase())) {
|
||||
nextRem[id] = [...cur, lab]
|
||||
}
|
||||
if (nextAdd[id]?.length) {
|
||||
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
|
||||
if (nextAdd[id].length === 0) delete nextAdd[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!currentLabels.some((l) => l.toLowerCase() === folderLabel.toLowerCase())) {
|
||||
nextAdd[id] = [...(nextAdd[id] ?? []), folderLabel]
|
||||
}
|
||||
if (nextRem[id]?.length) {
|
||||
nextRem[id] = nextRem[id].filter((l) => l.toLowerCase() !== folderLabel.toLowerCase())
|
||||
if (nextRem[id].length === 0) delete nextRem[id]
|
||||
}
|
||||
const inboxIdx = currentLabels.findIndex((l) => l.toLowerCase() === "inbox")
|
||||
if (inboxIdx >= 0 || !email?.labels?.length || email.labels.includes("inbox")) {
|
||||
const cur = nextRem[id] ?? []
|
||||
@ -118,16 +99,12 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l)
|
||||
for (const row of sidebarNav.labelRows) s.add(row.label)
|
||||
for (const e of allEmails) {
|
||||
const eff = mergeEmailNotSpam(
|
||||
mergeEmailLabelEdits(e, labelEdits),
|
||||
notSpamEmailIds
|
||||
)
|
||||
for (const lab of eff.labels ?? []) {
|
||||
for (const lab of e.labels ?? []) {
|
||||
if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab)
|
||||
}
|
||||
}
|
||||
return [...s].sort((a, b) => a.localeCompare(b, "fr"))
|
||||
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
|
||||
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails])
|
||||
|
||||
const resolveLabelVisual = useCallback(
|
||||
(label: string) =>
|
||||
@ -162,15 +139,9 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
const nextAdd = { ...prev.additions }
|
||||
const nextRem = { ...prev.removals }
|
||||
for (const id of ids) {
|
||||
if (nextRem[id]?.length) {
|
||||
nextRem[id] = nextRem[id].filter(
|
||||
(x) => x.toLowerCase() !== resolved.toLowerCase()
|
||||
)
|
||||
if (nextRem[id].length === 0) delete nextRem[id]
|
||||
}
|
||||
const base = allEmails.find((e) => e.id === id)
|
||||
const merged = effectiveLabels(base, nextAdd, nextRem)
|
||||
if (merged.some((x) => x.toLowerCase() === resolved.toLowerCase())) {
|
||||
const currentLabels = base?.labels ?? []
|
||||
if (currentLabels.some((x: string) => x.toLowerCase() === resolved.toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
nextAdd[id] = [...(nextAdd[id] ?? []), resolved]
|
||||
@ -189,14 +160,14 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
let n = 0
|
||||
for (const id of ids) {
|
||||
const e = allEmails.find((x) => x.id === id)
|
||||
const eff = effectiveLabels(e, labelEdits.additions, labelEdits.removals)
|
||||
if (eff.some((l) => l.toLowerCase() === lc)) n++
|
||||
const labels = e?.labels ?? []
|
||||
if (labels.some((l: string) => l.toLowerCase() === lc)) n++
|
||||
}
|
||||
if (n === 0) return "none"
|
||||
if (n === ids.length) return "all"
|
||||
return "some"
|
||||
},
|
||||
[allEmails, labelEdits, resolveLabelCasing]
|
||||
[allEmails, resolveLabelCasing]
|
||||
)
|
||||
|
||||
const toggleLabelOnEmails = useCallback(
|
||||
@ -208,8 +179,8 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
const presence = (id: string) => {
|
||||
const e = allEmails.find((x) => x.id === id)
|
||||
if (!e) return false
|
||||
return effectiveLabels(e, prev.additions, prev.removals).some(
|
||||
(l) => l.toLowerCase() === resolved.toLowerCase()
|
||||
return (e.labels ?? []).some(
|
||||
(l: string) => l.toLowerCase() === resolved.toLowerCase()
|
||||
)
|
||||
}
|
||||
const allHave = ids.every((id) => presence(id))
|
||||
@ -218,30 +189,7 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
|
||||
if (allHave) {
|
||||
for (const id of ids) {
|
||||
if (nextAdd[id]?.length) {
|
||||
const filtered = nextAdd[id].filter(
|
||||
(l) => l.toLowerCase() !== resolved.toLowerCase()
|
||||
)
|
||||
if (filtered.length) nextAdd[id] = filtered
|
||||
else delete nextAdd[id]
|
||||
}
|
||||
const e = allEmails.find((x) => x.id === id)
|
||||
if (!e) continue
|
||||
const still = effectiveLabels(e, nextAdd, nextRem).some(
|
||||
(l) => l.toLowerCase() === resolved.toLowerCase()
|
||||
)
|
||||
if (still) {
|
||||
const cur = nextRem[id] ?? []
|
||||
if (!cur.some((l) => l.toLowerCase() === resolved.toLowerCase())) {
|
||||
nextRem[id] = [...cur, resolved]
|
||||
}
|
||||
} else if (nextRem[id]?.length) {
|
||||
const fr = nextRem[id].filter(
|
||||
(l) => l.toLowerCase() !== resolved.toLowerCase()
|
||||
)
|
||||
if (fr.length) nextRem[id] = fr
|
||||
else delete nextRem[id]
|
||||
}
|
||||
nextRem[id] = [...(nextRem[id] ?? []), resolved]
|
||||
}
|
||||
} else {
|
||||
const anyMissing = ids.some((id) => !presence(id))
|
||||
@ -249,23 +197,8 @@ export function useEmailListLabels(data: EmailListData) {
|
||||
queueMicrotask(() => sidebarNav.ensureLabelRowForLabelText(resolved))
|
||||
}
|
||||
for (const id of ids) {
|
||||
const e = allEmails.find((x) => x.id === id)
|
||||
if (!e) continue
|
||||
const had = effectiveLabels(e, prev.additions, prev.removals).some(
|
||||
(l) => l.toLowerCase() === resolved.toLowerCase()
|
||||
)
|
||||
if (nextRem[id]?.length) {
|
||||
const fr = nextRem[id].filter(
|
||||
(l) => l.toLowerCase() !== resolved.toLowerCase()
|
||||
)
|
||||
if (fr.length) nextRem[id] = fr
|
||||
else delete nextRem[id]
|
||||
}
|
||||
if (!had) {
|
||||
if (!nextAdd[id]) nextAdd[id] = []
|
||||
if (!nextAdd[id].some((l) => l.toLowerCase() === resolved.toLowerCase())) {
|
||||
nextAdd[id] = [...nextAdd[id], resolved]
|
||||
}
|
||||
if (!presence(id)) {
|
||||
nextAdd[id] = [...(nextAdd[id] ?? []), resolved]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,10 +11,6 @@ import type { Email } from "@/lib/email-data"
|
||||
import { readStateTargets } from "@/lib/mail-thread"
|
||||
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
|
||||
import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email"
|
||||
import {
|
||||
mergeEmailLabelEdits,
|
||||
mergeEmailNotSpam,
|
||||
} from "@/lib/label-edits"
|
||||
import {
|
||||
DEFAULT_INBOX_TAB,
|
||||
} from "@/lib/mail-url"
|
||||
@ -59,9 +55,6 @@ export function useEmailListReading(
|
||||
listRowsDep,
|
||||
listViewportRef,
|
||||
conversationMode,
|
||||
labelEdits,
|
||||
notSpamEmailIds,
|
||||
readOverrides,
|
||||
setReadOverrides,
|
||||
markEmailSeen,
|
||||
mailActions,
|
||||
@ -82,20 +75,12 @@ export function useEmailListReading(
|
||||
)
|
||||
if (!resolved) return null
|
||||
if (resolved.email.labels?.includes("scheduled")) return null
|
||||
const email = mergeEmailNotSpam(
|
||||
mergeEmailLabelEdits(resolved.email, labelEdits),
|
||||
notSpamEmailIds
|
||||
)
|
||||
const threadRoot = mergeEmailNotSpam(
|
||||
mergeEmailLabelEdits(resolved.threadRoot, labelEdits),
|
||||
notSpamEmailIds
|
||||
)
|
||||
return {
|
||||
email,
|
||||
threadRoot,
|
||||
email: resolved.email,
|
||||
threadRoot: resolved.threadRoot,
|
||||
isSingleMessageView: resolved.isSingleMessageView,
|
||||
}
|
||||
}, [openMailId, labelEdits, allEmails, notSpamEmailIds, conversationMode])
|
||||
}, [openMailId, allEmails, conversationMode])
|
||||
|
||||
const openEmail = openEmailView?.email ?? null
|
||||
const openEmailThreadRoot = openEmailView?.threadRoot ?? null
|
||||
@ -116,15 +101,11 @@ export function useEmailListReading(
|
||||
markEmailSeen(id)
|
||||
}
|
||||
setReadOverrides((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const id of targets) {
|
||||
if (next[id] === undefined) {
|
||||
next[id] = true
|
||||
changed = true
|
||||
}
|
||||
next[id] = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
return next
|
||||
})
|
||||
}, [openMailId, markEmailSeen, emailById, conversationMode, setReadOverrides])
|
||||
|
||||
@ -211,8 +192,6 @@ export function useEmailListReading(
|
||||
(emailRow: Email) => {
|
||||
void data.requestRestoreSnoozedToInbox(emailRow)
|
||||
if (emailRow.id.startsWith("snz-")) {
|
||||
const baseId = emailRow.id.slice(4)
|
||||
if (baseId.length > 0) mailActions.unhideEmail(baseId)
|
||||
onSelectFolder?.("inbox")
|
||||
} else {
|
||||
onSelectFolder?.("scheduled")
|
||||
@ -221,7 +200,6 @@ export function useEmailListReading(
|
||||
},
|
||||
[
|
||||
data,
|
||||
mailActions,
|
||||
closeViewIfShowingEmail,
|
||||
onSelectFolder,
|
||||
]
|
||||
@ -288,7 +266,7 @@ export function useEmailListReading(
|
||||
if (openMailIndex > 0) {
|
||||
const id = displayListEmails[openMailIndex - 1]!.id
|
||||
markEmailSeen(id)
|
||||
setReadOverrides((prev) => ({ ...prev, [id]: true }))
|
||||
setReadOverrides(() => ({ [id]: true }))
|
||||
navigateToMail(id)
|
||||
}
|
||||
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides])
|
||||
@ -297,7 +275,7 @@ export function useEmailListReading(
|
||||
if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) {
|
||||
const id = displayListEmails[openMailIndex + 1]!.id
|
||||
markEmailSeen(id)
|
||||
setReadOverrides((prev) => ({ ...prev, [id]: true }))
|
||||
setReadOverrides(() => ({ [id]: true }))
|
||||
navigateToMail(id)
|
||||
}
|
||||
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides])
|
||||
@ -307,7 +285,7 @@ export function useEmailListReading(
|
||||
const em = allEmails.find((e) => e.id === id)
|
||||
if (em?.labels?.includes("scheduled")) return
|
||||
markEmailSeen(id)
|
||||
setReadOverrides((prev) => ({ ...prev, [id]: true }))
|
||||
setReadOverrides(() => ({ [id]: true }))
|
||||
navigateToMail(id)
|
||||
},
|
||||
[navigateToMail, markEmailSeen, allEmails, setReadOverrides]
|
||||
@ -316,7 +294,7 @@ export function useEmailListReading(
|
||||
const openDraftInCompose = useCallback(
|
||||
(email: Email) => {
|
||||
markEmailSeen(email.id)
|
||||
setReadOverrides((prev) => ({ ...prev, [email.id]: true }))
|
||||
setReadOverrides(() => ({ [email.id]: true }))
|
||||
const to: Contact[] = email.senderEmail
|
||||
? [{ name: email.sender.trim(), email: email.senderEmail }]
|
||||
: []
|
||||
@ -350,10 +328,8 @@ export function useEmailListReading(
|
||||
|
||||
const viewModeIsRead = useMemo(() => {
|
||||
if (!openEmail) return true
|
||||
return readOverrides[openEmail.id] !== undefined
|
||||
? readOverrides[openEmail.id]!
|
||||
: openEmail.read
|
||||
}, [openEmail, readOverrides])
|
||||
return openEmail.read
|
||||
}, [openEmail])
|
||||
|
||||
const afterSingleMessageRemoved = useCallback(
|
||||
(removedId: string) => {
|
||||
@ -394,7 +370,8 @@ export function useEmailListReading(
|
||||
|
||||
const singleToggleRead = useCallback(() => {
|
||||
if (!openMailId) return
|
||||
setReadOverrides((prev) => ({ ...prev, [openMailId]: !viewModeIsRead }))
|
||||
const next = !viewModeIsRead
|
||||
setReadOverrides(() => ({ [openMailId]: next }))
|
||||
}, [openMailId, viewModeIsRead, setReadOverrides])
|
||||
|
||||
const singleMoveTo = useCallback(
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import { useMailStore } from "@/lib/stores/mail-store"
|
||||
|
||||
export type ListMailIndex = {
|
||||
emailById: Map<string, Email>
|
||||
scheduledIds: Set<string>
|
||||
}
|
||||
|
||||
/** O(n) index for list row logic — avoids repeated `allEmails.some` / `find` per row. */
|
||||
export function buildListMailIndex(emails: Email[]): ListMailIndex {
|
||||
const emailById = new Map<string, Email>()
|
||||
const scheduledIds = new Set<string>()
|
||||
@ -26,24 +23,10 @@ export type MailRowFlags = {
|
||||
isImportant: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row mail UI flags from the persisted mail store.
|
||||
* Use inside a keyed `memo` row component (not a plain `.map` callback).
|
||||
*/
|
||||
export function useMailRowFlags(email: Email): MailRowFlags {
|
||||
const id = email.id
|
||||
const readOverride = useMailStore(
|
||||
useCallback((s) => s.readOverrides[id], [id])
|
||||
)
|
||||
const starred = useMailStore(
|
||||
useCallback((s) => s.starredIds.includes(id), [id])
|
||||
)
|
||||
const important = useMailStore(
|
||||
useCallback((s) => s.importantIds.includes(id), [id])
|
||||
)
|
||||
return {
|
||||
isRead: readOverride !== undefined ? readOverride : email.read,
|
||||
isStarred: starred || email.starred,
|
||||
isImportant: important || email.important,
|
||||
isRead: email.read,
|
||||
isStarred: email.starred,
|
||||
isImportant: email.important,
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,14 +6,10 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
import { Star, Reply, ReplyAll, Forward } from "lucide-react"
|
||||
import { Reply, ReplyAll, Forward } from "lucide-react"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
@ -21,8 +17,16 @@ import {
|
||||
cleanSenderName,
|
||||
senderInitial,
|
||||
} from "@/lib/sender-display"
|
||||
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||
import { useMessage, useThread } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
useToggleStar,
|
||||
useMarkRead,
|
||||
useUpdateFlags,
|
||||
useUpdateLabels,
|
||||
} from "@/lib/api/hooks/use-mail-mutations"
|
||||
import {
|
||||
useComposeActions,
|
||||
useComposeDrafts,
|
||||
@ -52,36 +56,55 @@ import {
|
||||
SpamWhyBanner,
|
||||
} from "@/components/gmail/email-view/email-view-messages"
|
||||
|
||||
function apiToLegacyEmail(
|
||||
msg: ApiMessageSummary,
|
||||
full?: ApiMessageFull | null,
|
||||
thread?: ApiMessageFull[] | null
|
||||
): Email {
|
||||
const senderName = msg.from[0]?.name ?? ""
|
||||
return {
|
||||
id: msg.id,
|
||||
sender: senderName,
|
||||
senderEmail: msg.from[0]?.address,
|
||||
subject: msg.subject,
|
||||
preview: msg.snippet,
|
||||
body: full?.body_html ?? full?.body_text,
|
||||
date: msg.date,
|
||||
read: msg.flags.includes("read"),
|
||||
starred: msg.flags.includes("starred"),
|
||||
important: msg.flags.includes("important"),
|
||||
spam: msg.flags.includes("spam") || msg.labels.includes("spam"),
|
||||
labels: msg.labels,
|
||||
hasAttachment: msg.has_attachments,
|
||||
conversation: thread
|
||||
?.filter((m) => m.id !== msg.id)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
sender: m.from[0]?.name ?? "",
|
||||
senderEmail: m.from[0]?.address ?? "",
|
||||
date: m.date,
|
||||
body: m.body_html ?? m.body_text ?? "",
|
||||
preview: m.snippet,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
interface EmailViewProps {
|
||||
email: Email
|
||||
onToggleStar: (id: string) => void
|
||||
isStarred: boolean
|
||||
email: ApiMessageSummary
|
||||
onNavigateToLabel?: (label: string) => void
|
||||
/** Message spam : bannière + pastille sujet ; bouton « non-spam » */
|
||||
onNotSpam?: () => void
|
||||
/** Si défini, les pastilles libellé dont la fonction retourne false sont masquées (préférences barre latérale). */
|
||||
showLabelChip?: (label: string) => boolean
|
||||
labelBgByText?: Map<string, string>
|
||||
emailLabelToSidebarFolderId?: Record<string, string>
|
||||
getNavItemPrefs?: (id: string) => { messages: string }
|
||||
folderTree?: FolderTreeNode[]
|
||||
labelRows?: readonly LabelRowItem[]
|
||||
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
|
||||
currentFolderId?: string
|
||||
/** Fil complet (mode message isolé hors conversation). */
|
||||
threadRoot?: Email | null
|
||||
/** Affiche uniquement le message courant avec option d’ouvrir le fil. */
|
||||
isSingleMessageView?: boolean
|
||||
}
|
||||
|
||||
/* ── Main EmailView component ── */
|
||||
|
||||
export function EmailView({
|
||||
email,
|
||||
onToggleStar,
|
||||
isStarred,
|
||||
onNavigateToLabel,
|
||||
onNotSpam,
|
||||
showLabelChip,
|
||||
labelBgByText,
|
||||
emailLabelToSidebarFolderId = {},
|
||||
@ -89,47 +112,82 @@ export function EmailView({
|
||||
folderTree,
|
||||
labelRows,
|
||||
currentFolderId,
|
||||
threadRoot = null,
|
||||
isSingleMessageView = false,
|
||||
}: EmailViewProps) {
|
||||
const { data: fullMessage } = useMessage(email.id)
|
||||
const { data: threadMessages } = useThread(email.thread_id ?? null)
|
||||
|
||||
const toggleStar = useToggleStar()
|
||||
const markRead = useMarkRead()
|
||||
const updateFlags = useUpdateFlags()
|
||||
const updateLabels = useUpdateLabels()
|
||||
|
||||
const flags = fullMessage?.flags ?? email.flags
|
||||
const isStarred = flags.includes("starred")
|
||||
const isSpam = flags.includes("spam") || email.labels.includes("spam")
|
||||
|
||||
const initialFlagsRef = useRef(flags)
|
||||
useEffect(() => {
|
||||
initialFlagsRef.current = email.flags
|
||||
}, [email.id, email.flags])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFlagsRef.current.includes("read")) {
|
||||
markRead.mutate({ id: email.id, flags: initialFlagsRef.current })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email.id])
|
||||
|
||||
const body =
|
||||
fullMessage?.body_html ??
|
||||
fullMessage?.body_text ??
|
||||
`<p style="color:var(--muted-foreground);">${email.snippet}</p>`
|
||||
|
||||
const [showFullThread, setShowFullThread] = useState(false)
|
||||
const threadForReplies = threadRoot ?? email
|
||||
const priorCount = Math.max(
|
||||
0,
|
||||
(threadForReplies.threadMessageIds?.length ?? 1) - 1
|
||||
)
|
||||
|
||||
const priorMessages = useMemo(() => {
|
||||
if (!threadMessages) return []
|
||||
return threadMessages.filter((m) => m.id !== email.id)
|
||||
}, [threadMessages, email.id])
|
||||
|
||||
const priorCount = priorMessages.length
|
||||
const showRepliesCta =
|
||||
isSingleMessageView && !showFullThread && priorCount > 0
|
||||
|
||||
const conversation =
|
||||
isSingleMessageView && !showFullThread
|
||||
? []
|
||||
: (showFullThread ? threadForReplies.conversation : email.conversation) ?? []
|
||||
isSingleMessageView && !showFullThread ? [] : priorMessages
|
||||
const hasConversation = conversation.length > 0
|
||||
const isSpamMessage = email.spam === true
|
||||
|
||||
// Track which conversation messages are expanded (by index).
|
||||
// By default all previous messages are collapsed, only the last (main) is expanded.
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleExpanded = (msgId: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(msgId)) {
|
||||
next.delete(msgId)
|
||||
} else {
|
||||
next.add(msgId)
|
||||
}
|
||||
if (next.has(msgId)) next.delete(msgId)
|
||||
else next.add(msgId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const mainSenderName = cleanSenderName(email.sender)
|
||||
const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||||
const mainSenderName = cleanSenderName(email.from[0]?.name ?? "")
|
||||
const mainSenderAddr =
|
||||
email.from[0]?.address ??
|
||||
`${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||||
|
||||
const legacyEmail = useMemo(
|
||||
() => apiToLegacyEmail(email, fullMessage, threadMessages),
|
||||
[email, fullMessage, threadMessages]
|
||||
)
|
||||
|
||||
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
|
||||
if (email.has_attachments)
|
||||
return [{ name: "Pièce jointe", kind: "other" }]
|
||||
return []
|
||||
}, [email.has_attachments])
|
||||
|
||||
const { composeWindows } = useComposeWindows()
|
||||
const { savedThreadReplyDrafts } = useComposeDrafts()
|
||||
const { openComposeWithInitial } = useComposeActions()
|
||||
|
||||
const inlineCompose = useMemo(
|
||||
() =>
|
||||
composeWindows.find(
|
||||
@ -138,13 +196,6 @@ export function EmailView({
|
||||
[composeWindows, email.id]
|
||||
)
|
||||
|
||||
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
|
||||
if (email.attachments && email.attachments.length > 0) return email.attachments
|
||||
if (email.hasAttachment) return [{ name: "Pièce jointe", kind: "other" }]
|
||||
return []
|
||||
}, [email.attachments, email.hasAttachment])
|
||||
|
||||
const savedThreadDraft = savedThreadReplyDrafts[email.id]
|
||||
const hasInlineForThread = Boolean(inlineCompose)
|
||||
const showReplyForwardBar = !inlineCompose
|
||||
|
||||
@ -174,120 +225,140 @@ export function EmailView({
|
||||
[openComposeWithInitial, scrollThreadComposeIntoView]
|
||||
)
|
||||
|
||||
const savedThreadDraft = savedThreadReplyDrafts[email.id]
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedThreadDraft || hasInlineForThread) return
|
||||
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
|
||||
}, [
|
||||
email.id,
|
||||
savedThreadDraft,
|
||||
hasInlineForThread,
|
||||
openThreadCompose,
|
||||
])
|
||||
}, [email.id, savedThreadDraft, hasInlineForThread, openThreadCompose])
|
||||
|
||||
const startThreadCompose = useCallback(
|
||||
(kind: ThreadComposeKind) => {
|
||||
openThreadCompose(buildThreadComposePreset(email, kind))
|
||||
openThreadCompose(buildThreadComposePreset(legacyEmail, kind))
|
||||
},
|
||||
[email, openThreadCompose]
|
||||
[legacyEmail, openThreadCompose]
|
||||
)
|
||||
|
||||
const selfIdentity = DEFAULT_IDENTITIES[0]
|
||||
const selfName = cleanSenderName(selfIdentity.name)
|
||||
|
||||
const calendarInvitation = useMemo(
|
||||
() => resolveParsedCalendarInvitation(email),
|
||||
[email]
|
||||
() => resolveParsedCalendarInvitation(legacyEmail),
|
||||
[legacyEmail]
|
||||
)
|
||||
|
||||
const handleToggleStar = useCallback(() => {
|
||||
toggleStar.mutate({ id: email.id, flags, starred: isStarred })
|
||||
}, [email.id, flags, isStarred, toggleStar])
|
||||
|
||||
const handleNotSpam = useCallback(() => {
|
||||
if (flags.includes("spam")) {
|
||||
updateFlags.mutate({
|
||||
id: email.id,
|
||||
flags: flags.filter((f) => f !== "spam"),
|
||||
})
|
||||
}
|
||||
if (email.labels.includes("spam")) {
|
||||
updateLabels.mutate({
|
||||
id: email.id,
|
||||
labels: email.labels.filter((l) => l !== "spam"),
|
||||
})
|
||||
}
|
||||
}, [email.id, flags, email.labels, updateFlags, updateLabels])
|
||||
|
||||
const handlePrint = useCallback(() => {
|
||||
openConversationPrint(legacyEmail)
|
||||
}, [legacyEmail])
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
|
||||
{/* Spacer for floating nav buttons on xs */}
|
||||
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden />
|
||||
<EmailViewSubjectHeader
|
||||
email={email}
|
||||
isSpamMessage={isSpamMessage}
|
||||
onNotSpam={onNotSpam}
|
||||
onNavigateToLabel={onNavigateToLabel}
|
||||
showLabelChip={showLabelChip}
|
||||
labelBgByText={labelBgByText}
|
||||
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
|
||||
getNavItemPrefs={getNavItemPrefs}
|
||||
folderTree={folderTree}
|
||||
labelRows={labelRows}
|
||||
currentFolderId={currentFolderId}
|
||||
/>
|
||||
<div
|
||||
className="h-[52px] shrink-0 bg-mail-surface sm:hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
<EmailViewSubjectHeader
|
||||
email={email}
|
||||
isSpamMessage={isSpam}
|
||||
onNotSpam={isSpam ? handleNotSpam : undefined}
|
||||
onPrint={handlePrint}
|
||||
onNavigateToLabel={onNavigateToLabel}
|
||||
showLabelChip={showLabelChip}
|
||||
labelBgByText={labelBgByText}
|
||||
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
|
||||
getNavItemPrefs={getNavItemPrefs}
|
||||
folderTree={folderTree}
|
||||
labelRows={labelRows}
|
||||
currentFolderId={currentFolderId}
|
||||
/>
|
||||
|
||||
{calendarInvitation ? (
|
||||
<CalendarInvitationPreview invitation={calendarInvitation} />
|
||||
) : null}
|
||||
{calendarInvitation ? (
|
||||
<CalendarInvitationPreview invitation={calendarInvitation} />
|
||||
) : null}
|
||||
|
||||
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
|
||||
{isSpam && <SpamWhyBanner onNotSpam={handleNotSpam} />}
|
||||
|
||||
{showRepliesCta ? (
|
||||
<div className="border-b border-border px-6 py-3 max-sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullThread(true)}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{priorCount === 1
|
||||
? "Afficher la réponse"
|
||||
: `Afficher les ${priorCount} réponses`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Conversation messages */}
|
||||
{/* Previous messages in conversation */}
|
||||
{hasConversation && conversation.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id)
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={msg.id} className="border-b border-border">
|
||||
<ExpandedMessage
|
||||
sender={msg.sender}
|
||||
senderEmail={msg.senderEmail}
|
||||
dateIso={msg.date}
|
||||
body={msg.body}
|
||||
isSpam={false}
|
||||
isLast={false}
|
||||
starred={false}
|
||||
attachments={msg.attachments ?? []}
|
||||
onCollapse={() => toggleExpanded(msg.id)}
|
||||
onPrintConversation={() => openConversationPrint(email)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="border-b border-[#eceff1]">
|
||||
<CollapsedMessage
|
||||
message={msg}
|
||||
onClick={() => toggleExpanded(msg.id)}
|
||||
/>
|
||||
{showRepliesCta ? (
|
||||
<div className="border-b border-border px-6 py-3 max-sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullThread(true)}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{priorCount === 1
|
||||
? "Afficher la réponse"
|
||||
: `Afficher les ${priorCount} réponses`}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
) : null}
|
||||
|
||||
{/* Last / main message — always expanded */}
|
||||
<ExpandedMessage
|
||||
sender={mainSenderName}
|
||||
senderEmail={mainSenderAddr}
|
||||
dateIso={email.date}
|
||||
body={email.body || `<p style="color:var(--muted-foreground);">${email.preview}</p>`}
|
||||
isSpam={email.spam === true}
|
||||
isLast={true}
|
||||
starred={isStarred}
|
||||
attachments={mainMessageAttachments}
|
||||
onToggleStar={() => onToggleStar(email.id)}
|
||||
onPrintConversation={() => openConversationPrint(email)}
|
||||
/>
|
||||
{hasConversation &&
|
||||
conversation.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id)
|
||||
|
||||
{showReplyForwardBar ? (
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={msg.id} className="border-b border-border">
|
||||
<ExpandedMessage
|
||||
sender={msg.from[0]?.name ?? ""}
|
||||
senderEmail={msg.from[0]?.address ?? ""}
|
||||
dateIso={msg.date}
|
||||
body={msg.body_html ?? msg.body_text ?? ""}
|
||||
isSpam={false}
|
||||
isLast={false}
|
||||
starred={msg.flags.includes("starred")}
|
||||
onCollapse={() => toggleExpanded(msg.id)}
|
||||
onPrintConversation={handlePrint}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="border-b border-[#eceff1]">
|
||||
<CollapsedMessage
|
||||
message={msg}
|
||||
onClick={() => toggleExpanded(msg.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<ExpandedMessage
|
||||
sender={mainSenderName}
|
||||
senderEmail={mainSenderAddr}
|
||||
dateIso={email.date}
|
||||
body={body}
|
||||
isSpam={isSpam}
|
||||
isLast={true}
|
||||
starred={isStarred}
|
||||
attachments={mainMessageAttachments}
|
||||
onToggleStar={handleToggleStar}
|
||||
onPrintConversation={handlePrint}
|
||||
/>
|
||||
|
||||
{showReplyForwardBar ? (
|
||||
<div
|
||||
className={cn(
|
||||
"z-10 mt-4 hidden flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px] sm:flex",
|
||||
@ -300,7 +371,10 @@ export function EmailView({
|
||||
onClick={() => startThreadCompose("reply")}
|
||||
className={MAIL_REPLY_BUTTON_CLASS}
|
||||
>
|
||||
<Reply className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Reply
|
||||
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Répondre
|
||||
</button>
|
||||
<button
|
||||
@ -308,7 +382,10 @@ export function EmailView({
|
||||
onClick={() => startThreadCompose("replyAll")}
|
||||
className={MAIL_REPLY_BUTTON_CLASS}
|
||||
>
|
||||
<ReplyAll className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
|
||||
<ReplyAll
|
||||
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Répondre à tous
|
||||
</button>
|
||||
<button
|
||||
@ -316,14 +393,20 @@ export function EmailView({
|
||||
onClick={() => startThreadCompose("forward")}
|
||||
className={MAIL_REPLY_BUTTON_CLASS}
|
||||
>
|
||||
<Forward className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Forward
|
||||
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Transférer
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{inlineCompose ? (
|
||||
<div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4">
|
||||
{inlineCompose ? (
|
||||
<div
|
||||
ref={threadComposeAnchorRef}
|
||||
className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
@ -336,13 +419,12 @@ export function EmailView({
|
||||
<ComposeWindow
|
||||
key={inlineCompose.id}
|
||||
compose={inlineCompose}
|
||||
threadSourceEmail={email}
|
||||
threadSourceEmail={legacyEmail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@ -9,9 +9,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import type { ApiMessageSummary } from "@/lib/api/types"
|
||||
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||
import { openConversationPrint } from "@/lib/print-conversation"
|
||||
import { MailLabelPillStrip } from "@/components/gmail/mail-label-pills"
|
||||
import {
|
||||
MAIL_ICON_BTN,
|
||||
@ -81,9 +80,10 @@ const LABEL_DISPLAY_NAMES: Record<string, string> = {
|
||||
}
|
||||
|
||||
export interface EmailViewSubjectHeaderProps {
|
||||
email: Email
|
||||
email: ApiMessageSummary
|
||||
isSpamMessage: boolean
|
||||
onNotSpam?: () => void
|
||||
onPrint?: () => void
|
||||
onNavigateToLabel?: (label: string) => void
|
||||
showLabelChip?: (label: string) => boolean
|
||||
labelBgByText?: Map<string, string>
|
||||
@ -98,6 +98,7 @@ export function EmailViewSubjectHeader({
|
||||
email,
|
||||
isSpamMessage,
|
||||
onNotSpam,
|
||||
onPrint,
|
||||
onNavigateToLabel,
|
||||
showLabelChip,
|
||||
labelBgByText,
|
||||
@ -120,7 +121,7 @@ export function EmailViewSubjectHeader({
|
||||
{labelBgByText && onNavigateToLabel ? (
|
||||
<MailLabelPillStrip
|
||||
variant="header"
|
||||
labels={email.labels ?? ["inbox"]}
|
||||
labels={email.labels}
|
||||
labelBgByText={labelBgByText}
|
||||
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
|
||||
getNavItemPrefs={getNavItemPrefs}
|
||||
@ -147,7 +148,7 @@ export function EmailViewSubjectHeader({
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", MAIL_ICON_BTN)}
|
||||
aria-label="Imprimer"
|
||||
onClick={() => openConversationPrint(email)}
|
||||
onClick={() => onPrint?.()}
|
||||
>
|
||||
<Printer className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
</Button>
|
||||
|
||||
@ -13,10 +13,8 @@ import {
|
||||
senderInitial,
|
||||
} from "@/lib/sender-display"
|
||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||
import type {
|
||||
ConversationMessage,
|
||||
EmailAttachment,
|
||||
} from "@/lib/email-data"
|
||||
import type { ApiMessageFull } from "@/lib/api/types"
|
||||
import type { EmailAttachment } from "@/lib/email-data"
|
||||
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
|
||||
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
|
||||
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
|
||||
@ -30,10 +28,12 @@ export function CollapsedMessage({
|
||||
message,
|
||||
onClick,
|
||||
}: {
|
||||
message: ConversationMessage
|
||||
message: ApiMessageFull
|
||||
onClick: () => void
|
||||
}) {
|
||||
const name = cleanSenderName(message.sender)
|
||||
const senderName = message.from[0]?.name ?? ""
|
||||
const senderAddr = message.from[0]?.address ?? ""
|
||||
const name = cleanSenderName(senderName)
|
||||
const color = avatarColor(name)
|
||||
|
||||
return (
|
||||
@ -57,7 +57,7 @@ export function CollapsedMessage({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
|
||||
<ContactHoverCard displayName={senderName} email={senderAddr} className="min-w-0">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
|
||||
</ContactHoverCard>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@ -72,7 +72,7 @@ export function CollapsedMessage({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.preview}</p>
|
||||
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.snippet}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -183,7 +183,7 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
className="size-11 overflow-hidden rounded-full p-0"
|
||||
aria-label={`Compte : ${activeAccount.email}`}
|
||||
aria-label={`Compte : ${activeAccount?.email ?? ""}`}
|
||||
aria-expanded={accountMenuOpen}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => {
|
||||
@ -191,7 +191,7 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
||||
setAppsMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<AccountAvatar account={activeAccount} size="md" />
|
||||
{activeAccount && <AccountAvatar account={activeAccount} size="md" />}
|
||||
</Button>
|
||||
<AccountSwitcherDropdown
|
||||
open={accountMenuOpen}
|
||||
|
||||
@ -19,15 +19,13 @@ import {
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emails } from "@/lib/email-data"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
||||
import {
|
||||
matchContacts,
|
||||
matchEmails,
|
||||
bestCompletion,
|
||||
type SearchSuggestion,
|
||||
type ContactSuggestion,
|
||||
} from "@/lib/mail-search/search-engine"
|
||||
import {
|
||||
parseSearchParams,
|
||||
@ -61,7 +59,6 @@ export function MailSearchBar({
|
||||
[urlSearchParams]
|
||||
)
|
||||
const account = useActiveAccount()
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
|
||||
const inputValue = useMailSearchStore((s) => s.inputValue)
|
||||
const dropdownOpen = useMailSearchStore((s) => s.dropdownOpen)
|
||||
@ -71,6 +68,8 @@ export function MailSearchBar({
|
||||
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
|
||||
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
|
||||
|
||||
const { data: searchContactResults } = useSearchContacts(inputValue)
|
||||
|
||||
const {
|
||||
setInputValue,
|
||||
setDropdownOpen,
|
||||
@ -94,13 +93,23 @@ export function MailSearchBar({
|
||||
}, [currentSearchParams?.q])
|
||||
|
||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||
if (!inputValue.trim()) return []
|
||||
const contactHits = matchContacts(inputValue, contacts, 5)
|
||||
const emailHits = matchEmails(inputValue, emails, 5)
|
||||
const seen = new Set(contactHits.map((c) => c.email))
|
||||
const unique = emailHits.filter((e) => !seen.has(e.email))
|
||||
return [...contactHits, ...unique]
|
||||
}, [inputValue, contacts])
|
||||
if (!inputValue.trim() || !searchContactResults?.length) return []
|
||||
return searchContactResults.slice(0, 8).map<ContactSuggestion>((c) => ({
|
||||
kind: "contact",
|
||||
contact: {
|
||||
id: c.uid,
|
||||
firstName: c.full_name.split(" ")[0] ?? "",
|
||||
lastName: c.full_name.split(" ").slice(1).join(" "),
|
||||
emails: c.email ? [{ value: c.email, label: "primary" }] : [],
|
||||
phones: [],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
email: c.email ?? "",
|
||||
displayName: c.full_name,
|
||||
score: 1,
|
||||
}))
|
||||
}, [inputValue, searchContactResults])
|
||||
|
||||
const ghostText = useMemo(
|
||||
() => bestCompletion(inputValue, suggestions),
|
||||
@ -116,7 +125,7 @@ export function MailSearchBar({
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
fromEmail: account?.email ?? "",
|
||||
})
|
||||
if (!Object.keys(params).length) return
|
||||
submitMailSearch(router, params, {
|
||||
@ -126,7 +135,7 @@ export function MailSearchBar({
|
||||
},
|
||||
})
|
||||
},
|
||||
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router]
|
||||
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account?.email, router]
|
||||
)
|
||||
|
||||
const selectSuggestion = useCallback(
|
||||
@ -135,7 +144,7 @@ export function MailSearchBar({
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
fromEmail: account?.email ?? "",
|
||||
})
|
||||
submitMailSearch(router, params, {
|
||||
onAfter: () => {
|
||||
@ -145,7 +154,7 @@ export function MailSearchBar({
|
||||
},
|
||||
})
|
||||
},
|
||||
[chipAttachment, chipLast7Days, chipFromMe, account.email, router]
|
||||
[chipAttachment, chipLast7Days, chipFromMe, account?.email, router]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
|
||||
@ -21,14 +21,12 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emails } from "@/lib/email-data"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
import {
|
||||
matchContacts,
|
||||
matchEmails,
|
||||
bestCompletion,
|
||||
type SearchSuggestion,
|
||||
type ContactSuggestion,
|
||||
} from "@/lib/mail-search/search-engine"
|
||||
import {
|
||||
buildQuickSearchParams,
|
||||
@ -53,13 +51,14 @@ interface MobileSearchOverlayProps {
|
||||
export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: MobileSearchOverlayProps) {
|
||||
const router = useRouter()
|
||||
const account = useActiveAccount()
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
|
||||
const inputValue = useMailSearchStore((s) => s.inputValue)
|
||||
const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
|
||||
const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
|
||||
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
|
||||
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
|
||||
|
||||
const { data: searchContactResults } = useSearchContacts(inputValue)
|
||||
const {
|
||||
setInputValue,
|
||||
setSelectedIndex,
|
||||
@ -85,13 +84,23 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
}, [open, initialQuery, setInputValue, reset])
|
||||
|
||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||
if (!inputValue.trim()) return []
|
||||
const contactHits = matchContacts(inputValue, contacts, 4)
|
||||
const emailHits = matchEmails(inputValue, emails, 4)
|
||||
const seen = new Set(contactHits.map((c) => c.email))
|
||||
const unique = emailHits.filter((e) => !seen.has(e.email))
|
||||
return [...contactHits, ...unique]
|
||||
}, [inputValue, contacts])
|
||||
if (!inputValue.trim() || !searchContactResults?.length) return []
|
||||
return searchContactResults.slice(0, 6).map<ContactSuggestion>((c) => ({
|
||||
kind: "contact",
|
||||
contact: {
|
||||
id: c.uid,
|
||||
firstName: c.full_name.split(" ")[0] ?? "",
|
||||
lastName: c.full_name.split(" ").slice(1).join(" "),
|
||||
emails: c.email ? [{ value: c.email, label: "primary" }] : [],
|
||||
phones: [],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
email: c.email ?? "",
|
||||
displayName: c.full_name,
|
||||
score: 1,
|
||||
}))
|
||||
}, [inputValue, searchContactResults])
|
||||
|
||||
const ghostText = useMemo(
|
||||
() => bestCompletion(inputValue, suggestions),
|
||||
@ -107,12 +116,12 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
fromEmail: account?.email ?? "",
|
||||
})
|
||||
if (!Object.keys(params).length) return
|
||||
submitMailSearch(router, params, { onAfter: onClose })
|
||||
},
|
||||
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
|
||||
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
|
||||
)
|
||||
|
||||
const selectSuggestion = useCallback(
|
||||
@ -121,11 +130,11 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
fromEmail: account?.email ?? "",
|
||||
})
|
||||
submitMailSearch(router, params, { onAfter: onClose })
|
||||
},
|
||||
[chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
|
||||
[chipAttachment, chipLast7Days, chipFromMe, account?.email, router, onClose]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
|
||||
94
components/gmail/sync-status-bar.tsx
Normal file
94
components/gmail/sync-status-bar.tsx
Normal 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
158
lib/api/adapters.ts
Normal 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
42
lib/api/auth-store.ts
Normal 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
151
lib/api/client.ts
Normal 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")
|
||||
218
lib/api/hooks/use-compose-mutations.ts
Normal file
218
lib/api/hooks/use-compose-mutations.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
114
lib/api/hooks/use-contact-mutations.ts
Normal file
114
lib/api/hooks/use-contact-mutations.ts
Normal 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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
74
lib/api/hooks/use-contact-queries.ts
Normal file
74
lib/api/hooks/use-contact-queries.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
80
lib/api/hooks/use-folder-label-queries.ts
Normal file
80
lib/api/hooks/use-folder-label-queries.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
200
lib/api/hooks/use-mail-mutations.ts
Normal file
200
lib/api/hooks/use-mail-mutations.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
112
lib/api/hooks/use-mail-queries.ts
Normal file
112
lib/api/hooks/use-mail-queries.ts
Normal 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
106
lib/api/offline-queue.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
67
lib/api/query-provider.tsx
Normal file
67
lib/api/query-provider.tsx
Normal 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
127
lib/api/types.ts
Normal 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
|
||||
}
|
||||
25
lib/api/use-network-status.ts
Normal file
25
lib/api/use-network-status.ts
Normal 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
129
lib/api/ws.ts
Normal 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])
|
||||
}
|
||||
@ -3,18 +3,10 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import {
|
||||
findDuplicatePairs,
|
||||
mergePairKey,
|
||||
normalizePhone,
|
||||
type DuplicateMatchReason,
|
||||
} from "./duplicate-detection"
|
||||
import { MOCK_FULL_CONTACTS } from "./mock-data"
|
||||
import type { FullContact } from "./types"
|
||||
|
||||
type ContactsView = "list" | "view" | "create" | "edit"
|
||||
|
||||
/** Prefill for "Nouveau contact" opened from hover card / elsewhere. */
|
||||
export type ContactCreateDraft = {
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
@ -27,20 +19,7 @@ export interface DeletedContact {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface MergeSuggestion {
|
||||
contactA: FullContact
|
||||
contactB: FullContact
|
||||
reason: DuplicateMatchReason
|
||||
}
|
||||
|
||||
export interface CoordinateSuggestion {
|
||||
contact: FullContact
|
||||
suggestedField: string
|
||||
suggestedValue: string
|
||||
}
|
||||
|
||||
interface ContactsState {
|
||||
contacts: FullContact[]
|
||||
deletedContacts: DeletedContact[]
|
||||
ignoredMergePairs: string[]
|
||||
panelOpen: boolean
|
||||
@ -62,61 +41,17 @@ interface ContactsActions {
|
||||
showContactsList: () => void
|
||||
setSearchQuery: (q: string) => void
|
||||
setSearchMode: (active: boolean) => void
|
||||
addContact: (
|
||||
contact: Omit<FullContact, "id" | "createdAt" | "updatedAt">
|
||||
) => string
|
||||
addContacts: (
|
||||
contacts: Omit<FullContact, "id" | "createdAt" | "updatedAt">[]
|
||||
) => number
|
||||
updateContact: (id: string, patch: Partial<FullContact>) => void
|
||||
deleteContact: (id: string) => void
|
||||
softDeleteContact: (id: string, reason?: string) => void
|
||||
softDeleteContact: (contact: FullContact, reason?: string) => void
|
||||
restoreContact: (id: string) => void
|
||||
emptyTrash: () => void
|
||||
mergeContacts: (keepId: string, mergeId: string) => void
|
||||
ignoreMergePair: (idA: string, idB: string) => void
|
||||
getMergeSuggestions: () => MergeSuggestion[]
|
||||
getCoordinateSuggestions: () => CoordinateSuggestion[]
|
||||
}
|
||||
|
||||
export type ContactsStore = ContactsState & ContactsActions
|
||||
|
||||
function computeCoordinateSuggestions(contacts: FullContact[]): CoordinateSuggestion[] {
|
||||
const suggestions: CoordinateSuggestion[] = []
|
||||
const emailDomains = new Map<string, { company?: string; jobTitle?: string }>()
|
||||
|
||||
for (const c of contacts) {
|
||||
if (c.company) {
|
||||
for (const e of c.emails) {
|
||||
const domain = e.value.split("@")[1]?.toLowerCase()
|
||||
if (domain && !domain.includes("gmail") && !domain.includes("outlook") && !domain.includes("yahoo") && !domain.includes("proton")) {
|
||||
emailDomains.set(domain, { company: c.company, jobTitle: c.jobTitle })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of contacts) {
|
||||
if (c.company) continue
|
||||
for (const e of c.emails) {
|
||||
const domain = e.value.split("@")[1]?.toLowerCase()
|
||||
if (domain && emailDomains.has(domain)) {
|
||||
const info = emailDomains.get(domain)!
|
||||
if (info.company) {
|
||||
suggestions.push({ contact: c, suggestedField: "company", suggestedValue: info.company })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (suggestions.length >= 20) break
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
export const useContactsStore = create<ContactsStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
contacts: MOCK_FULL_CONTACTS,
|
||||
(set) => ({
|
||||
deletedContacts: [],
|
||||
ignoredMergePairs: [],
|
||||
panelOpen: false,
|
||||
@ -191,148 +126,38 @@ export const useContactsStore = create<ContactsStore>()(
|
||||
setSearchMode: (searchMode) =>
|
||||
set(searchMode ? { searchMode } : { searchMode, searchQuery: "" }),
|
||||
|
||||
addContact: (contact) => {
|
||||
const id = `contact-${crypto.randomUUID()}`
|
||||
const now = Date.now()
|
||||
const full: FullContact = { ...contact, id, createdAt: now, updatedAt: now }
|
||||
set((s) => ({ contacts: [...s.contacts, full] }))
|
||||
return id
|
||||
},
|
||||
|
||||
addContacts: (incoming) => {
|
||||
if (incoming.length === 0) return 0
|
||||
const now = Date.now()
|
||||
const added = incoming.map((contact) => ({
|
||||
...contact,
|
||||
id: `contact-${crypto.randomUUID()}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
set((s) => ({ contacts: [...s.contacts, ...added] }))
|
||||
return added.length
|
||||
},
|
||||
|
||||
updateContact: (id, patch) =>
|
||||
softDeleteContact: (contact, reason = "Supprimé manuellement") =>
|
||||
set((s) => ({
|
||||
contacts: s.contacts.map((c) =>
|
||||
c.id === id ? { ...c, ...patch, updatedAt: Date.now() } : c
|
||||
),
|
||||
deletedContacts: [
|
||||
...s.deletedContacts,
|
||||
{ contact, deletedAt: Date.now(), reason },
|
||||
],
|
||||
activeContactId: s.activeContactId === contact.id ? null : s.activeContactId,
|
||||
view: s.activeContactId === contact.id ? "list" : s.view,
|
||||
})),
|
||||
|
||||
deleteContact: (id) =>
|
||||
set((s) => ({
|
||||
contacts: s.contacts.filter((c) => c.id !== id),
|
||||
activeContactId: s.activeContactId === id ? null : s.activeContactId,
|
||||
view: s.activeContactId === id ? "list" : s.view,
|
||||
})),
|
||||
|
||||
softDeleteContact: (id, reason = "Supprimé manuellement") =>
|
||||
set((s) => {
|
||||
const contact = s.contacts.find((c) => c.id === id)
|
||||
if (!contact) return s
|
||||
return {
|
||||
contacts: s.contacts.filter((c) => c.id !== id),
|
||||
deletedContacts: [
|
||||
...s.deletedContacts,
|
||||
{ contact, deletedAt: Date.now(), reason },
|
||||
],
|
||||
activeContactId: s.activeContactId === id ? null : s.activeContactId,
|
||||
view: s.activeContactId === id ? "list" : s.view,
|
||||
}
|
||||
}),
|
||||
|
||||
restoreContact: (id) =>
|
||||
set((s) => {
|
||||
const entry = s.deletedContacts.find((d) => d.contact.id === id)
|
||||
if (!entry) return s
|
||||
return {
|
||||
contacts: [...s.contacts, entry.contact],
|
||||
deletedContacts: s.deletedContacts.filter((d) => d.contact.id !== id),
|
||||
}
|
||||
}),
|
||||
|
||||
emptyTrash: () => set({ deletedContacts: [] }),
|
||||
|
||||
mergeContacts: (keepId, mergeId) =>
|
||||
set((s) => {
|
||||
const keep = s.contacts.find((c) => c.id === keepId)
|
||||
const merge = s.contacts.find((c) => c.id === mergeId)
|
||||
if (!keep || !merge) return s
|
||||
|
||||
const mergedEmails = [...keep.emails]
|
||||
for (const e of merge.emails) {
|
||||
if (!mergedEmails.some((me) => me.value.toLowerCase() === e.value.toLowerCase())) {
|
||||
mergedEmails.push(e)
|
||||
}
|
||||
}
|
||||
const mergedPhones = [...keep.phones]
|
||||
for (const p of merge.phones) {
|
||||
const norm = normalizePhone(p.value)
|
||||
if (
|
||||
!mergedPhones.some(
|
||||
(mp) => normalizePhone(mp.value) === norm && norm.length > 0
|
||||
)
|
||||
) {
|
||||
mergedPhones.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
const mergedLabels = [
|
||||
...new Set([...(keep.labels ?? []), ...(merge.labels ?? [])]),
|
||||
]
|
||||
|
||||
const merged: FullContact = {
|
||||
...keep,
|
||||
firstName: keep.firstName || merge.firstName,
|
||||
lastName: keep.lastName || merge.lastName,
|
||||
emails: mergedEmails,
|
||||
phones: mergedPhones,
|
||||
labels: mergedLabels.length ? mergedLabels : undefined,
|
||||
company: keep.company || merge.company,
|
||||
jobTitle: keep.jobTitle || merge.jobTitle,
|
||||
department: keep.department || merge.department,
|
||||
birthday: keep.birthday || merge.birthday,
|
||||
avatarUrl: keep.avatarUrl || merge.avatarUrl,
|
||||
notes: [keep.notes, merge.notes].filter(Boolean).join("\n") || undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
const pairKey = mergePairKey(keepId, mergeId)
|
||||
|
||||
return {
|
||||
contacts: s.contacts
|
||||
.filter((c) => c.id !== mergeId)
|
||||
.map((c) => (c.id === keepId ? merged : c)),
|
||||
ignoredMergePairs: s.ignoredMergePairs.includes(pairKey)
|
||||
? s.ignoredMergePairs
|
||||
: [...s.ignoredMergePairs, pairKey],
|
||||
}
|
||||
}),
|
||||
|
||||
ignoreMergePair: (idA, idB) =>
|
||||
set((s) => {
|
||||
const key = mergePairKey(idA, idB)
|
||||
const key = [idA, idB].sort().join("::")
|
||||
if (s.ignoredMergePairs.includes(key)) return s
|
||||
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
|
||||
}),
|
||||
|
||||
getMergeSuggestions: () => {
|
||||
const s = get()
|
||||
const ignored = new Set(s.ignoredMergePairs)
|
||||
return findDuplicatePairs(s.contacts, ignored).map((p) => ({
|
||||
contactA: p.contactA,
|
||||
contactB: p.contactB,
|
||||
reason: p.reason,
|
||||
}))
|
||||
},
|
||||
|
||||
getCoordinateSuggestions: () => computeCoordinateSuggestions(get().contacts),
|
||||
}),
|
||||
{
|
||||
name: "contacts-store",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
partialize: (state) => ({
|
||||
contacts: state.contacts,
|
||||
deletedContacts: state.deletedContacts,
|
||||
ignoredMergePairs: state.ignoredMergePairs,
|
||||
}),
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
export { type FullContact, fullContactDisplayName, toComposeContact } from "./types"
|
||||
export { MOCK_FULL_CONTACTS } from "./mock-data"
|
||||
export { type FullContact, type MergeSuggestion, type CoordinateSuggestion, fullContactDisplayName, toComposeContact } from "./types"
|
||||
export { useContactsStore, type ContactsStore } from "./contacts-store"
|
||||
export { searchContacts } from "./fuzzy-search"
|
||||
export {
|
||||
@ -32,6 +31,5 @@ export {
|
||||
export type {
|
||||
ContactCreateDraft,
|
||||
DeletedContact,
|
||||
MergeSuggestion,
|
||||
CoordinateSuggestion,
|
||||
} from "./contacts-store"
|
||||
export type { DuplicateMatchReason } from "./duplicate-detection"
|
||||
|
||||
@ -35,6 +35,18 @@ export interface FullContact {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface MergeSuggestion {
|
||||
contactA: FullContact
|
||||
contactB: FullContact
|
||||
reason: import("./duplicate-detection").DuplicateMatchReason
|
||||
}
|
||||
|
||||
export interface CoordinateSuggestion {
|
||||
contact: FullContact
|
||||
suggestedField: string
|
||||
suggestedValue: string
|
||||
}
|
||||
|
||||
export function fullContactDisplayName(c: FullContact): string {
|
||||
return `${c.firstName} ${c.lastName}`.trim()
|
||||
}
|
||||
|
||||
10
lib/contacts/use-contacts-list.ts
Normal file
10
lib/contacts/use-contacts-list.ts
Normal 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 }
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import type { LabelEditState } from "@/lib/stores/mail-store"
|
||||
|
||||
export type LabelEditState = {
|
||||
additions: Record<string, string[]>
|
||||
removals: Record<string, string[]>
|
||||
}
|
||||
|
||||
export function effectiveLabels(
|
||||
email: Email | undefined,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import { effectiveLabels } from "@/lib/label-edits"
|
||||
import type { LabelEditState } from "@/lib/stores/mail-store"
|
||||
import type { LabelEditState } from "@/lib/label-edits"
|
||||
|
||||
/** Libellés système exclus du picker « Ajouter le libellé ». */
|
||||
export const LABEL_PICKER_EXCLUDE = new Set([
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import { applyLabelEditsToEmails, mergeEmailNotSpam } from "@/lib/label-edits"
|
||||
import type { LabelEditState } from "@/lib/label-edits"
|
||||
import {
|
||||
emailMatchesFolder,
|
||||
type MailFolderFilterCtx,
|
||||
type MailNavFolderMaps,
|
||||
} from "@/lib/mail-folder-filter"
|
||||
import type { LabelEditState } from "@/lib/stores/mail-store"
|
||||
import {
|
||||
folderTree as defaultFolderTree,
|
||||
sidebarNavFolderIdToLabel,
|
||||
|
||||
@ -4,21 +4,28 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
|
||||
import {
|
||||
useScheduledStore,
|
||||
type ScheduleSendPayload,
|
||||
type OutboxEntry,
|
||||
} from "@/lib/stores/scheduled-store"
|
||||
import {
|
||||
useScheduleSend,
|
||||
useRescheduleSend,
|
||||
useCancelScheduled,
|
||||
useSendNow,
|
||||
} from "@/lib/api/hooks/use-compose-mutations"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
|
||||
export type { ScheduleSendPayload } from "@/lib/stores/scheduled-store"
|
||||
export type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
|
||||
|
||||
type ScheduledMailContextValue = {
|
||||
scheduledEmails: Email[]
|
||||
scheduledEmails: OutboxEntry[]
|
||||
snoozedEmails: Email[]
|
||||
sentPlaceholderEmails: Email[]
|
||||
refreshAll: () => Promise<void>
|
||||
scheduleSend: (payload: ScheduleSendPayload) => Promise<{ id: string }>
|
||||
removeScheduledLocal: (id: string) => void
|
||||
requestDeleteScheduled: (id: string) => Promise<void>
|
||||
@ -35,38 +42,171 @@ type ScheduledMailContextValue = {
|
||||
|
||||
const ScheduledMailContext = createContext<ScheduledMailContextValue | null>(null)
|
||||
|
||||
const noop = async () => {}
|
||||
|
||||
export function ScheduledMailProvider({ children }: { children: ReactNode }) {
|
||||
const scheduledEmails = useScheduledStore((s) => s.scheduledEmails)
|
||||
const snoozedEmails = useScheduledStore((s) => s.snoozedEmails)
|
||||
const sentPlaceholderEmails = useScheduledStore((s) => s.sentPlaceholderEmails)
|
||||
const account = useActiveAccount()
|
||||
|
||||
const value = useMemo<ScheduledMailContextValue>(() => {
|
||||
const actions = useScheduledStore.getState()
|
||||
return {
|
||||
const scheduleSendMutation = useScheduleSend()
|
||||
const rescheduleMutation = useRescheduleSend()
|
||||
const cancelMutation = useCancelScheduled()
|
||||
const sendNowMutation = useSendNow()
|
||||
|
||||
const scheduleSend = useCallback(
|
||||
async (payload: ScheduleSendPayload): Promise<{ id: string }> => {
|
||||
const accountId = account?.id ?? ""
|
||||
const result = await scheduleSendMutation.mutateAsync({
|
||||
account_id: accountId,
|
||||
to: payload.to.map((r) => ({ name: r.name, address: r.email })),
|
||||
subject: payload.subject,
|
||||
body_html: payload.bodyHtml,
|
||||
idempotency_key: `sched-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
scheduled_at: payload.sendAtIso,
|
||||
})
|
||||
|
||||
const id = result?.id ?? `local-${Date.now()}`
|
||||
const entry: OutboxEntry = {
|
||||
id,
|
||||
account_id: accountId,
|
||||
status: "scheduled",
|
||||
subject: payload.subject,
|
||||
to: payload.to.map((r) => ({ name: r.name, address: r.email })),
|
||||
scheduled_at: payload.sendAtIso,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
useScheduledStore.getState().addScheduledEmail(entry)
|
||||
return { id }
|
||||
},
|
||||
[scheduleSendMutation, account?.id]
|
||||
)
|
||||
|
||||
const removeScheduledLocal = useCallback((id: string) => {
|
||||
useScheduledStore.getState().removeScheduled(id)
|
||||
}, [])
|
||||
|
||||
const requestDeleteScheduled = useCallback(
|
||||
async (id: string) => {
|
||||
await cancelMutation.mutateAsync({ id })
|
||||
useScheduledStore.getState().removeScheduled(id)
|
||||
},
|
||||
[cancelMutation]
|
||||
)
|
||||
|
||||
const requestArchiveScheduled = useCallback(
|
||||
async (id: string) => {
|
||||
await cancelMutation.mutateAsync({ id })
|
||||
useScheduledStore.getState().removeScheduled(id)
|
||||
},
|
||||
[cancelMutation]
|
||||
)
|
||||
|
||||
const requestSnoozeScheduled = useCallback(
|
||||
async (id: string) => {
|
||||
await cancelMutation.mutateAsync({ id })
|
||||
useScheduledStore.getState().removeScheduled(id)
|
||||
},
|
||||
[cancelMutation]
|
||||
)
|
||||
|
||||
const requestToggleReadScheduled = useCallback(
|
||||
async (_id: string, _read: boolean) => {},
|
||||
[]
|
||||
)
|
||||
|
||||
const requestRescheduleScheduled = useCallback(
|
||||
async (id: string, sendAtIso: string) => {
|
||||
await rescheduleMutation.mutateAsync({ id, scheduled_at: sendAtIso })
|
||||
const store = useScheduledStore.getState()
|
||||
const existing = store.scheduledEmails.find((e) => e.id === id)
|
||||
if (existing) {
|
||||
store.addScheduledEmail({ ...existing, scheduled_at: sendAtIso })
|
||||
}
|
||||
},
|
||||
[rescheduleMutation]
|
||||
)
|
||||
|
||||
const requestGetScheduledEditPayload = useCallback(
|
||||
async (id: string): Promise<ScheduleSendPayload | null> => {
|
||||
const entry = useScheduledStore.getState().scheduledEmails.find((e) => e.id === id)
|
||||
if (!entry) return null
|
||||
return {
|
||||
sendAtIso: entry.scheduled_at ?? new Date().toISOString(),
|
||||
to: entry.to.map((r) => ({ name: r.name, email: r.address })),
|
||||
subject: entry.subject,
|
||||
previewText: "",
|
||||
bodyHtml: "",
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const requestUpdateScheduledSend = useCallback(
|
||||
async (id: string, payload: ScheduleSendPayload) => {
|
||||
await rescheduleMutation.mutateAsync({ id, scheduled_at: payload.sendAtIso })
|
||||
const entry: OutboxEntry = {
|
||||
id,
|
||||
account_id: account?.id ?? "",
|
||||
status: "scheduled",
|
||||
subject: payload.subject,
|
||||
to: payload.to.map((r) => ({ name: r.name, address: r.email })),
|
||||
scheduled_at: payload.sendAtIso,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
useScheduledStore.getState().addScheduledEmail(entry)
|
||||
},
|
||||
[rescheduleMutation, account?.id]
|
||||
)
|
||||
|
||||
const requestSendScheduledNow = useCallback(
|
||||
async (id: string) => {
|
||||
await sendNowMutation.mutateAsync({ id })
|
||||
useScheduledStore.getState().removeScheduled(id)
|
||||
},
|
||||
[sendNowMutation]
|
||||
)
|
||||
|
||||
const requestSnoozeMailboxEmail = useCallback(async (row: Email) => {
|
||||
useScheduledStore.getState().snoozeMailboxEmail(row)
|
||||
}, [])
|
||||
|
||||
const requestRestoreSnoozedToInbox = useCallback(async (row: Email) => {
|
||||
useScheduledStore.getState().restoreSnoozedToInbox(row)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ScheduledMailContextValue>(
|
||||
() => ({
|
||||
scheduledEmails,
|
||||
snoozedEmails,
|
||||
sentPlaceholderEmails,
|
||||
refreshAll: noop,
|
||||
scheduleSend: async (payload) => actions.createScheduledSend(payload),
|
||||
removeScheduledLocal: (id) => actions.removeScheduledLocal(id),
|
||||
requestDeleteScheduled: async (id) => { actions.deleteScheduledSend(id) },
|
||||
requestArchiveScheduled: async (id) => { actions.archiveScheduledSend(id) },
|
||||
requestSnoozeScheduled: async (id) => { actions.snoozeScheduledSend(id) },
|
||||
requestToggleReadScheduled: async (id, read) => { actions.markScheduledReadState(id, read) },
|
||||
requestRescheduleScheduled: async (id, sendAtIso) => { actions.rescheduleScheduledSend(id, sendAtIso) },
|
||||
requestGetScheduledEditPayload: async (id) => actions.getScheduledEditPayload(id),
|
||||
requestUpdateScheduledSend: async (id, payload) => { actions.updateScheduledSend(id, payload) },
|
||||
requestSendScheduledNow: async (id) => { actions.sendScheduledNow(id) },
|
||||
requestSnoozeMailboxEmail: async (row) => {
|
||||
actions.snoozeMailboxEmail(row)
|
||||
},
|
||||
requestRestoreSnoozedToInbox: async (row) => {
|
||||
actions.restoreSnoozedToInbox(row)
|
||||
},
|
||||
}
|
||||
}, [scheduledEmails, snoozedEmails, sentPlaceholderEmails])
|
||||
scheduleSend,
|
||||
removeScheduledLocal,
|
||||
requestDeleteScheduled,
|
||||
requestArchiveScheduled,
|
||||
requestSnoozeScheduled,
|
||||
requestToggleReadScheduled,
|
||||
requestRescheduleScheduled,
|
||||
requestGetScheduledEditPayload,
|
||||
requestUpdateScheduledSend,
|
||||
requestSendScheduledNow,
|
||||
requestSnoozeMailboxEmail,
|
||||
requestRestoreSnoozedToInbox,
|
||||
}),
|
||||
[
|
||||
scheduledEmails,
|
||||
snoozedEmails,
|
||||
scheduleSend,
|
||||
removeScheduledLocal,
|
||||
requestDeleteScheduled,
|
||||
requestArchiveScheduled,
|
||||
requestSnoozeScheduled,
|
||||
requestToggleReadScheduled,
|
||||
requestRescheduleScheduled,
|
||||
requestGetScheduledEditPayload,
|
||||
requestUpdateScheduledSend,
|
||||
requestSendScheduledNow,
|
||||
requestSnoozeMailboxEmail,
|
||||
requestRestoreSnoozedToInbox,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<ScheduledMailContext.Provider value={value}>
|
||||
|
||||
@ -1,54 +1,40 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
MOCK_USER_ACCOUNTS,
|
||||
} from "@/lib/accounts/mock-accounts"
|
||||
import type { UserAccount } from "@/lib/accounts/types"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '@/lib/api/auth-store'
|
||||
import { useMailAccounts } from '@/lib/api/hooks/use-mail-queries'
|
||||
import { debouncedPersistJSONStorage } from '@/lib/stores/debounced-json-storage'
|
||||
import type { ApiMailAccount } from '@/lib/api/types'
|
||||
|
||||
type AccountStoreState = {
|
||||
activeAccountId: string
|
||||
activeAccountId: string | null
|
||||
otherAccountsExpanded: boolean
|
||||
}
|
||||
|
||||
type AccountStoreActions = {
|
||||
setActiveAccount: (id: string) => void
|
||||
setActiveAccountId: (id: string | null) => void
|
||||
setOtherAccountsExpanded: (expanded: boolean) => void
|
||||
toggleOtherAccountsExpanded: () => void
|
||||
signOutAll: () => void
|
||||
}
|
||||
|
||||
export function getAccountById(id: string): UserAccount | undefined {
|
||||
return MOCK_USER_ACCOUNTS.find((a) => a.id === id)
|
||||
}
|
||||
|
||||
export function useActiveAccount(): UserAccount {
|
||||
const activeAccountId = useAccountStore((s) => s.activeAccountId)
|
||||
return getAccountById(activeAccountId) ?? MOCK_USER_ACCOUNTS[0]!
|
||||
}
|
||||
|
||||
export const useAccountStore = create<AccountStoreState & AccountStoreActions>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeAccountId: DEFAULT_ACCOUNT_ID,
|
||||
activeAccountId: null,
|
||||
otherAccountsExpanded: true,
|
||||
|
||||
setActiveAccount: (id) => set({ activeAccountId: id }),
|
||||
setActiveAccountId: (id) => set({ activeAccountId: id }),
|
||||
|
||||
setOtherAccountsExpanded: (expanded) =>
|
||||
set({ otherAccountsExpanded: expanded }),
|
||||
|
||||
toggleOtherAccountsExpanded: () =>
|
||||
set((s) => ({ otherAccountsExpanded: !s.otherAccountsExpanded })),
|
||||
|
||||
signOutAll: () =>
|
||||
set({ activeAccountId: DEFAULT_ACCOUNT_ID, otherAccountsExpanded: true }),
|
||||
}),
|
||||
{
|
||||
name: "ultimail-accounts",
|
||||
name: 'ultimail-accounts',
|
||||
storage: debouncedPersistJSONStorage,
|
||||
partialize: (s) => ({
|
||||
activeAccountId: s.activeAccountId,
|
||||
@ -57,3 +43,19 @@ export const useAccountStore = create<AccountStoreState & AccountStoreActions>()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export function useActiveAccount(): ApiMailAccount | null {
|
||||
const activeAccountId = useAccountStore((s) => s.activeAccountId)
|
||||
const { data: accounts } = useMailAccounts()
|
||||
return accounts?.find((a) => a.id === activeAccountId) ?? accounts?.[0] ?? null
|
||||
}
|
||||
|
||||
export function useSignOutAll() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return () => {
|
||||
useAuthStore.getState().logout()
|
||||
queryClient.clear()
|
||||
useAccountStore.setState({ activeAccountId: null, otherAccountsExpanded: true })
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,48 +4,14 @@ import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
|
||||
/**
|
||||
* Persistent mail store — survives across navigations and page reloads.
|
||||
* Tracks user-driven mutations on top of the static `emails` array from email-data.ts.
|
||||
* Designed for future server sync: every action is a discrete delta.
|
||||
*/
|
||||
|
||||
export type LabelEditState = {
|
||||
additions: Record<string, string[]>
|
||||
removals: Record<string, string[]>
|
||||
}
|
||||
|
||||
type MailStoreState = {
|
||||
readOverrides: Record<string, boolean>
|
||||
starredIds: string[]
|
||||
importantIds: string[]
|
||||
labelEdits: LabelEditState
|
||||
hiddenEmailIds: string[]
|
||||
seenEmailIds: string[]
|
||||
/** Ids marqués comme non-spam (réintégration boîte de réception dans l’UI). */
|
||||
notSpamEmailIds: string[]
|
||||
recentMoveTargets: string[]
|
||||
/** Dernières boîtes visitées (clés `mailNavVisitKey`), la plus récente en tête. */
|
||||
recentFolderVisits: string[]
|
||||
}
|
||||
|
||||
type MailStoreActions = {
|
||||
setReadOverride: (id: string, read: boolean) => void
|
||||
setReadOverrides: (overrides: Record<string, boolean>) => void
|
||||
toggleStar: (id: string) => void
|
||||
setStar: (id: string, starred: boolean) => void
|
||||
toggleImportant: (id: string) => void
|
||||
setImportant: (id: string, important: boolean) => void
|
||||
addLabel: (emailId: string, label: string) => void
|
||||
removeLabel: (emailId: string, label: string) => void
|
||||
setLabelEdits: (updater: (prev: LabelEditState) => LabelEditState) => void
|
||||
hideEmail: (id: string) => void
|
||||
hideEmails: (ids: string[]) => void
|
||||
unhideEmail: (id: string) => void
|
||||
markSeen: (id: string) => void
|
||||
/** Réintègre le message comme non-spam (liste / boîte de réception). */
|
||||
markNotSpam: (id: string) => void
|
||||
resetHidden: () => void
|
||||
pushRecentMoveTarget: (targetId: string) => void
|
||||
pushRecentFolderVisit: (visitKey: string) => void
|
||||
}
|
||||
@ -53,115 +19,10 @@ type MailStoreActions = {
|
||||
export const useMailStore = create<MailStoreState & MailStoreActions>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
readOverrides: {},
|
||||
starredIds: [],
|
||||
importantIds: [],
|
||||
labelEdits: { additions: {}, removals: {} },
|
||||
hiddenEmailIds: [],
|
||||
seenEmailIds: [],
|
||||
notSpamEmailIds: [],
|
||||
recentMoveTargets: [],
|
||||
recentFolderVisits: [],
|
||||
|
||||
setReadOverride: (id, read) =>
|
||||
set((s) => ({ readOverrides: { ...s.readOverrides, [id]: read } })),
|
||||
|
||||
setReadOverrides: (overrides) =>
|
||||
set((s) => ({ readOverrides: { ...s.readOverrides, ...overrides } })),
|
||||
|
||||
toggleStar: (id) =>
|
||||
set((s) => ({
|
||||
starredIds: s.starredIds.includes(id)
|
||||
? s.starredIds.filter((x) => x !== id)
|
||||
: [...s.starredIds, id],
|
||||
})),
|
||||
|
||||
setStar: (id, starred) =>
|
||||
set((s) => ({
|
||||
starredIds: starred
|
||||
? s.starredIds.includes(id) ? s.starredIds : [...s.starredIds, id]
|
||||
: s.starredIds.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
toggleImportant: (id) =>
|
||||
set((s) => ({
|
||||
importantIds: s.importantIds.includes(id)
|
||||
? s.importantIds.filter((x) => x !== id)
|
||||
: [...s.importantIds, id],
|
||||
})),
|
||||
|
||||
setImportant: (id, important) =>
|
||||
set((s) => ({
|
||||
importantIds: important
|
||||
? s.importantIds.includes(id) ? s.importantIds : [...s.importantIds, id]
|
||||
: s.importantIds.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
addLabel: (emailId, label) =>
|
||||
set((s) => {
|
||||
const curr = s.labelEdits.additions[emailId] ?? []
|
||||
if (curr.some((l) => l.toLowerCase() === label.toLowerCase())) return s
|
||||
return {
|
||||
labelEdits: {
|
||||
additions: { ...s.labelEdits.additions, [emailId]: [...curr, label] },
|
||||
removals: {
|
||||
...s.labelEdits.removals,
|
||||
[emailId]: (s.labelEdits.removals[emailId] ?? []).filter(
|
||||
(r) => r.toLowerCase() !== label.toLowerCase()
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
removeLabel: (emailId, label) =>
|
||||
set((s) => {
|
||||
const curr = s.labelEdits.removals[emailId] ?? []
|
||||
if (curr.some((l) => l.toLowerCase() === label.toLowerCase())) return s
|
||||
return {
|
||||
labelEdits: {
|
||||
removals: { ...s.labelEdits.removals, [emailId]: [...curr, label] },
|
||||
additions: {
|
||||
...s.labelEdits.additions,
|
||||
[emailId]: (s.labelEdits.additions[emailId] ?? []).filter(
|
||||
(a) => a.toLowerCase() !== label.toLowerCase()
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
setLabelEdits: (updater) =>
|
||||
set((s) => ({ labelEdits: updater(s.labelEdits) })),
|
||||
|
||||
hideEmail: (id) =>
|
||||
set((s) => ({
|
||||
hiddenEmailIds: s.hiddenEmailIds.includes(id)
|
||||
? s.hiddenEmailIds
|
||||
: [...s.hiddenEmailIds, id],
|
||||
})),
|
||||
|
||||
hideEmails: (ids) =>
|
||||
set((s) => {
|
||||
const existing = new Set(s.hiddenEmailIds)
|
||||
const toAdd = ids.filter((id) => !existing.has(id))
|
||||
return toAdd.length > 0
|
||||
? { hiddenEmailIds: [...s.hiddenEmailIds, ...toAdd] }
|
||||
: s
|
||||
}),
|
||||
|
||||
unhideEmail: (id) =>
|
||||
set((s) => ({
|
||||
hiddenEmailIds: s.hiddenEmailIds.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
markNotSpam: (id) =>
|
||||
set((s) =>
|
||||
s.notSpamEmailIds.includes(id)
|
||||
? s
|
||||
: { notSpamEmailIds: [...s.notSpamEmailIds, id] }
|
||||
),
|
||||
|
||||
markSeen: (id) =>
|
||||
set((s) => ({
|
||||
seenEmailIds: s.seenEmailIds.includes(id)
|
||||
@ -169,8 +30,6 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
|
||||
: [...s.seenEmailIds, id],
|
||||
})),
|
||||
|
||||
resetHidden: () => set({ hiddenEmailIds: [] }),
|
||||
|
||||
pushRecentMoveTarget: (targetId) =>
|
||||
set((s) => {
|
||||
const MAX = 5
|
||||
@ -188,16 +47,14 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
|
||||
{
|
||||
name: "ultimail-mail-state",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
version: 3,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] }
|
||||
if (version < 2) {
|
||||
return { ...state, recentFolderVisits: [], notSpamEmailIds: [] }
|
||||
version: 4,
|
||||
migrate: (persisted) => {
|
||||
const state = persisted as Record<string, unknown>
|
||||
return {
|
||||
seenEmailIds: (state.seenEmailIds as string[]) ?? [],
|
||||
recentMoveTargets: (state.recentMoveTargets as string[]) ?? [],
|
||||
recentFolderVisits: (state.recentFolderVisits as string[]) ?? [],
|
||||
}
|
||||
if (version < 3) {
|
||||
return { ...state, notSpamEmailIds: state.notSpamEmailIds ?? [] }
|
||||
}
|
||||
return state
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -5,182 +5,48 @@ import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
|
||||
export type ScheduleSendPayload = {
|
||||
sendAtIso: string
|
||||
to: { name: string; email: string }[]
|
||||
export interface OutboxEntry {
|
||||
id: string
|
||||
account_id: string
|
||||
status: "queued" | "scheduled" | "sending" | "sent" | "failed" | "cancelled"
|
||||
subject: string
|
||||
previewText: string
|
||||
bodyHtml: string
|
||||
to: { name: string; address: string }[]
|
||||
scheduled_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type ScheduledStoreState = {
|
||||
scheduledEmails: Email[]
|
||||
scheduledEmails: OutboxEntry[]
|
||||
snoozedEmails: Email[]
|
||||
sentPlaceholderEmails: Email[]
|
||||
}
|
||||
|
||||
function rowToSchedulePayload(row: Email): ScheduleSendPayload {
|
||||
const email = row.senderEmail?.trim() ?? ""
|
||||
const name = row.scheduledToName ?? row.sender
|
||||
return {
|
||||
sendAtIso: row.scheduledSendAt ?? new Date().toISOString(),
|
||||
to: email ? [{ name, email }] : [],
|
||||
subject: row.subject,
|
||||
previewText: row.preview,
|
||||
bodyHtml: row.body ?? `<p></p>`,
|
||||
}
|
||||
}
|
||||
|
||||
type ScheduledStoreActions = {
|
||||
createScheduledSend: (payload: ScheduleSendPayload) => { id: string }
|
||||
deleteScheduledSend: (id: string) => void
|
||||
archiveScheduledSend: (id: string) => void
|
||||
snoozeScheduledSend: (id: string) => void
|
||||
rescheduleScheduledSend: (id: string, sendAtIso: string) => void
|
||||
markScheduledReadState: (id: string, read: boolean) => void
|
||||
getScheduledEditPayload: (id: string) => ScheduleSendPayload | null
|
||||
updateScheduledSend: (id: string, payload: ScheduleSendPayload) => void
|
||||
sendScheduledNow: (id: string) => void
|
||||
removeScheduledLocal: (id: string) => void
|
||||
/** Mettre en attente depuis la boîte (clone id `snz-…` dans En attente ; l’appelant masque l’id source). */
|
||||
addScheduledEmail: (entry: OutboxEntry) => void
|
||||
updateScheduledStatus: (id: string, status: OutboxEntry["status"]) => void
|
||||
removeScheduled: (id: string) => void
|
||||
snoozeMailboxEmail: (row: Email) => void
|
||||
/** Quitter « En attente » : réaffiche dans la Boîte (snz-) ou parmi Planifiés (ex-envoi différé snoozé). */
|
||||
restoreSnoozedToInbox: (row: Email) => void
|
||||
}
|
||||
|
||||
export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActions>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
(set) => ({
|
||||
scheduledEmails: [],
|
||||
snoozedEmails: [],
|
||||
sentPlaceholderEmails: [],
|
||||
|
||||
createScheduledSend: (payload) => {
|
||||
const id = `sched-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
const first = payload.to[0]
|
||||
const toName = first?.name?.trim() || first?.email || "Destinataire"
|
||||
const row: Email = {
|
||||
id,
|
||||
sender: toName,
|
||||
senderEmail: first?.email,
|
||||
subject: payload.subject.trim() || "(Sans objet)",
|
||||
preview: payload.previewText.slice(0, 200),
|
||||
body: payload.bodyHtml,
|
||||
date: payload.sendAtIso,
|
||||
read: true,
|
||||
starred: false,
|
||||
important: false,
|
||||
labels: ["scheduled"],
|
||||
scheduledSendAt: payload.sendAtIso,
|
||||
scheduledToName: toName,
|
||||
}
|
||||
addScheduledEmail: (entry) =>
|
||||
set((s) => ({
|
||||
scheduledEmails: [row, ...s.scheduledEmails.filter((e) => e.id !== id)],
|
||||
}))
|
||||
return { id }
|
||||
},
|
||||
|
||||
deleteScheduledSend: (id) =>
|
||||
set((s) => ({
|
||||
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
|
||||
scheduledEmails: [entry, ...s.scheduledEmails.filter((e) => e.id !== entry.id)],
|
||||
})),
|
||||
|
||||
archiveScheduledSend: (id) =>
|
||||
set((s) => ({
|
||||
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
|
||||
})),
|
||||
|
||||
snoozeScheduledSend: (id) =>
|
||||
set((s) => {
|
||||
const row = s.scheduledEmails.find((e) => e.id === id)
|
||||
if (!row) return s
|
||||
const wake = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
|
||||
snoozedEmails: [
|
||||
{
|
||||
...row,
|
||||
labels: ["snoozed"],
|
||||
scheduledSendAt: undefined,
|
||||
scheduledToName: undefined,
|
||||
snoozeWakeAt: wake.toISOString(),
|
||||
sender: row.scheduledToName ?? row.sender,
|
||||
read: true,
|
||||
},
|
||||
...s.snoozedEmails,
|
||||
],
|
||||
}
|
||||
}),
|
||||
|
||||
rescheduleScheduledSend: (id, sendAtIso) =>
|
||||
updateScheduledStatus: (id, status) =>
|
||||
set((s) => ({
|
||||
scheduledEmails: s.scheduledEmails.map((e) =>
|
||||
e.id === id ? { ...e, scheduledSendAt: sendAtIso } : e
|
||||
e.id === id ? { ...e, status } : e
|
||||
),
|
||||
})),
|
||||
|
||||
markScheduledReadState: (id, read) =>
|
||||
set((s) => ({
|
||||
scheduledEmails: s.scheduledEmails.map((e) =>
|
||||
e.id === id ? { ...e, read } : e
|
||||
),
|
||||
})),
|
||||
|
||||
getScheduledEditPayload: (id) => {
|
||||
const row = get().scheduledEmails.find((e) => e.id === id)
|
||||
if (!row) return null
|
||||
return rowToSchedulePayload(row)
|
||||
},
|
||||
|
||||
updateScheduledSend: (id, payload) =>
|
||||
set((s) => {
|
||||
const first = payload.to[0]
|
||||
const toName = first?.name?.trim() || first?.email || "Destinataire"
|
||||
return {
|
||||
scheduledEmails: s.scheduledEmails.map((e) =>
|
||||
e.id === id
|
||||
? {
|
||||
...e,
|
||||
sender: toName,
|
||||
senderEmail: first?.email,
|
||||
subject: payload.subject.trim() || "(Sans objet)",
|
||||
preview: payload.previewText.slice(0, 200),
|
||||
body: payload.bodyHtml,
|
||||
scheduledSendAt: payload.sendAtIso,
|
||||
scheduledToName: toName,
|
||||
}
|
||||
: e
|
||||
),
|
||||
}
|
||||
}),
|
||||
|
||||
sendScheduledNow: (id) =>
|
||||
set((s) => {
|
||||
const row = s.scheduledEmails.find((e) => e.id === id)
|
||||
if (!row) return s
|
||||
const now = new Date()
|
||||
return {
|
||||
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
|
||||
sentPlaceholderEmails: [
|
||||
{
|
||||
id: `sent-now-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`,
|
||||
sender: row.scheduledToName ?? row.sender,
|
||||
senderEmail: row.senderEmail,
|
||||
subject: row.subject,
|
||||
preview: row.preview,
|
||||
body: row.body,
|
||||
date: now.toISOString(),
|
||||
read: true,
|
||||
starred: false,
|
||||
important: false,
|
||||
labels: ["sent"],
|
||||
},
|
||||
...s.sentPlaceholderEmails,
|
||||
],
|
||||
}
|
||||
}),
|
||||
|
||||
removeScheduledLocal: (id) =>
|
||||
removeScheduled: (id) =>
|
||||
set((s) => ({
|
||||
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
|
||||
})),
|
||||
@ -208,36 +74,21 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
|
||||
}),
|
||||
|
||||
restoreSnoozedToInbox: (row) =>
|
||||
set((s) => {
|
||||
const nextSnoozed = s.snoozedEmails.filter((e) => e.id !== row.id)
|
||||
if (row.id.startsWith("snz-")) {
|
||||
return { snoozedEmails: nextSnoozed }
|
||||
}
|
||||
const resumeAt =
|
||||
row.snoozeWakeAt ??
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
||||
const back: Email = {
|
||||
...row,
|
||||
labels: ["scheduled"],
|
||||
scheduledSendAt: resumeAt,
|
||||
scheduledToName: row.sender,
|
||||
snoozeWakeAt: undefined,
|
||||
date: "",
|
||||
read: true,
|
||||
}
|
||||
return {
|
||||
snoozedEmails: nextSnoozed,
|
||||
scheduledEmails: [
|
||||
back,
|
||||
...s.scheduledEmails.filter((e) => e.id !== row.id),
|
||||
],
|
||||
}
|
||||
}),
|
||||
set((s) => ({
|
||||
snoozedEmails: s.snoozedEmails.filter((e) => e.id !== row.id),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "ultimail-scheduled-state",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: (persisted) => {
|
||||
const state = persisted as Record<string, unknown>
|
||||
return {
|
||||
scheduledEmails: [],
|
||||
snoozedEmails: Array.isArray(state.snoozedEmails) ? state.snoozedEmails : [],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -53,6 +53,8 @@
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@tanstack/react-query": "^5.100.13",
|
||||
"@tanstack/react-query-persist-client": "^5.100.13",
|
||||
"@tiptap/core": "^3.23.2",
|
||||
"@tiptap/extension-color": "^3.23.2",
|
||||
"@tiptap/extension-link": "^3.23.2",
|
||||
@ -73,6 +75,7 @@
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.6",
|
||||
|
||||
@ -116,6 +116,12 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: 1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.100.13
|
||||
version: 5.100.13(react@19.2.4)
|
||||
'@tanstack/react-query-persist-client':
|
||||
specifier: ^5.100.13
|
||||
version: 5.100.13(@tanstack/react-query@5.100.13(react@19.2.4))(react@19.2.4)
|
||||
'@tiptap/core':
|
||||
specifier: ^3.23.2
|
||||
version: 3.23.2(@tiptap/pm@3.23.2)
|
||||
@ -176,6 +182,9 @@ importers:
|
||||
fuse.js:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
idb:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
input-otp:
|
||||
specifier: 1.4.2
|
||||
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -1277,6 +1286,23 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.0':
|
||||
resolution: {integrity: sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==}
|
||||
|
||||
'@tanstack/query-core@5.100.13':
|
||||
resolution: {integrity: sha512-mlKVKMTzZWGTKAC1CKOgt7axAjJ921emkEvYIp27I/PdP1yEYL/BteLY8iK35gn8hoYeKB4mgJ/ve3lrDI6/Fw==}
|
||||
|
||||
'@tanstack/query-persist-client-core@5.100.13':
|
||||
resolution: {integrity: sha512-y0er+wfRn+TL3uNQ9mUSJcoSv+DTkKN0QFFy+CLM+zZVwuQ/CCgR+ApAp7aAaU7XzPILuhM0XSgnDyMlwMIrvQ==}
|
||||
|
||||
'@tanstack/react-query-persist-client@5.100.13':
|
||||
resolution: {integrity: sha512-1Mvlkc4ay9sbdI9CuV4G3rbhSMk1lqST2lQZ0v7aLQzAEzARI9Kqz956PDhHIAVoKc6qTmwHoL7OauflcSCkNw==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^5.100.13
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-query@5.100.13':
|
||||
resolution: {integrity: sha512-HSBr8CycQEAoXsJR7KNDawBnINJEJ96Eme8oE0hCXjyodE2I97vg3IDzDJBDu18LsbzpVVJcKo80eqLfVCykxw==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tiptap/core@3.23.2':
|
||||
resolution: {integrity: sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==}
|
||||
peerDependencies:
|
||||
@ -1679,6 +1705,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
idb@8.0.3:
|
||||
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||
|
||||
input-otp@1.4.2:
|
||||
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
|
||||
peerDependencies:
|
||||
@ -3084,6 +3113,23 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
tailwindcss: 4.2.0
|
||||
|
||||
'@tanstack/query-core@5.100.13': {}
|
||||
|
||||
'@tanstack/query-persist-client-core@5.100.13':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.100.13
|
||||
|
||||
'@tanstack/react-query-persist-client@5.100.13(@tanstack/react-query@5.100.13(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/query-persist-client-core': 5.100.13
|
||||
'@tanstack/react-query': 5.100.13(react@19.2.4)
|
||||
react: 19.2.4
|
||||
|
||||
'@tanstack/react-query@5.100.13(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.100.13
|
||||
react: 19.2.4
|
||||
|
||||
'@tiptap/core@3.23.2(@tiptap/pm@3.23.2)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.23.2
|
||||
@ -3468,6 +3514,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
idb@8.0.3: {}
|
||||
|
||||
input-otp@1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user