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