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.
This commit is contained in:
Dak Washbrook
2025-05-01 00:20:47 -07:00
parent 8416139bd7
commit af669b403a
7 changed files with 198 additions and 23 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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",