Files
Zero/apps/mail/components/context/thread-context.tsx
Adam 7f0893b669 all round fixes (#1697)
# Add "Open in New Tab" Feature and Fix Email Handling

## Description

This PR adds an "Open in New Tab" feature to the thread context menu, allowing users to open email threads in a new browser tab. It also fixes several issues with email handling:

1. Adds an "Open in New Tab" option to the thread context menu with an ExternalLink icon
2. Improves draft handling by saving drafts before sending emails
3. Fixes the identification of the latest email in threads by properly filtering out drafts
4. Updates the styling of the "Mark as Important" icon in the thread context menu
5. Removes unnecessary console.log statements and async/await usage where not needed
6. Fixes the web search tool to work with or without data streaming

## Type of Change

- [x] 🐛 Bug fix (non-breaking change which fixes an issue)
- [x]  New feature (non-breaking change which adds functionality)
- [x] 🎨 UI/UX improvement
- [x]  Performance improvement

## Areas Affected

- [x] Email Integration (Gmail, IMAP, etc.)
- [x] User Interface/Experience
- [x] Data Storage/Management

## Testing Done

- [x] Manual testing performed

## Checklist

- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings

## Additional Notes

The "Open in New Tab" feature improves workflow for users who need to reference multiple email threads simultaneously. The draft handling improvements ensure that emails are properly saved before sending, preventing data loss.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added "Open in New Tab" action to the email thread context menu for easier access to threads in a new browser tab.

* **Improvements**
  * Drafts are now saved automatically before sending emails, ensuring no unsaved changes are lost.
  * More accurate detection of the latest email in a thread.
  * Localization updated with a new string for the "Open in New Tab" action.
  * Email and thread operations now consistently use an updated agent interface for improved backend handling.

* **Bug Fixes**
  * Latest message and reply counts now exclude drafts, improving thread accuracy.

* **Style**
  * Minor visual updates to icons and CSS class ordering.
  * Code formatting and import reordering for consistency.

* **Chores**
  * Internal state updates are now handled synchronously for improved performance.
  * Removed unnecessary logging and simplified cache management.
  * Removed deprecated notification function and related imports.
  * Simplified query cache clearing on account switch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 15:58:45 -07:00

509 lines
15 KiB
TypeScript

import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '../ui/context-menu';
import {
Archive,
ArchiveX,
ExternalLink,
Forward,
Inbox,
MailOpen,
Reply,
ReplyAll,
Star,
StarOff,
Tag,
Trash,
} from 'lucide-react';
import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-state';
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
import { type ThreadDestination } from '@/lib/thread-actions';
import { useThread, useThreads } from '@/hooks/use-threads';
import { ExclamationCircle, Mail } from '../icons/icons';
import { useMemo, type ReactNode } from 'react';
import { useLabels } from '@/hooks/use-labels';
import { FOLDERS, LABELS } from '@/lib/utils';
import { useMail } from '../mail/use-mail';
import { Checkbox } from '../ui/checkbox';
import { m } from '@/paraglide/messages';
import { useParams } from 'react-router';
import { useQueryState } from 'nuqs';
import { toast } from 'sonner';
interface EmailAction {
id: string;
label: string | ReactNode;
icon?: ReactNode;
shortcut?: string;
action: () => void;
disabled?: boolean;
condition?: () => boolean;
}
interface EmailContextMenuProps {
children: ReactNode;
threadId: string;
isInbox?: boolean;
isSpam?: boolean;
isSent?: boolean;
isBin?: boolean;
refreshCallback?: () => void;
}
const LabelsList = ({ threadId, bulkSelected }: { threadId: string; bulkSelected: string[] }) => {
const { userLabels: labels } = useLabels();
const { optimisticToggleLabel } = useOptimisticActions();
const targetThreadIds = bulkSelected.length > 0 ? bulkSelected : [threadId];
const { data: thread } = useThread(threadId);
const rightClickedThreadOptimisticState = useOptimisticThreadState(threadId);
if (!labels || !thread) return null;
const handleToggleLabel = async (labelId: string) => {
if (!labelId) return;
let shouldAddLabel = false;
let hasLabel = thread.labels?.map((label) => label.id).includes(labelId) || false;
if (rightClickedThreadOptimisticState.optimisticLabels) {
if (rightClickedThreadOptimisticState.optimisticLabels.addedLabelIds.includes(labelId)) {
hasLabel = true;
} else if (
rightClickedThreadOptimisticState.optimisticLabels.removedLabelIds.includes(labelId)
) {
hasLabel = false;
}
}
shouldAddLabel = !hasLabel;
optimisticToggleLabel(targetThreadIds, labelId, shouldAddLabel);
};
return (
<>
{labels
.filter((label) => label.id)
.map((label) => {
let isChecked = label.id ? thread.labels?.map((l) => l.id).includes(label.id) : false;
const checkboxOptimisticState = useOptimisticThreadState(threadId);
if (label.id && checkboxOptimisticState.optimisticLabels) {
if (checkboxOptimisticState.optimisticLabels.addedLabelIds.includes(label.id)) {
isChecked = true;
} else if (
checkboxOptimisticState.optimisticLabels.removedLabelIds.includes(label.id)
) {
isChecked = false;
}
}
return (
<ContextMenuItem
key={label.id}
onClick={() => label.id && handleToggleLabel(label.id)}
className="font-normal"
>
<div className="flex items-center">
<Checkbox checked={isChecked} className="mr-2 h-4 w-4" />
{label.name}
</div>
</ContextMenuItem>
);
})}
</>
);
};
export function ThreadContextMenu({
children,
threadId,
isInbox = true,
isSpam = false,
isSent = false,
isBin = false,
}: EmailContextMenuProps) {
const { folder } = useParams<{ folder: string }>();
const [mail, setMail] = useMail();
const [{ isLoading, isFetching }] = useThreads();
const currentFolder = folder ?? '';
const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE;
const [, setMode] = useQueryState('mode');
const [, setThreadId] = useQueryState('threadId');
const { data: threadData } = useThread(threadId);
const [, setActiveReplyId] = useQueryState('activeReplyId');
const optimisticState = useOptimisticThreadState(threadId);
const {
optimisticMoveThreadsTo,
optimisticToggleStar,
optimisticToggleImportant,
optimisticMarkAsRead,
optimisticMarkAsUnread,
optimisticDeleteThreads,
} = useOptimisticActions();
const { isUnread, isStarred, isImportant } = useMemo(() => {
const unread = threadData?.hasUnread ?? false;
let starred;
if (optimisticState.optimisticStarred !== null) {
starred = optimisticState.optimisticStarred;
} else {
starred = threadData?.messages.some((message) =>
message.tags?.some((tag) => tag.name.toLowerCase() === 'starred'),
);
}
let important;
if (optimisticState.optimisticImportant !== null) {
important = optimisticState.optimisticImportant;
} else {
important = threadData?.messages.some((message) =>
message.tags?.some((tag) => tag.name.toLowerCase() === 'important'),
);
}
return { isUnread: unread, isStarred: starred, isImportant: important };
}, [threadData, optimisticState.optimisticStarred, optimisticState.optimisticImportant]);
const handleMove = (from: string, to: string) => () => {
try {
let targets = [];
if (mail.bulkSelected.length) {
targets = mail.bulkSelected;
} else {
targets = [threadId];
}
let destination: ThreadDestination = null;
if (to === LABELS.INBOX) destination = FOLDERS.INBOX;
else if (to === LABELS.SPAM) destination = FOLDERS.SPAM;
else if (to === LABELS.TRASH) destination = FOLDERS.BIN;
else if (from && !to) destination = FOLDERS.ARCHIVE;
optimisticMoveThreadsTo(targets, currentFolder, destination);
if (mail.bulkSelected.length) {
setMail({ ...mail, bulkSelected: [] });
}
} catch (error) {
console.error(`Error moving ${threadId ? 'email' : 'thread'}:`, error);
toast.error(m['common.actions.failedToMove']());
}
};
const handleFavorites = () => {
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
const newStarredState = !isStarred;
optimisticToggleStar(targets, newStarredState);
if (mail.bulkSelected.length) {
setMail((prev) => ({ ...prev, bulkSelected: [] }));
}
};
const handleToggleImportant = () => {
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
const newImportantState = !isImportant;
// Use optimistic update with undo functionality
optimisticToggleImportant(targets, newImportantState);
// Clear bulk selection after action
if (mail.bulkSelected.length) {
setMail((prev) => ({ ...prev, bulkSelected: [] }));
}
};
const handleReadUnread = () => {
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
const newReadState = isUnread; // If currently unread, mark as read (true)
// Use optimistic update with undo functionality
if (newReadState) {
optimisticMarkAsRead(targets);
} else if (!newReadState) {
optimisticMarkAsUnread(targets);
} else {
toast.error('Failed to mark as read');
}
// Clear bulk selection after action
if (mail.bulkSelected.length) {
setMail((prev) => ({ ...prev, bulkSelected: [] }));
}
};
const handleThreadReply = () => {
setMode('reply');
setThreadId(threadId);
if (threadData?.latest) setActiveReplyId(threadData?.latest?.id);
};
const handleThreadReplyAll = () => {
setMode('replyAll');
setThreadId(threadId);
if (threadData?.latest) setActiveReplyId(threadData?.latest?.id);
};
const handleThreadForward = () => {
setMode('forward');
setThreadId(threadId);
if (threadData?.latest) setActiveReplyId(threadData?.latest?.id);
};
const handleOpenInNewTab = () => {
window.open(`/mail/${folder}?threadId=${threadId}`, '_blank');
};
const primaryActions: EmailAction[] = useMemo(
() => [
{
id: 'open-in-new-tab',
label: m['common.mail.openInNewTab'](),
icon: <ExternalLink className="mr-2.5 h-4 w-4" />,
action: handleOpenInNewTab,
disabled: false,
},
{
id: 'reply',
label: m['common.mail.reply'](),
icon: <Reply className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleThreadReply,
disabled: false,
},
{
id: 'reply-all',
label: m['common.mail.replyAll'](),
icon: <ReplyAll className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleThreadReplyAll,
disabled: false,
},
{
id: 'forward',
label: m['common.mail.forward'](),
icon: <Forward className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleThreadForward,
disabled: false,
},
],
[m, handleThreadReply, handleThreadReplyAll, handleThreadForward],
);
const handleDelete = () => () => {
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
// Use optimistic update with undo functionality
optimisticDeleteThreads(targets, currentFolder);
// Clear bulk selection after action
if (mail.bulkSelected.length) {
setMail((prev) => ({ ...prev, bulkSelected: [] }));
}
// Navigation removed to prevent route change on current thread action
// if (!mail.bulkSelected.length && threadId) {
// navigate(`/mail/${currentFolder}`);
// }
};
const getActions = useMemo(() => {
if (isSpam) {
return [
{
id: 'move-to-inbox',
label: m['common.mail.moveToInbox'](),
icon: <Inbox className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SPAM, LABELS.INBOX),
disabled: false,
},
{
id: 'move-to-bin',
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SPAM, LABELS.TRASH),
disabled: false,
},
];
}
if (isBin) {
return [
{
id: 'restore-from-bin',
label: m['common.mail.restoreFromBin'](),
icon: <Inbox className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.TRASH, LABELS.INBOX),
disabled: false,
},
{
id: 'delete-from-bin',
label: m['common.mail.deleteFromBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleDelete(),
disabled: true,
},
];
}
if (isArchiveFolder || !isInbox) {
return [
{
id: 'move-to-inbox',
label: m['common.mail.unarchive'](),
icon: <Inbox className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove('', LABELS.INBOX),
disabled: false,
},
{
id: 'move-to-bin',
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove('', LABELS.TRASH),
disabled: false,
},
];
}
if (isSent) {
return [
{
id: 'archive',
label: m['common.mail.archive'](),
icon: <Archive className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SENT, ''),
disabled: false,
},
{
id: 'move-to-bin',
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SENT, LABELS.TRASH),
disabled: false,
},
];
}
return [
{
id: 'archive',
label: m['common.mail.archive'](),
icon: <Archive className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.INBOX, ''),
disabled: false,
},
{
id: 'move-to-spam',
label: m['common.mail.moveToSpam'](),
icon: <ArchiveX className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.INBOX, LABELS.SPAM),
disabled: !isInbox,
},
{
id: 'move-to-bin',
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.INBOX, LABELS.TRASH),
disabled: false,
},
];
}, [isSpam, isBin, isArchiveFolder, isInbox, isSent, handleMove, handleDelete]);
const otherActions: EmailAction[] = useMemo(
() => [
{
id: 'toggle-read',
label: isUnread ? m['common.mail.markAsRead']() : m['common.mail.markAsUnread'](),
icon: !isUnread ? (
<Mail className="mr-2.5 h-4 w-4 fill-[#9D9D9D] dark:fill-[#9D9D9D]" />
) : (
<MailOpen className="mr-2.5 h-4 w-4 opacity-60" />
),
action: handleReadUnread,
disabled: false,
},
{
id: 'toggle-important',
label: isImportant
? m['common.mail.removeFromImportant']()
: m['common.mail.markAsImportant'](),
icon: <ExclamationCircle className="mr-2.5 h-4 w-4 fill-[#9D9D9D] dark:fill-[#9D9D9D]" />,
action: handleToggleImportant,
},
{
id: 'favorite',
label: isStarred ? m['common.mail.removeFavorite']() : m['common.mail.addFavorite'](),
icon: isStarred ? (
<StarOff className="mr-2.5 h-4 w-4 opacity-60" />
) : (
<Star className="mr-2.5 h-4 w-4 opacity-60" />
),
action: handleFavorites,
},
],
[isUnread, isImportant, isStarred, m, handleReadUnread, handleToggleImportant, handleFavorites],
);
const renderAction = (action: EmailAction) => {
return (
<ContextMenuItem
key={action.id}
onClick={action.action}
disabled={action.disabled}
className="font-normal"
>
{action.icon}
{action.label}
{action.shortcut && <ContextMenuShortcut>{action.shortcut}</ContextMenuShortcut>}
</ContextMenuItem>
);
};
return (
<ContextMenu>
<ContextMenuTrigger disabled={isLoading || isFetching} className="w-full">
{children}
</ContextMenuTrigger>
<ContextMenuContent
className="dark:bg-panelDark w-56 overflow-y-auto bg-white"
onContextMenu={(e) => e.preventDefault()}
>
{primaryActions.map(renderAction)}
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
<ContextMenuSub>
<ContextMenuSubTrigger className="font-normal">
<Tag className="mr-2.5 h-4 w-4 opacity-60" />
{m['common.mail.labels']()}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="dark:bg-panelDark max-h-[520px] w-48 overflow-y-auto bg-white">
<LabelsList threadId={threadId} bulkSelected={mail.bulkSelected} />
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
{getActions.map(renderAction)}
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
{otherActions.map(renderAction)}
</ContextMenuContent>
</ContextMenu>
);
}