diff --git a/apps/mail/actions/mail.ts b/apps/mail/actions/mail.ts index 4eb0b40515..f6699233e5 100644 --- a/apps/mail/actions/mail.ts +++ b/apps/mail/actions/mail.ts @@ -111,3 +111,44 @@ export const modifyLabels = async ({ throw error; } }; + +export const toggleStar = async ({ ids }: { ids: string[] }) => { + try { + const driver = await getActiveDriver(); + const { threadIds } = driver.normalizeIds(ids); + + if (!threadIds.length) { + return { success: false, error: 'No thread IDs provided' }; + } + + const threadResults = await Promise.allSettled( + threadIds.map(id => driver.get(id)) + ); + + let allStarred = true; + let anyValid = false; + + for (const result of threadResults) { + if (result.status === 'fulfilled' && result.value?.[0]) { + anyValid = true; + if (!result.value[0].tags?.includes('STARRED')) { + allStarred = false; + break; + } + } + } + + const shouldStar = !anyValid || !allStarred; + + await driver.modifyLabels(threadIds, { + addLabels: shouldStar ? ['STARRED'] : [], + removeLabels: shouldStar ? [] : ['STARRED'], + }); + + return { success: true }; + } catch (error) { + if (FatalErrors.includes((error as Error).message)) await deleteActiveConnection(); + console.error('Error toggling star:', error); + throw error; + } +}; diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts index bac3738372..c818da94c5 100644 --- a/apps/mail/app/api/driver/google.ts +++ b/apps/mail/app/api/driver/google.ts @@ -310,7 +310,7 @@ export const driver = async (config: IConfig): Promise => { return { ...res.data, threads } as any; }, get: async (id: string): Promise => { - console.log(id); + console.log('Fetching thread:', id); const res = await gmail.users.threads.get({ userId: 'me', id, format: 'full' }); if (!res.data.messages) return []; @@ -459,15 +459,33 @@ export const driver = async (config: IConfig): Promise => { ); return { threadIds }; }, - async modifyLabels(id: string[], options: { addLabels: string[]; removeLabels: string[] }) { - await gmail.users.messages.batchModify({ - userId: 'me', - requestBody: { - ids: id, - addLabelIds: options.addLabels, - removeLabelIds: options.removeLabels, - }, - }); + async modifyLabels(threadIds: string[], options: { addLabels: string[]; removeLabels: string[] }) { + const threadResults = await Promise.allSettled( + threadIds.map(threadId => + gmail.users.threads.get({ + userId: 'me', + id: threadId, + format: 'minimal' + }) + ) + ); + + const messageIds = threadResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .flatMap(result => result.value.data.messages || []) + .map(msg => msg.id) + .filter((id): id is string => !!id); + + if (messageIds.length > 0) { + await gmail.users.messages.batchModify({ + userId: 'me', + requestBody: { + ids: messageIds, + addLabelIds: options.addLabels, + removeLabelIds: options.removeLabels, + }, + }); + } }, getDraft: async (draftId: string) => { try { diff --git a/apps/mail/components/context/thread-context.tsx b/apps/mail/components/context/thread-context.tsx new file mode 100644 index 0000000000..33799775f7 --- /dev/null +++ b/apps/mail/components/context/thread-context.tsx @@ -0,0 +1,353 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from '../ui/context-menu'; +import { + Archive, + ArchiveX, + BellOff, + Forward, + Inbox, + MailPlus, + Reply, + ReplyAll, + Tag, + Mail, + Star, + StarOff, + Trash, +} from 'lucide-react'; +import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions'; +import { useSearchValue } from '@/hooks/use-search-value'; +import { useThreads } from '@/hooks/use-threads'; +import { LABELS, FOLDERS } from '@/lib/utils'; +import { useStats } from '@/hooks/use-stats'; +import { useParams } from 'next/navigation'; +import { useMail } from '../mail/use-mail'; +import { useTranslations } from 'use-intl'; +import { type ReactNode } from 'react'; +import { toast } from 'sonner'; +import { modifyLabels } from '@/actions/mail'; +import { markAsRead, markAsUnread, toggleStar } from '@/actions/mail'; +import { useMemo } from 'react'; + +interface EmailAction { + id: string; + label: string | ReactNode; + icon?: ReactNode; + shortcut?: string; + action: () => void; + disabled?: boolean; + condition?: () => boolean; +} + +interface EmailContextMenuProps { + children: ReactNode; + emailId: string; + threadId?: string; + isInbox?: boolean; + isSpam?: boolean; + isSent?: boolean; + refreshCallback?: () => void; +} + +export function ThreadContextMenu({ + children, + emailId, + threadId = emailId, + isInbox = true, + isSpam = false, + isSent = false, + refreshCallback, +}: EmailContextMenuProps) { + const { folder } = useParams<{ folder: string }>(); + const [mail, setMail] = useMail(); + const { data: { threads }, mutate, isLoading, isValidating } = useThreads(); + const currentFolder = folder ?? ''; + const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE; + const { mutate: mutateStats } = useStats(); + const t = useTranslations(); + + const selectedThreads = useMemo(() => { + if (mail.bulkSelected.length) { + return threads.filter(thread => mail.bulkSelected.includes(thread.id)); + } + return threads.filter(thread => thread.id === threadId || thread.threadId === threadId); + }, [mail.bulkSelected, threadId, threads]); + + const isUnread = useMemo(() => { + if (mail.bulkSelected.length) { + return selectedThreads.some(thread => thread.unread); + } + return selectedThreads[0]?.unread ?? false; + }, [selectedThreads, mail.bulkSelected]); + + const isStarred = useMemo(() => { + if (mail.bulkSelected.length) { + return selectedThreads.every(thread => thread.tags?.includes('STARRED')); + } + return selectedThreads[0]?.tags?.includes('STARRED') ?? false; + }, [selectedThreads, mail.bulkSelected]); + + const noopAction = () => async () => { + toast.info(t('common.actions.featureNotImplemented')); + }; + + const handleMove = (from: string, to: string) => async () => { + try { + let targets = []; + if (mail.bulkSelected.length) { + targets = mail.bulkSelected.map((id) => `thread:${id}`); + } else { + targets = [threadId ? `thread:${threadId}` : emailId]; + } + + let destination: ThreadDestination = null; + if (to === LABELS.INBOX) destination = FOLDERS.INBOX; + else if (to === LABELS.SPAM) destination = FOLDERS.SPAM; + else if (from && !to) destination = FOLDERS.ARCHIVE; + + const promise = moveThreadsTo({ + threadIds: targets, + currentFolder: currentFolder, + destination + }).then(async () => { + await Promise.all([mutate(), mutateStats()]); + setMail({ ...mail, bulkSelected: [] }); + }); + + let loadingMessage = t('common.actions.moving'); + let successMessage = t('common.actions.movedToInbox'); + + if (destination === FOLDERS.INBOX) { + loadingMessage = t('common.actions.movingToInbox'); + successMessage = t('common.actions.movedToInbox'); + } else if (destination === FOLDERS.SPAM) { + loadingMessage = t('common.actions.movingToSpam'); + successMessage = t('common.actions.movedToSpam'); + } else if (destination === FOLDERS.ARCHIVE) { + loadingMessage = t('common.actions.archiving'); + successMessage = t('common.actions.archived'); + } + + toast.promise(promise, { + loading: loadingMessage, + success: successMessage, + error: t('common.actions.failedToMove'), + }); + + await promise; + } catch (error) { + console.error(`Error moving ${threadId ? 'email' : 'thread'}:`, error); + } + }; + + const handleFavorites = () => { + const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; + const promise = toggleStar({ ids: targets }).then(() => { + setMail(prev => ({ ...prev, bulkSelected: [] })); + return mutate(); + }); + + toast.promise(promise, { + loading: isStarred ? t('common.actions.removingFromFavorites') : t('common.actions.addingToFavorites'), + success: isStarred ? t('common.actions.removedFromFavorites') : t('common.actions.addedToFavorites'), + error: t('common.actions.failedToModifyFavorites'), + }); + }; + + const handleReadUnread = () => { + const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; + const action = isUnread ? markAsRead : markAsUnread; + + const promise = action({ ids: targets }).then(() => { + setMail(prev => ({ ...prev, bulkSelected: [] })); + return mutate(); + }); + + toast.promise(promise, { + loading: t(isUnread ? 'common.actions.markingAsRead' : 'common.actions.markingAsUnread'), + success: t(isUnread ? 'common.mail.markedAsRead' : 'common.mail.markedAsUnread'), + error: t(isUnread ? 'common.mail.failedToMarkAsRead' : 'common.mail.failedToMarkAsUnread'), + }); + }; + + const primaryActions: EmailAction[] = [ + { + id: 'reply', + label: t('common.mail.reply'), + icon: , + shortcut: 'R', + action: noopAction, + disabled: true, + }, + { + id: 'reply-all', + label: t('common.mail.replyAll'), + icon: , + shortcut: '⇧R', + action: noopAction, + disabled: true, + }, + { + id: 'forward', + label: t('common.mail.forward'), + icon: , + shortcut: 'F', + action: noopAction, + disabled: true, + }, + ]; + + const getActions = () => { + if (isSpam) { + return [ + { + id: 'move-to-inbox', + label: t('common.mail.moveToInbox'), + icon: , + action: handleMove(LABELS.SPAM, LABELS.INBOX), + disabled: false, + }, + ]; + } + + if (isArchiveFolder || !isInbox) { + return [ + { + id: 'move-to-inbox', + label: t('common.mail.unarchive'), + icon: , + action: handleMove('', LABELS.INBOX), + disabled: false, + }, + ]; + } + + if (isSent) { + return [ + { + id: 'archive', + label: t('common.mail.archive'), + icon: , + shortcut: 'E', + action: handleMove(LABELS.SENT, ''), + disabled: false, + }, + ]; + } + + return [ + { + id: 'archive', + label: t('common.mail.archive'), + icon: , + shortcut: 'E', + action: handleMove(LABELS.INBOX, ''), + disabled: false, + }, + { + id: 'move-to-spam', + label: t('common.mail.moveToSpam'), + icon: , + action: handleMove(LABELS.INBOX, LABELS.SPAM), + disabled: !isInbox, + }, + ]; + }; + + const moveActions: EmailAction[] = [ + { + id: 'move-to-trash', + label: t('common.mail.moveToTrash'), + icon: , + action: noopAction, + disabled: true, // TODO: Move to trash functionality to be implemented + }, + ]; + + const otherActions: EmailAction[] = [ + { + id: 'toggle-read', + label: isUnread ? t('common.mail.markAsRead') : t('common.mail.markAsUnread'), + icon: , + shortcut: 'U', + action: handleReadUnread, + disabled: false, + }, + { + id: 'favorite', + label: isStarred ? t('common.mail.removeFavorite') : t('common.mail.addFavorite'), + icon: isStarred ? : , + shortcut: 'S', + action: handleFavorites, + disabled: false, + }, + { + id: 'mute', + label: t('common.mail.muteThread'), + icon: , + action: noopAction, + disabled: true, // TODO: Mute thread functionality to be implemented + }, + ]; + + const renderAction = (action: EmailAction) => { + return ( + + {action.icon} + {action.label} + {action.shortcut && {action.shortcut}} + + ); + }; + + return ( + + {children} + e.preventDefault()}> + {primaryActions.map(renderAction)} + + + + {getActions().map(renderAction as any)} + {moveActions.filter((action) => action.id !== 'move-to-spam').map(renderAction)} + + + + {otherActions.map(renderAction)} + + {/* + + + + + {t('common.mail.labels')} + + + + + {t('common.mail.createNewLabel')} + + + + {t('common.mail.noLabelsAvailable')} + + + */} + + + ); +} diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index 38188e9a98..4033e4aecc 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -10,6 +10,7 @@ import { Tag, User, Users, + StarOff, } from 'lucide-react'; import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode } from '@/types'; import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react'; @@ -20,7 +21,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '../ui/avatar'; import { useMailNavigation } from '@/hooks/use-mail-navigation'; import { preloadThread, useThreads } from '@/hooks/use-threads'; import { useHotKey, useKeyState } from '@/hooks/use-hot-key'; -import { cn, formatDate, getEmailLogo } from '@/lib/utils'; +import { cn, FOLDERS, formatDate, getEmailLogo } from '@/lib/utils'; import { useSearchValue } from '@/hooks/use-search-value'; import { markAsRead, markAsUnread } from '@/actions/mail'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -34,8 +35,40 @@ import { Button } from '../ui/button'; import { useQueryState } from 'nuqs'; import items from './demo.json'; import { toast } from 'sonner'; +import { ThreadContextMenu } from '@/components/context/thread-context'; const HOVER_DELAY = 1000; // ms before prefetching +const ThreadWrapper = ({ + children, + emailId, + threadId, + isFolderInbox, + isFolderSpam, + isFolderSent, + refreshCallback, +}: { + children: React.ReactNode; + emailId: string; + threadId: string; + isFolderInbox: boolean; + isFolderSpam: boolean; + isFolderSent: boolean; + refreshCallback: () => void; +}) => { + return ( + + {children} + + ); +}; + const Thread = memo( ({ message, @@ -58,6 +91,8 @@ const Thread = memo( const [searchValue] = useSearchValue(); const t = useTranslations(); const searchParams = useSearchParams(); + const { folder } = useParams<{ folder: string }>(); + const { mutate } = useThreads(); const threadIdParam = searchParams.get('threadId'); const hoverTimeoutRef = useRef | undefined>(undefined); const isHovering = useRef(false); @@ -73,6 +108,10 @@ const Thread = memo( return [...(message.tags || [])]; }, [message.tags]); + const isFolderInbox = folder === FOLDERS.INBOX || !folder; + const isFolderSpam = folder === FOLDERS.SPAM; + const isFolderSent = folder === FOLDERS.SENT; + const handleMouseEnter = () => { if (demo) return; isHovering.current = true; @@ -120,7 +159,7 @@ const Thread = memo( }; }, []); - return ( + const content = (
{demo ? (
); + + return ( + mutate()} + > + {content} + + ); }, ); diff --git a/apps/mail/components/ui/context-menu.tsx b/apps/mail/components/ui/context-menu.tsx new file mode 100644 index 0000000000..d4e6ac526e --- /dev/null +++ b/apps/mail/components/ui/context-menu.tsx @@ -0,0 +1,189 @@ +'use client'; + +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/apps/mail/locales/en.json b/apps/mail/locales/en.json index 59d1bdaf9d..921308209f 100644 --- a/apps/mail/locales/en.json +++ b/apps/mail/locales/en.json @@ -12,7 +12,23 @@ "signedOutSuccess": "Signed out successfully!", "signOutError": "Error signing out", "refresh": "Refresh", - "loading": "Loading..." + "loading": "Loading...", + "featureNotImplemented": "This feature is not implemented yet", + "moving": "Moving...", + "movedToInbox": "Moved to inbox", + "movingToInbox": "Moving to inbox...", + "movedToSpam": "Moved to spam", + "movingToSpam": "Moving to spam...", + "archiving": "Archiving...", + "archived": "Archived", + "failedToMove": "Failed to move message", + "addingToFavorites": "Adding to favorites...", + "removingFromFavorites": "Removing from favorites...", + "addedToFavorites": "Added to favorites", + "removedFromFavorites": "Removed from favorites", + "failedToModifyFavorites": "Failed to modify favorites", + "markingAsRead": "Marking as read...", + "markingAsUnread": "Marking as unread..." }, "themes": { "dark": "Dark", @@ -220,7 +236,8 @@ "moveToTrash": "Move to Trash", "markAsUnread": "Mark as Unread", "markAsRead": "Mark as Read", - "addStar": "Add Star", + "addFavorite": "Favorite", + "removeFavorite": "Unfavorite", "muteThread": "Mute Thread", "moving": "Moving...", "moved": "Moved",