ultisuite-client/lib/api/hooks/use-mail-mutations.ts
R3D347HR4Y c87670e90f
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
feat(api): offline-first mail sync w/ TanStack Query
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.
2026-05-23 00:04:28 +02:00

201 lines
5.9 KiB
TypeScript

'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'] })
},
})
}