From af669b403a0e2dfc7c7529eb0b26fd5ace1caa50 Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Thu, 1 May 2025 00:20:47 -0700 Subject: [PATCH] Add reusable hooks for email deletion and movement logic Introduced `useDelete` and `useMoveTo` hooks to centralize deletion and movement logic for emails and threads. These hooks improve code reusability, streamline background queue handling, and enhance folder-specific actions like moving to bin or spam. --- apps/mail/components/mail/thread-display.tsx | 6 +- apps/mail/hooks/driver/use-delete.ts | 50 +++++++++++ apps/mail/hooks/driver/use-move-to.ts | 89 +++++++++++++++++++ apps/mail/hooks/ui/use-background-queue.ts | 23 +++++ .../lib/hotkeys/thread-display-hotkeys.tsx | 26 ++++-- apps/mail/lib/thread-actions.ts | 26 +++--- apps/mail/locales/en.json | 1 + 7 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 apps/mail/hooks/driver/use-delete.ts create mode 100644 apps/mail/hooks/driver/use-move-to.ts create mode 100644 apps/mail/hooks/ui/use-background-queue.ts diff --git a/apps/mail/components/mail/thread-display.tsx b/apps/mail/components/mail/thread-display.tsx index 97f59a5ec..eac8a2905 100644 --- a/apps/mail/components/mail/thread-display.tsx +++ b/apps/mail/components/mail/thread-display.tsx @@ -1,6 +1,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { useTheme } from 'next-themes'; import Image from 'next/image'; @@ -33,7 +33,9 @@ import { focusedIndexAtom } from '@/hooks/use-mail-navigation'; import { backgroundQueueAtom } from '@/store/backgroundQueue'; import { handleUnsubscribe } from '@/lib/email-utils.client'; import { useThread, useThreads } from '@/hooks/use-threads'; +import { useAISidebar } from '@/components/ui/ai-sidebar'; import { markAsRead, markAsUnread } from '@/actions/mail'; +import { useHotkeysContext } from 'react-hotkeys-hook'; import { MailDisplaySkeleton } from './mail-skeleton'; import { useIsMobile } from '@/hooks/use-mobile'; import { Button } from '@/components/ui/button'; @@ -51,8 +53,6 @@ import { ParsedMessage } from '@/types'; import { useQueryState } from 'nuqs'; import { useAtom } from 'jotai'; import { toast } from 'sonner'; -import { useAISidebar } from '@/components/ui/ai-sidebar'; -import { useHotkeysContext } from 'react-hotkeys-hook'; interface ThreadDisplayProps { threadParam?: any; diff --git a/apps/mail/hooks/driver/use-delete.ts b/apps/mail/hooks/driver/use-delete.ts new file mode 100644 index 000000000..e88527eea --- /dev/null +++ b/apps/mail/hooks/driver/use-delete.ts @@ -0,0 +1,50 @@ +import useBackgroundQueue from '@/hooks/ui/use-background-queue'; +import { useMail } from '@/components/mail/use-mail'; +import { useThreads } from '@/hooks/use-threads'; +import { deleteThread } from '@/actions/mail'; +import { useStats } from '@/hooks/use-stats'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +const useDelete = () => { + const [isLoading, setIsLoading] = useState(false); + const [mail, setMail] = useMail(); + const { mutate: refetchThreads } = useThreads(); + const { mutate: refetchStats } = useStats(); + const t = useTranslations(); + const { addToQueue, deleteFromQueue } = useBackgroundQueue(); + + return { + mutate: (id: string, type: 'thread' | 'email' = 'thread') => { + setIsLoading(true); + addToQueue(id); + return toast.promise( + deleteThread({ + id, + }), + { + loading: t('common.actions.deletingMail'), + success: t('common.actions.deletedMail'), + error: (error) => { + console.error(`Error deleting ${type}:`, error); + + return t('common.actions.failedToDeleteMail'); + }, + finally: async () => { + setMail({ + ...mail, + bulkSelected: [], + }); + deleteFromQueue(id); + setIsLoading(false); + await Promise.all([refetchThreads(), refetchStats()]); + }, + }, + ); + }, + isLoading, + }; +}; + +export default useDelete; diff --git a/apps/mail/hooks/driver/use-move-to.ts b/apps/mail/hooks/driver/use-move-to.ts new file mode 100644 index 000000000..ed2c22273 --- /dev/null +++ b/apps/mail/hooks/driver/use-move-to.ts @@ -0,0 +1,89 @@ +import { moveThreadsTo, type MoveThreadOptions } from '@/lib/thread-actions'; +import useBackgroundQueue from '@/hooks/ui/use-background-queue'; +import { useMail } from '@/components/mail/use-mail'; +import { useThreads } from '@/hooks/use-threads'; +import { useStats } from '@/hooks/use-stats'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +const useMoveTo = () => { + const t = useTranslations(); + const [isLoading, setIsLoading] = useState(false); + const { mutate: refetchThreads } = useThreads(); + const { mutate: refetchStats } = useStats(); + const [mail, setMail] = useMail(); + const { addToQueue, deleteFromQueue } = useBackgroundQueue(); + + const getCopyByDestination = (to?: MoveThreadOptions['destination']) => { + switch (to) { + case 'inbox': + return { + loading: t('common.actions.movingToInbox'), + success: t('common.actions.movedToInbox'), + }; + case 'spam': + return { + loading: t('common.actions.movingToSpam'), + success: t('common.actions.movedToSpam'), + }; + case 'bin': + return { + loading: t('common.actions.movingToBin'), + success: t('common.actions.movedToBin'), + }; + case 'archive': + return { + loading: t('common.actions.archiving'), + success: t('common.actions.archived'), + }; + default: + return { + loading: t('common.actions.moving'), + success: t('common.actions.moved'), + }; + } + }; + + const mutate = ({ threadIds, currentFolder, destination }: MoveThreadOptions) => { + if (!threadIds.length) { + return; + } + + setIsLoading(true); + const promise = moveThreadsTo({ + threadIds, + currentFolder, + destination, + }); + for (const threadId of threadIds) { + addToQueue(threadId); + } + return toast.promise(promise, { + ...getCopyByDestination(destination), + error: (error) => { + console.error('Error moving thread(s):', error); + + return t('common.actions.failedToMove'); + }, + finally: async () => { + setIsLoading(false); + for (const threadId of threadIds) { + deleteFromQueue(threadId); + } + await Promise.all([refetchThreads(), refetchStats()]); + setMail({ + ...mail, + bulkSelected: [], + }); + }, + }); + }; + + return { + mutate, + isLoading, + }; +}; + +export default useMoveTo; diff --git a/apps/mail/hooks/ui/use-background-queue.ts b/apps/mail/hooks/ui/use-background-queue.ts new file mode 100644 index 000000000..f08d593ce --- /dev/null +++ b/apps/mail/hooks/ui/use-background-queue.ts @@ -0,0 +1,23 @@ +import { backgroundQueueAtom } from '@/store/backgroundQueue'; +import { useAtom } from 'jotai'; + +const useBackgroundQueue = () => { + const [backgroundQueue, setBackgroundQueue] = useAtom(backgroundQueueAtom); + + return { + addToQueue: (threadId: string) => + setBackgroundQueue({ + type: 'add', + threadId: threadId.startsWith('thread:') ? threadId : `thread:${threadId}`, + }), + deleteFromQueue: (threadId: string) => + setBackgroundQueue({ + type: 'delete', + threadId: threadId.startsWith('thread:') ? threadId : `thread:${threadId}`, + }), + clearQueue: () => setBackgroundQueue({ type: 'clear' }), + queue: backgroundQueue, + }; +}; + +export default useBackgroundQueue; diff --git a/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx b/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx index 59b99ecc4..07abfe9e5 100644 --- a/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useThread, useThreads } from '@/hooks/use-threads'; import { keyboardShortcuts } from '@/config/shortcuts'; +import useMoveTo from '@/hooks/driver/use-move-to'; +import useDelete from '@/hooks/driver/use-delete'; import { useShortcuts } from './use-hotkey-utils'; -import { deleteThread } from '@/actions/mail'; +import { useThread } from '@/hooks/use-threads'; +import { useParams } from 'next/navigation'; import { useQueryState } from 'nuqs'; -import { toast } from 'sonner'; const closeView = (event: KeyboardEvent) => { event.preventDefault(); @@ -17,6 +18,11 @@ export function ThreadDisplayHotkeys() { const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId'); const [openThreadId] = useQueryState('threadId'); const { data: thread } = useThread(openThreadId); + const params = useParams<{ + folder: string; + }>(); + const { mutate: deleteThread } = useDelete(); + const { mutate: moveTo } = useMoveTo(); const handlers = { closeView: () => closeView(new KeyboardEvent('keydown', { key: 'Escape' })), @@ -34,11 +40,15 @@ export function ThreadDisplayHotkeys() { }, delete: () => { if (!openThreadId) return; - toast.promise(deleteThread({ id: thread?.latest?.id ?? openThreadId }), { - loading: 'Deleting email...', - success: 'Email deleted', - error: 'Failed to delete email', - }); + if (params.folder === 'bin') { + deleteThread(thread?.latest?.id ?? openThreadId); + } else { + moveTo({ + threadIds: [thread?.latest?.id ?? openThreadId], + currentFolder: params.folder, + destination: 'bin', + }); + } }, }; diff --git a/apps/mail/lib/thread-actions.ts b/apps/mail/lib/thread-actions.ts index 795196e82..f3bf52703 100644 --- a/apps/mail/lib/thread-actions.ts +++ b/apps/mail/lib/thread-actions.ts @@ -4,7 +4,7 @@ import { LABELS, FOLDERS } from '@/lib/utils'; export type ThreadDestination = 'inbox' | 'archive' | 'spam' | 'bin' | null; export type FolderLocation = 'inbox' | 'archive' | 'spam' | 'sent' | 'bin' | string; -interface MoveThreadOptions { +export interface MoveThreadOptions { threadIds: string[]; currentFolder: FolderLocation; destination: ThreadDestination; @@ -43,14 +43,10 @@ export function isActionAvailable(folder: FolderLocation, action: ThreadDestinat export function getAvailableActions(folder: FolderLocation): ThreadDestination[] { const allPossibleActions: ThreadDestination[] = ['inbox', 'archive', 'spam', 'bin']; - return allPossibleActions.filter(action => isActionAvailable(folder, action)); + return allPossibleActions.filter((action) => isActionAvailable(folder, action)); } -export async function moveThreadsTo({ - threadIds, - currentFolder, - destination, -}: MoveThreadOptions) { +export async function moveThreadsTo({ threadIds, currentFolder, destination }: MoveThreadOptions) { try { if (!threadIds.length) return; const isInInbox = currentFolder === FOLDERS.INBOX || !currentFolder; @@ -60,22 +56,28 @@ export async function moveThreadsTo({ let addLabel = ''; let removeLabel = ''; - switch(destination) { + switch (destination) { case 'inbox': addLabel = LABELS.INBOX; - removeLabel = isInSpam ? LABELS.SPAM : (isInBin ? LABELS.TRASH : ''); + removeLabel = isInSpam ? LABELS.SPAM : isInBin ? LABELS.TRASH : ''; break; case 'archive': addLabel = ''; - removeLabel = isInInbox ? LABELS.INBOX : (isInSpam ? LABELS.SPAM : (isInBin ? LABELS.TRASH : '')); + removeLabel = isInInbox + ? LABELS.INBOX + : isInSpam + ? LABELS.SPAM + : isInBin + ? LABELS.TRASH + : ''; break; case 'spam': addLabel = LABELS.SPAM; - removeLabel = isInInbox ? LABELS.INBOX : (isInBin ? LABELS.TRASH : ''); + removeLabel = isInInbox ? LABELS.INBOX : isInBin ? LABELS.TRASH : ''; break; case 'bin': addLabel = LABELS.TRASH; - removeLabel = isInInbox ? LABELS.INBOX : (isInSpam ? LABELS.SPAM : ''); + removeLabel = isInInbox ? LABELS.INBOX : isInSpam ? LABELS.SPAM : ''; break; default: return; diff --git a/apps/mail/locales/en.json b/apps/mail/locales/en.json index 7e37fad97..0f4584564 100644 --- a/apps/mail/locales/en.json +++ b/apps/mail/locales/en.json @@ -15,6 +15,7 @@ "loading": "Loading...", "featureNotImplemented": "This feature is not implemented yet", "moving": "Moving...", + "moved": "Moved", "movedToInbox": "Moved to inbox", "movingToInbox": "Moving to inbox...", "movedToSpam": "Moved to spam",