diff --git a/apps/mail/components/context/thread-context.tsx b/apps/mail/components/context/thread-context.tsx index b3d23c941f..36f51064b4 100644 --- a/apps/mail/components/context/thread-context.tsx +++ b/apps/mail/components/context/thread-context.tsx @@ -26,18 +26,19 @@ import { MailOpen, } from 'lucide-react'; import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions'; +import { markAsRead, markAsUnread, toggleStar } from '@/actions/mail'; import { useSearchValue } from '@/hooks/use-search-value'; +import { useParams, useRouter } from 'next/navigation'; import { useThreads } from '@/hooks/use-threads'; +import { modifyLabels } from '@/actions/mail'; 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 { useQueryState } from 'nuqs'; import { useMemo } from 'react'; +import { toast } from 'sonner'; interface EmailAction { id: string; @@ -50,14 +51,14 @@ interface EmailAction { } interface EmailContextMenuProps { - children: ReactNode; - emailId: string; - threadId?: string; - isInbox?: boolean; - isSpam?: boolean; - isSent?: boolean; - isBin?: boolean; - refreshCallback?: () => void; + children: ReactNode; + emailId: string; + threadId?: string; + isInbox?: boolean; + isSpam?: boolean; + isSent?: boolean; + isBin?: boolean; + refreshCallback?: () => void; } export function ThreadContextMenu({ @@ -72,146 +73,169 @@ export function ThreadContextMenu({ }: EmailContextMenuProps) { const { folder } = useParams<{ folder: string }>(); const [mail, setMail] = useMail(); - const { data: { threads }, mutate, isLoading, isValidating } = useThreads(); + const { + data: { threads }, + mutate, + isLoading, + isValidating, + } = useThreads(); const currentFolder = folder ?? ''; - const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE; + const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE; const { mutate: mutateStats } = useStats(); const t = useTranslations(); + const router = useRouter(); + const [, setMode] = useQueryState('mode'); + const [, setThreadId] = useQueryState('threadId'); + 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 (to === LABELS.TRASH) destination = FOLDERS.BIN; + 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'); + } else if (destination === FOLDERS.BIN) { + loadingMessage = t('common.actions.movingToBin'); + successMessage = t('common.actions.movedToBin'); + } + + 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 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 (to === LABELS.TRASH) destination = FOLDERS.BIN; - 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'); - } else if (destination === FOLDERS.BIN) { - loadingMessage = t('common.actions.movingToBin'); - successMessage = t('common.actions.movedToBin'); - } - - 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 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 handleThreadReply = () => { + setMode('reply'); + setThreadId(threadId); + }; + + const handleThreadReplyAll = () => { + setMode('replyAll'); + setThreadId(threadId); + }; + + const handleThreadForward = () => { + setMode('forward'); + setThreadId(threadId); + }; + + const primaryActions: EmailAction[] = [ + { + id: 'reply', + label: t('common.mail.reply'), + icon: , + action: handleThreadReply, + disabled: false, + }, + { + id: 'reply-all', + label: t('common.mail.replyAll'), + icon: , + action: handleThreadReplyAll, + disabled: false, + }, + { + id: 'forward', + label: t('common.mail.forward'), + icon: , + action: handleThreadForward, + disabled: false, + }, + ]; const getActions = () => { if (isSpam) { @@ -270,7 +294,6 @@ export function ThreadContextMenu({ id: 'archive', label: t('common.mail.archive'), icon: , - shortcut: 'E', action: handleMove(LABELS.SENT, ''), disabled: false, }, @@ -289,7 +312,6 @@ export function ThreadContextMenu({ id: 'archive', label: t('common.mail.archive'), icon: , - shortcut: 'E', action: handleMove(LABELS.INBOX, ''), disabled: false, }, @@ -314,16 +336,22 @@ export function ThreadContextMenu({ { id: 'toggle-read', label: isUnread ? t('common.mail.markAsRead') : t('common.mail.markAsUnread'), - icon: isUnread ? : , - shortcut: 'U', + icon: isUnread ? ( + + ) : ( + + ), action: handleReadUnread, disabled: false, }, { id: 'favorite', label: isStarred ? t('common.mail.removeFavorite') : t('common.mail.addFavorite'), - icon: isStarred ? : , - shortcut: 'S', + icon: isStarred ? ( + + ) : ( + + ), action: handleFavorites, disabled: false, }, @@ -351,11 +379,13 @@ export function ThreadContextMenu({ ); }; - return ( - - {children} - e.preventDefault()}> - {primaryActions.map(renderAction)} + return ( + + + {children} + + e.preventDefault()}> + {primaryActions.map(renderAction)} @@ -383,7 +413,7 @@ export function ThreadContextMenu({ */} - - - ); + + + ); } diff --git a/apps/mail/components/create/prosemirror.css b/apps/mail/components/create/prosemirror.css index a09b1a668f..d2e36daa78 100644 --- a/apps/mail/components/create/prosemirror.css +++ b/apps/mail/components/create/prosemirror.css @@ -2,20 +2,18 @@ font-size: 1rem; font-family: 'Inter', sans-serif; font-weight: 400; - } /* Add placeholder styles */ .ProseMirror p.is-editor-empty:first-child::before { color: #616161; opacity: 0.5; - content: "Start your email here"; + content: 'Start your email here'; float: left; height: 0; pointer-events: none; } - /* Custom image styles */ .ProseMirror img { @@ -126,11 +124,8 @@ ul[data-type='taskList'] li[data-checked='true'] > div > p { box-shadow: none; } - - /* Custom Youtube Video CSS */ iframe { - border: 8px solid #ffd00027; border-radius: 4px; min-width: 200px; min-height: 200px; @@ -172,21 +167,21 @@ mark[style] > strong { /* Add specific heading styles */ .ProseMirror h1 { - font-size: 1.5rem; /* text-2xl equivalent */ + font-size: 1.5rem; /* text-2xl equivalent */ font-weight: bold; margin-top: 1rem; margin-bottom: 0.5rem; } .ProseMirror h2 { - font-size: 1.25rem; /* text-xl equivalent */ + font-size: 1.25rem; /* text-xl equivalent */ font-weight: bold; margin-top: 0.75rem; margin-bottom: 0.5rem; } .ProseMirror h3 { - font-size: 1.125rem; /* text-lg equivalent */ + font-size: 1.125rem; /* text-lg equivalent */ font-weight: bold; margin-top: 0.75rem; margin-bottom: 0.5rem; @@ -194,7 +189,7 @@ mark[style] > strong { /* Add these new rules to fix list placeholder issues */ .ProseMirror li .is-editor-empty::before { - content: none !important; /* Disable placeholder in list items */ + content: none !important; /* Disable placeholder in list items */ } /* Ensure list items don't show placeholders */ @@ -221,8 +216,6 @@ mark[style] > strong { line-height: 1.4; } - - /* Keep the list indentation the same */ .ProseMirror ul { padding-left: 1.5rem; diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index 9b7f5984c7..fb909968f3 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -500,13 +500,6 @@ export const MailList = memo(({ isCompact }: MailListProps) => { const messageThreadId = message.threadId ?? message.id; - // Update local state immediately for optimistic UI - setMail((prev) => ({ - ...prev, - replyComposerOpen: false, - forwardComposerOpen: false, - })); - // Update URL param without navigation void setThreadId(messageThreadId); }, diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index 984f956930..75a74201a7 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -73,13 +73,6 @@ interface AIState { showOptions: boolean; } -interface MailState { - replyComposerOpen: boolean; - replyAllComposerOpen: boolean; - forwardComposerOpen: boolean; - // ... other existing state -} - // Define action types type ComposerAction = | { type: 'SET_UPLOADING'; payload: boolean } @@ -150,14 +143,14 @@ type FormData = { bccInput: string; }; -export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { +export default function ReplyCompose() { const [threadId] = useQueryState('threadId'); const { data: emailData, mutate } = useThread(threadId); const [attachments, setAttachments] = useState([]); const { data: session } = useSession(); const [mail, setMail] = useMail(); - const { settings } = useSettings(); const [draftId, setDraftId] = useQueryState('draftId'); + const [mode, setMode] = useQueryState('mode'); const { enableScope, disableScope } = useHotkeysContext(); const [isEditingRecipients, setIsEditingRecipients] = useState(false); const [showCc, setShowCc] = useState(false); @@ -166,20 +159,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const bccInputRef = useRef(null); // Use global state instead of local state - const composerIsOpen = - mode === 'reply' - ? mail.replyComposerOpen - : mode === 'replyAll' - ? mail.replyAllComposerOpen - : mail.forwardComposerOpen; - const setComposerIsOpen = (value: boolean) => { - setMail((prev: typeof mail) => ({ - ...prev, - replyComposerOpen: mode === 'reply' ? value : prev.replyComposerOpen, - replyAllComposerOpen: mode === 'replyAll' ? value : prev.replyAllComposerOpen, - forwardComposerOpen: mode === 'forward' ? value : prev.forwardComposerOpen, - })); - }; + const composerIsOpen = !!mode; // Use reducers instead of multiple useState const [composerState, composerDispatch] = useReducer(composerReducer, { @@ -327,7 +307,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { } reset(); - setComposerIsOpen(false); + setMode(null); toast.success(t('pages.createEmail.emailSentSuccessfully')); } catch (error) { console.error('Error sending email:', error); @@ -389,10 +369,6 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { setAttachments([...attachments, ...Array.from(e.dataTransfer.files)]); - // Open the composer if it's not already open - if (!composerIsOpen) { - setComposerIsOpen(true); - } } } }; @@ -428,12 +404,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } - setMail((prev) => ({ - ...prev, - replyComposerOpen: false, - replyAllComposerOpen: false, - forwardComposerOpen: false, - })); + setMode(null); setIsEditingRecipients(false); setShowCc(false); setShowBcc(false); @@ -976,12 +947,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) {
- +
@@ -142,6 +142,7 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { const { mutate: mutateStats } = useStats(); const { folder } = useParams<{ folder: string }>(); const [threadId, setThreadId] = useQueryState('threadId'); + const [mode, setMode] = useQueryState('mode'); // Check if thread contains any images (excluding sender avatars) const hasImages = useMemo(() => { @@ -191,7 +192,8 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { const isInBin = folder === FOLDERS.BIN; const handleClose = useCallback(() => { setThreadId(null); - }, []); + setMode(null); + }, [setThreadId, setMode]); const moveThreadTo = useCallback( async (destination: ThreadDestination) => { @@ -373,14 +375,9 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { icon={Reply} label={t('common.threadDisplay.reply')} disabled={!emailData} - className={cn(mail.replyComposerOpen && 'bg-primary/10')} + className={cn(mode === 'reply' && 'bg-primary/10')} onClick={() => { - setMail((prev) => ({ - ...prev, - replyComposerOpen: true, - replyAllComposerOpen: false, - forwardComposerOpen: false, - })); + setMode('reply'); }} /> {hasMultipleParticipants && ( @@ -388,14 +385,9 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { icon={ReplyAll} label={t('common.threadDisplay.replyAll')} disabled={!emailData} - className={cn(mail.replyAllComposerOpen && 'bg-primary/10')} + className={cn(mode === 'replyAll' && 'bg-primary/10')} onClick={() => { - setMail((prev) => ({ - ...prev, - replyComposerOpen: false, - replyAllComposerOpen: true, - forwardComposerOpen: false, - })); + setMode('replyAll'); }} /> )} @@ -403,14 +395,9 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { icon={Forward} label={t('common.threadDisplay.forward')} disabled={!emailData} - className={cn(mail.forwardComposerOpen && 'bg-primary/10')} + className={cn(mode === 'forward' && 'bg-primary/10')} onClick={() => { - setMail((prev) => ({ - ...prev, - replyComposerOpen: false, - replyAllComposerOpen: false, - forwardComposerOpen: true, - })); + setMode('forward'); }} /> @@ -453,15 +440,7 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) {
- +
diff --git a/apps/mail/components/providers/hotkey-provider-wrapper.tsx b/apps/mail/components/providers/hotkey-provider-wrapper.tsx index cc89ddc636..13809cd307 100644 --- a/apps/mail/components/providers/hotkey-provider-wrapper.tsx +++ b/apps/mail/components/providers/hotkey-provider-wrapper.tsx @@ -1,10 +1,10 @@ 'use client'; -import { HotkeysProvider } from 'react-hotkeys-hook'; -import { GlobalHotkeys } from '@/lib/hotkeys/global-hotkeys'; -import { MailListHotkeys } from '@/lib/hotkeys/mail-list-hotkeys'; import { ThreadDisplayHotkeys } from '@/lib/hotkeys/thread-display-hotkeys'; +import { MailListHotkeys } from '@/lib/hotkeys/mail-list-hotkeys'; import { ComposeHotkeys } from '@/lib/hotkeys/compose-hotkeys'; +import { GlobalHotkeys } from '@/lib/hotkeys/global-hotkeys'; +import { HotkeysProvider } from 'react-hotkeys-hook'; import React from 'react'; interface HotkeyProviderWrapperProps { @@ -15,10 +15,10 @@ export function HotkeyProviderWrapper({ children }: HotkeyProviderWrapperProps) return ( - + {/* */} {children} ); -} \ No newline at end of file +}