diff --git a/apps/mail/app/(routes)/mail/page.tsx b/apps/mail/app/(routes)/mail/page.tsx deleted file mode 100644 index d9518b6201..0000000000 --- a/apps/mail/app/(routes)/mail/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { MailLayout } from '@/components/mail/mail'; - -export default async function MailPage() { - return ( -
-
- -
-
- ); -} diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts index d7efd463b9..86095915e4 100644 --- a/apps/mail/app/api/driver/google.ts +++ b/apps/mail/app/api/driver/google.ts @@ -172,6 +172,9 @@ export const driver = async (config: IConfig): Promise => { if (folder === 'trash') { return { folder: undefined, q: `in:trash ${q}` }; } + if (folder === "archive") { + return { folder: undefined, q: `in:archive ${q}` }; + } return { folder, q }; }; const gmail = google.gmail({ version: 'v1', auth }); diff --git a/apps/mail/components/context/thread-context.tsx b/apps/mail/components/context/thread-context.tsx index 6b39d5f334..6910123b48 100644 --- a/apps/mail/components/context/thread-context.tsx +++ b/apps/mail/components/context/thread-context.tsx @@ -23,16 +23,15 @@ import { Star, Trash, } from 'lucide-react'; +import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions'; 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'; // Keep this if localization is needed +import { useTranslations } from 'use-intl'; import { type ReactNode } from 'react'; -import { LABELS } from '@/lib/utils'; -import { toast } from 'sonner'; interface EmailAction { id: string; @@ -45,13 +44,13 @@ interface EmailAction { } interface EmailContextMenuProps { - children: React.ReactNode; - emailId: string; - threadId?: string; - isInbox?: boolean; - isSpam?: boolean; - isSent?: boolean; - refreshCallback?: () => void; + children: ReactNode; + emailId: string; + threadId?: string; + isInbox?: boolean; + isSpam?: boolean; + isSent?: boolean; + refreshCallback?: () => void; } export function ThreadContextMenu({ @@ -64,90 +63,72 @@ export function ThreadContextMenu({ refreshCallback, }: EmailContextMenuProps) { const { folder } = useParams<{ folder: string }>(); - const [searchValue] = useSearchValue(); const [mail, setMail] = useMail(); - const { mutate } = useThreads(folder, undefined, searchValue.value, 20); + const { mutate } = useThreads(); const currentFolder = folder ?? ''; - const isArchiveFolder = currentFolder === 'archive'; + const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE; const { mutate: mutateStats } = useStats(); const t = useTranslations(); - const noopAction = () => async () => { - console.log('Action will be implemented later'); - }; + const noopAction = () => async () => { + console.log('Action will be implemented later'); + }; - // const handleArchive = () => async () => { - // try { - // const targetId = threadId ? `thread:${threadId}` : emailId; - // console.log(`🗃️ EmailContextMenu: Archiving ${threadId ? 'thread' : 'email'}: ${targetId}`); + 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]; + } - // const success = await archiveMail(targetId); - // if (success) { - // toast.success(`${threadId ? 'Email' : 'Thread'} archived`); - // if (refreshCallback) refreshCallback(); - // } else { - // throw new Error(`Failed to archive ${threadId ? 'email' : 'thread'}`); - // } - // } catch (error) { - // console.error(`Error archiving ${threadId ? 'email' : 'thread'}:`, error); - // toast.error(`Error archiving ${threadId ? 'email' : 'thread'}`); - // } - // }; + 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 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]; - } - return toast.promise( - modifyLabels({ - threadId: targets, - addLabels: to ? [to] : [], - removeLabels: from ? [from] : [], - }).then(async () => { - await mutate().then(() => mutateStats()); - return setMail({ ...mail, bulkSelected: [] }); - }), - { - loading: t('common.mail.moving'), - success: () => t('common.mail.moved'), - error: t('common.mail.errorMoving'), - }, - ); - } catch (error) { - console.error(`Error moving ${threadId ? 'email' : 'thread'}`, error); - } - }; + return moveThreadsTo({ + threadIds: targets, + currentFolder: currentFolder, + destination, + onSuccess: async () => { + await mutate(); + await mutateStats(); + setMail({ ...mail, bulkSelected: [] }); + }, + }); + } catch (error) { + console.error(`Error moving ${threadId ? 'email' : 'thread'}`, error); + } + }; - const primaryActions: EmailAction[] = [ - { - id: 'reply', - label: 'Reply', - icon: , - shortcut: 'R', - action: noopAction, - disabled: true, // TODO: Reply functionality to be implemented - }, - { - id: 'reply-all', - label: 'Reply All', - icon: , - shortcut: '⇧R', - action: noopAction, - disabled: true, // TODO: Reply All functionality to be implemented - }, - { - id: 'forward', - label: 'Forward', - icon: , - shortcut: 'F', - action: noopAction, - disabled: true, // TODO: Forward functionality to be implemented - }, - ]; + const primaryActions: EmailAction[] = [ + { + id: 'reply', + label: t('common.mail.reply'), + icon: , + shortcut: 'R', + action: noopAction, + disabled: true, // TODO: Reply functionality to be implemented + }, + { + id: 'reply-all', + label: t('common.mail.replyAll'), + icon: , + shortcut: '⇧R', + action: noopAction, + disabled: true, // TODO: Reply All functionality to be implemented + }, + { + id: 'forward', + label: t('common.mail.forward'), + icon: , + shortcut: 'F', + action: noopAction, + disabled: true, // TODO: Forward functionality to be implemented + }, + ]; const getActions = () => { if (isSpam) { @@ -257,11 +238,11 @@ export function ThreadContextMenu({ ); }; - return ( - - {children} - - {primaryActions.map(renderAction)} + return ( + + {children} + e.preventDefault()}> + {primaryActions.map(renderAction)} @@ -274,23 +255,23 @@ export function ThreadContextMenu({ - - - - Labels - - - - - Create New Label - - - - No labels available - - - - - - ); + + + + {t('common.mail.labels')} + + + + + {t('common.mail.createNewLabel')} + + + + {t('common.mail.noLabelsAvailable')} + + + + + + ); } diff --git a/apps/mail/components/draft/drafts-list.tsx b/apps/mail/components/draft/drafts-list.tsx index f8c695c0c4..8fda53329e 100644 --- a/apps/mail/components/draft/drafts-list.tsx +++ b/apps/mail/components/draft/drafts-list.tsx @@ -16,26 +16,7 @@ import { useSession } from '@/lib/auth-client'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'use-intl'; import { toast } from 'sonner'; - -const highlightText = (text: string, highlight: string) => { - if (!highlight?.trim()) return text; - - const regex = new RegExp(`(${highlight})`, 'gi'); - const parts = text.split(regex); - - return parts.map((part, i) => { - return i % 2 === 1 ? ( - - {part} - - ) : ( - part - ); - }); -}; +import { highlightText } from '@/lib/email-utils.client'; const Draft = ({ message, onClick }: ThreadProps) => { const [mail] = useMail(); diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index bcce204982..229f69458f 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -5,13 +5,15 @@ import { AlertTriangle, Bell, Briefcase, StickyNote, Tag, User, Users } from 'lu import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { EmptyState, type FolderType } from '@/components/mail/empty-state'; +import { ThreadContextMenu } from '@/components/context/thread-context'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { cn, defaultPageSize, formatDate, FOLDERS } from '@/lib/utils'; import { preloadThread, useThreads } from '@/hooks/use-threads'; -import { cn, defaultPageSize, formatDate } from '@/lib/utils'; import { useHotKey, useKeyState } from '@/hooks/use-hot-key'; import { useSearchValue } from '@/hooks/use-search-value'; import { markAsRead, markAsUnread } from '@/actions/mail'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { highlightText } from '@/lib/email-utils.client'; import { useMail } from '@/components/mail/use-mail'; import type { VirtuosoHandle } from 'react-virtuoso'; import { useSession } from '@/lib/auth-client'; @@ -20,39 +22,33 @@ import { useTranslations } from 'next-intl'; import { Virtuoso } from 'react-virtuoso'; import items from './demo.json'; import { toast } from 'sonner'; - const HOVER_DELAY = 1000; // ms before prefetching -const highlightText = (text: string, highlight: string) => { - if (!highlight?.trim()) return text; - - const regex = new RegExp(`(${highlight})`, 'gi'); - const parts = text.split(regex); - - return parts.map((part, i) => { - return i % 2 === 1 ? ( - - {part} - - ) : ( - part - ); - }); -}; - const Thread = memo( - ({ message, selectMode, demo, onClick, sessionData }: ConditionalThreadProps) => { - const [mail] = useMail(); - const [searchValue] = useSearchValue(); - const t = useTranslations(); - const searchParams = useSearchParams(); - const threadIdParam = searchParams.get('threadId'); - const hoverTimeoutRef = useRef | undefined>(undefined); - const isHovering = useRef(false); - const hasPrefetched = useRef(false); + ({ + message, + selectMode, + demo, + onClick, + sessionData, + folder, + onRefresh, + }: ConditionalThreadProps & { + folder?: string; + onRefresh?: () => void; + }) => { + const [mail] = useMail(); + const [searchValue] = useSearchValue(); + const t = useTranslations(); + const searchParams = useSearchParams(); + const threadIdParam = searchParams.get('threadId'); + const hoverTimeoutRef = useRef | undefined>(undefined); + const isHovering = useRef(false); + const hasPrefetched = useRef(false); + + const isFolderInbox = folder === FOLDERS.INBOX || !folder; + const isFolderSpam = folder === FOLDERS.SPAM; + const isFolderSent = folder === FOLDERS.SENT; const isMailSelected = useMemo(() => { const threadId = message.threadId ?? message.id; @@ -112,78 +108,87 @@ const Thread = memo( }; }, []); - return ( -
-
-
-
-
-

- - {highlightText(message.sender.name, searchValue.highlight)} - {' '} - {message.unread ? : null} -

- -
- {message.totalReplies > 1 ? ( - - - - {message.totalReplies} - - - - {t('common.mail.replies', { count: message.totalReplies })} - - - ) : null} -
-
- {message.receivedOn ? ( -

- {formatDate(message.receivedOn.split('.')[0] || '')} -

- ) : null} -
-

- {highlightText(message.subject, searchValue.highlight)} -

-
-
- ); - }, + return ( + +
+
+
+
+
+

+ + {highlightText(message.sender.name, searchValue.highlight)} + {' '} + {message.unread && !isMailSelected ? : null} +

+ +
+ {message.totalReplies > 1 ? ( + + + + {message.totalReplies} + + + + {t('common.mail.replies', { count: message.totalReplies })} + + + ) : null} +
+
+ {message.receivedOn ? ( +

+ {formatDate(message.receivedOn.split('.')[0] || '')} +

+ ) : null} +
+

+ {highlightText(message.subject, searchValue.highlight)} +

+
+
+ + ); + }, ); Thread.displayName = 'Thread'; @@ -227,7 +232,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { isValidating, isLoading, loadMore, - } = useThreads(folder, undefined, searchValue.value, defaultPageSize); + } = useThreads(); const parentRef = useRef(null); const scrollRef = useRef(null); @@ -410,25 +415,37 @@ export const MailList = memo(({ isCompact }: MailListProps) => { // Update URL with threadId router.push(`/mail/${folder}?${currentParams.toString()}`); } - - if (message.unread) { - return markAsRead({ ids: [message.id] }) - .then(() => mutate()) - .catch(console.error); - } }, [mail, setMail, items, getSelectMode, router, searchParams, folder], ); - const isEmpty = items.length === 0; - const isFiltering = searchValue.value.trim().length > 0; - - if (isEmpty && session) { - if (isFiltering) { - return ; - } - return ; - } + const isEmpty = items.length === 0; + const isFiltering = searchValue.value.trim().length > 0; + + const rowRenderer = useCallback( + //TODO: Add proper typing + // @ts-expect-error + (props) => ( + mutate()} + {...props} + /> + ), + [handleMailClick, getSelectMode, isCompact, sessionData, folder, mutate], + ); + + if (isEmpty && session) { + if (isFiltering) { + return ; + } + return ; + } return ( <> diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index f34b100caa..b8ca7bbe6b 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -23,6 +23,12 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { + moveThreadsTo, + ThreadDestination, + isActionAvailable, + getAvailableActions, +} from '@/lib/thread-actions'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'; @@ -37,50 +43,54 @@ import { SearchIcon } from '../icons/animated/search'; import { useMail } from '@/components/mail/use-mail'; import { SidebarToggle } from '../ui/sidebar-toggle'; import { Skeleton } from '@/components/ui/skeleton'; +import { clearBulkSelectionAtom } from './use-mail'; import { cn, defaultPageSize } from '@/lib/utils'; import { useThreads } from '@/hooks/use-threads'; import { MessageKey } from '@/config/navigation'; import { Button } from '@/components/ui/button'; import { useHotKey } from '@/hooks/use-hot-key'; import { useSession } from '@/lib/auth-client'; +import { useStats } from '@/hooks/use-stats'; import { XIcon } from '../icons/animated/x'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { getMail } from '@/actions/mail'; import { SearchBar } from './search-bar'; import items from './demo.json'; +import { useAtom } from 'jotai'; import { toast } from 'sonner'; export function DemoMailLayout() { - const [mail] = useState({ - selected: 'demo', - bulkSelected: [], - }); - const isMobile = false; - const isValidating = false; - const isLoading = false; - const isDesktop = true; - const searchParams = useSearchParams(); - const threadIdParam = searchParams?.get('threadId'); - - const handleClose = () => { - // Update URL to remove threadId parameter - const currentParams = new URLSearchParams(searchParams?.toString() || ''); - currentParams.delete('threadId'); - }; - const [activeCategory, setActiveCategory] = useState('Primary'); - const [filteredItems, setFilteredItems] = useState(items); - - useEffect(() => { - if (activeCategory === 'Primary') { - setFilteredItems(items); - } else { - const categoryMap = { - Important: 'important', - Personal: 'personal', - Updates: 'updates', - Promotions: 'promotions', - }; + const [mail, setMail] = useState({ + selected: 'demo', + bulkSelected: [], + }); + const isMobile = false; + const isValidating = false; + const isLoading = false; + const isDesktop = true; + const searchParams = useSearchParams(); + const threadIdParam = searchParams?.get('threadId'); + const t = useTranslations(); + + const handleClose = () => { + // Update URL to remove threadId parameter + const currentParams = new URLSearchParams(searchParams?.toString() || ''); + currentParams.delete('threadId'); + }; + const [activeCategory, setActiveCategory] = useState(t('common.mailCategories.primary')); + const [filteredItems, setFilteredItems] = useState(items); + + useEffect(() => { + if (activeCategory === t('common.mailCategories.primary')) { + setFilteredItems(items); + } else { + const categoryMap = { + Important: t('common.mailCategories.important'), + Personal: t('common.mailCategories.personal'), + Updates: t('common.mailCategories.updates'), + Promotions: t('common.mailCategories.promotions'), + }; const filterTag = categoryMap[activeCategory as keyof typeof categoryMap]; const filtered = items.filter((item) => item.tags && item.tags.includes(filterTag)); @@ -172,40 +182,49 @@ export function DemoMailLayout() { )} - {/* Mobile Drawer */} - {isMobile && ( - { - if (!isOpen) handleClose(); - }} - > - - - Email Details - -
-
- -
-
-
-
- )} -
- - ); + {/* Mobile Drawer */} + {isMobile && ( + { + if (!isOpen) handleClose(); + }} + > + + + Email Details + +
+
+ +
+
+
+
+ )} +
+ + ); } export function MailLayout() { - const { folder } = useParams<{ folder: string }>(); - const [searchMode, setSearchMode] = useState(false); - const [searchValue] = useSearchValue(); - const [mail, setMail] = useMail(); - const [isMobile, setIsMobile] = useState(false); - const router = useRouter(); - const { data: session, isPending } = useSession(); - const t = useTranslations(); + const { folder } = useParams<{ folder: string }>(); + const [searchMode, setSearchMode] = useState(false); + const [searchValue] = useSearchValue(); + const [mail, setMail] = useMail(); + const [, clearBulkSelection] = useAtom(clearBulkSelectionAtom); + const [isMobile, setIsMobile] = useState(false); + const router = useRouter(); + const { data: session, isPending } = useSession(); + const t = useTranslations(); + const prevFolderRef = useRef(folder); + + useEffect(() => { + if (prevFolderRef.current !== folder && mail.bulkSelected.length > 0) { + clearBulkSelection(); + } + prevFolderRef.current = folder; + }, [folder, mail.bulkSelected.length, clearBulkSelection]); useEffect(() => { if (!session?.user && !isPending) { @@ -213,12 +232,7 @@ export function MailLayout() { } }, [session?.user, isPending]); - const { isLoading, isValidating } = useThreads( - folder, - undefined, - searchValue.value, - defaultPageSize, - ); + const { isLoading, isValidating } = useThreads(); const isDesktop = useMediaQuery('(min-width: 768px)'); @@ -370,49 +384,48 @@ export function MailLayout() { - {isDesktop && threadIdParam && ( - <> - -
- -
-
- - )} - - - {/* Mobile Drawer */} - {isMobile && ( - { - if (!isOpen) handleClose(); - }} - > - - - Email Details - -
-
- -
-
-
-
- )} - - - ); + {isDesktop && threadIdParam && ( + <> + +
+ +
+
+ + )} + + + {/* Mobile Drawer */} + {isMobile && ( + { + if (!isOpen) handleClose(); + }} + > + + + Email Details + +
+
+ +
+
+
+
+ )} + + + ); } function BulkSelectActions() { const t = useTranslations(); - const [mail] = useMail(); const [errorQty, setErrorQty] = useState(0); const [isLoading, setIsLoading] = useState(false); const [isUnsub, setIsUnsub] = useState(false); @@ -444,6 +457,35 @@ function BulkSelectActions() { }, ); }; + const [mail, setMail] = useMail(); + const { folder } = useParams<{ folder: string }>(); + const { mutate: mutateThreads } = useThreads(folder, undefined, '', defaultPageSize); + const { mutate: mutateStats } = useStats(); + + const onMoveSuccess = useCallback(async () => { + await mutateThreads(); + await mutateStats(); + setMail({ ...mail, bulkSelected: [] }); + }, [mail, setMail, mutateThreads, mutateStats]); + + const availableActions = getAvailableActions(folder).filter( + (action): action is Exclude => action !== null, + ); + + const actionButtons = { + spam: { + icon: , + tooltip: t('common.mail.moveToSpam'), + }, + archive: { + icon: , + tooltip: t('common.mail.archive'), + }, + inbox: { + icon: , + tooltip: t('common.mail.moveToInbox'), + }, + }; return (
@@ -488,68 +530,80 @@ function BulkSelectActions() { {t('common.mail.mute')} - - - - - {t('common.mail.moveToSpam')} - + + {availableActions.map((action) => ( + + + + + {actionButtons[action].tooltip} + + ))}
); } -const categories = [ - { - id: 'Primary', - name: 'common.mailCategories.primary', - searchValue: '', - icon: , - colors: - 'border-0 bg-gray-200 text-gray-700 dark:bg-gray-800/50 dark:text-gray-400 dark:hover:bg-gray-800/70', - }, - { - id: 'Important', - name: 'common.mailCategories.important', - searchValue: 'is:important', - icon: , - colors: - 'border-0 text-amber-800 bg-amber-100 dark:bg-amber-900/20 dark:text-amber-500 dark:hover:bg-amber-900/30', - }, - { - id: 'Personal', - name: 'common.mailCategories.personal', - searchValue: 'is:personal', - icon: , - colors: - 'border-0 text-green-800 bg-green-100 dark:bg-green-900/20 dark:text-green-500 dark:hover:bg-green-900/30', - }, - { - id: 'Updates', - name: 'common.mailCategories.updates', - searchValue: 'is:updates', - icon: , - colors: - 'border-0 text-purple-800 bg-purple-100 dark:bg-purple-900/20 dark:text-purple-500 dark:hover:bg-purple-900/30', - }, - { - id: 'Promotions', - name: 'common.mailCategories.promotions', - searchValue: 'is:promotions', - icon: , - colors: - 'border-0 text-red-800 bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:hover:bg-red-900/30', - }, - { - id: 'Favourites', - name: 'common.mailCategories.favourites', - searchValue: 'is:starred', - icon: , - colors: - 'border-0 text-pink-800 bg-pink-100 dark:bg-pink-900/20 dark:text-pink-500 dark:hover:bg-pink-900/30', - }, -]; +const Categories = () => { + const t = useTranslations(); + + return [ + { + id: 'primary', + name: t('common.mailCategories.primary'), + searchValue: '', + icon: , + colors: + 'border-0 bg-gray-200 text-gray-700 dark:bg-gray-800/50 dark:text-gray-400 dark:hover:bg-gray-800/70', + }, + { + id: 'important', + name: t('common.mailCategories.important'), + searchValue: 'is:important', + icon: , + colors: + 'border-0 text-amber-800 bg-amber-100 dark:bg-amber-900/20 dark:text-amber-500 dark:hover:bg-amber-900/30', + }, + { + id: 'personal', + name: t('common.mailCategories.personal'), + searchValue: 'is:personal', + icon: , + colors: + 'border-0 text-green-800 bg-green-100 dark:bg-green-900/20 dark:text-green-500 dark:hover:bg-green-900/30', + }, + { + id: 'updates', + name: t('common.mailCategories.updates'), + searchValue: 'is:updates', + icon: , + colors: + 'border-0 text-purple-800 bg-purple-100 dark:bg-purple-900/20 dark:text-purple-500 dark:hover:bg-purple-900/30', + }, + { + id: 'promotions', + name: t('common.mailCategories.promotions'), + searchValue: 'is:promotions', + icon: , + colors: + 'border-0 text-red-800 bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:hover:bg-red-900/30', + }, + ]; +}; function MailCategoryTabs({ iconsOnly = false, @@ -562,11 +616,14 @@ function MailCategoryTabs({ onCategoryChange?: (category: string) => void; initialCategory?: string; }) { - const [, setSearchValue] = useSearchValue(); - const t = useTranslations(); + const [, setSearchValue] = useSearchValue(); + const t = useTranslations(); + const categories = Categories(); - // Initialize with just the initialCategory or "Primary" - const [activeCategory, setActiveCategory] = useState('Primary'); + // Initialize with just the initialCategory or "Primary" + const [activeCategory, setActiveCategory] = useState( + initialCategory || t('common.mailCategories.primary'), + ); // Move localStorage logic to a useEffect useEffect(() => { @@ -644,72 +701,67 @@ function MailCategoryTabs({ return () => window.removeEventListener('resize', handleResize); }, [updateClipPath]); - return ( -
-
    - {categories.map((category) => ( -
  • - - - - - {iconsOnly && ( - - {t(category.name as MessageKey)} - - )} - -
  • - ))} -
- -
-
    - {categories.map((category) => ( -
  • - -
  • - ))} -
-
-
- ); + }} + className={cn( + 'flex h-7 items-center gap-1.5 rounded-full px-2 text-xs font-medium transition-all duration-200', + activeCategory === category.id + ? category.colors + : 'text-muted-foreground hover:text-foreground hover:bg-muted/50', + )} + > + {category.icon} + {category.name} + + + {iconsOnly && ( + + {category.name} + + )} + + + ))} + + +
+
    + {categories.map((category) => ( +
  • + +
  • + ))} +
+
+ + ); } diff --git a/apps/mail/components/mail/thread-display.tsx b/apps/mail/components/mail/thread-display.tsx index 0ca2d7aa53..373f53b504 100644 --- a/apps/mail/components/mail/thread-display.tsx +++ b/apps/mail/components/mail/thread-display.tsx @@ -2,9 +2,10 @@ import { DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown- import { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ArchiveX, Forward, ReplyAll, Star, StarOff } from 'lucide-react'; +import { useSearchParams, useParams } from 'next/navigation'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { useSearchParams } from 'next/navigation'; +import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions'; import { MoreVerticalIcon } from '../icons/animated/more-vertical'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useThread, useThreads } from '@/hooks/use-threads'; @@ -14,14 +15,16 @@ import { MailDisplaySkeleton } from './mail-skeleton'; import { ReplyIcon } from '../icons/animated/reply'; import { Button } from '@/components/ui/button'; import { modifyLabels } from '@/actions/mail'; +import { useStats } from '@/hooks/use-stats'; import ThreadSubject from './thread-subject'; import { XIcon } from '../icons/animated/x'; import ReplyCompose from './reply-composer'; import { useTranslations } from 'next-intl'; import { NotesPanel } from './note-panel'; +import { cn, FOLDERS } from '@/lib/utils'; import MailDisplay from './mail-display'; +import { Inbox } from 'lucide-react'; import { useMail } from './use-mail'; -import { cn } from '@/lib/utils'; import { toast } from 'sonner'; interface ThreadDisplayProps { @@ -187,32 +190,52 @@ function ThreadActionButton({ } export function ThreadDisplay({ mail, onClose, isMobile }: ThreadDisplayProps) { - const [, setMail] = useMail(); + const { data: emailData, isLoading, mutate: mutateThread } = useThread(); + const { mutate: mutateThreads } = useThreads(); const searchParams = useSearchParams(); - const threadIdParam = searchParams.get('threadId'); - const threadId = mail ?? threadIdParam ?? ''; - // Only fetch thread data if we have a valid threadId - const { data: emailData, isLoading, mutate } = useThread(threadId ?? ''); - const { mutate: mutateThreads } = useThreads('STARRED'); const [isMuted, setIsMuted] = useState(false); const [isReplyOpen, setIsReplyOpen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const t = useTranslations(); + const { mutate: mutateStats } = useStats(); + const { folder } = useParams<{ folder: string }>(); + const threadIdParam = searchParams.get('threadId'); + const threadId = mail ?? threadIdParam ?? ''; - const moreVerticalIconRef = useRef(null); + const moreVerticalIconRef = useRef(null); - useEffect(() => { - if (emailData?.[0]) { - setIsMuted(emailData[0].unread ?? false); - } - }, [emailData]); + const isInInbox = folder === FOLDERS.INBOX || !folder; + const isInArchive = folder === FOLDERS.ARCHIVE; + const isInSpam = folder === FOLDERS.SPAM; + + const handleClose = useCallback(() => { + onClose?.(); + }, [onClose]); + + const moveThreadTo = useCallback( + async (destination: ThreadDestination) => { + await moveThreadsTo({ + threadIds: [threadId], + currentFolder: folder, + destination, + onSuccess: async () => { + await mutateThread(); + await mutateStats(); + handleClose(); + }, + }); + }, + [threadId, folder, mutateThread, mutateStats, handleClose], + ); - const handleClose = useCallback(() => { - onClose?.(); - }, [onClose]); + useEffect(() => { + if (emailData?.[0]) { + setIsMuted(emailData[0].unread ?? false); + } + }, [emailData]); const handleFavourites = async () => { - if (!emailData) return; + if (!emailData || !threadId) return; if (emailData[0]?.tags?.includes('STARRED')) { toast.promise(modifyLabels({ threadId: [threadId], removeLabels: ['STARRED'] }), { success: 'Removed from favourites.', @@ -227,7 +250,7 @@ export function ThreadDisplay({ mail, onClose, isMobile }: ThreadDisplayProps) { }); } - await Promise.all([mutate(), mutateThreads()]); + await Promise.all([mutateThread(), mutateThreads()]); }; useEffect(() => { @@ -283,7 +306,7 @@ export function ThreadDisplay({ mail, onClose, isMobile }: ThreadDisplayProps) { /> ); - return ( -
-
-
-
- - -
-
- - setIsFullscreen(!isFullscreen)} - /> - - - setIsReplyOpen(true)} - /> - - - - - - - {t('common.threadDisplay.moveToSpam')} - - - {t('common.threadDisplay.replyAll')} - - - {t('common.threadDisplay.forward')} - - {t('common.threadDisplay.markAsUnread')} - {t('common.threadDisplay.addLabel')} - {t('common.threadDisplay.muteThread')} - - -
-
-
- -
- {[...(emailData || [])].reverse().map((message, index) => ( -
0 && 'border-border border-t', - )} - > - -
- ))} -
-
-
- -
-
-
-
- ); + return ( +
+
+
+
+ + +
+
+ + setIsFullscreen(!isFullscreen)} + /> + moveThreadTo('archive')} + /> + setIsReplyOpen(true)} + /> + + + + + + {isInInbox && ( + moveThreadTo('spam')}> + {t('common.threadDisplay.moveToSpam')} + + )} + {isInSpam && ( + moveThreadTo('inbox')}> + {t('common.mail.moveToInbox')} + + )} + {isInArchive && ( + moveThreadTo('inbox')}> + {t('common.mail.moveToInbox')} + + )} + + {t('common.threadDisplay.replyAll')} + + + {t('common.threadDisplay.forward')} + + {t('common.threadDisplay.markAsUnread')} + {t('common.threadDisplay.addLabel')} + {t('common.threadDisplay.muteThread')} + + +
+
+
+ +
+ {[...(emailData || [])].reverse().map((message, index) => ( +
0 && 'border-border border-t', + )} + > + +
+ ))} +
+
+
+ +
+
+
+
+ ); } diff --git a/apps/mail/components/mail/thread-subject.tsx b/apps/mail/components/mail/thread-subject.tsx index 94320bba8d..bc5ad62220 100644 --- a/apps/mail/components/mail/thread-subject.tsx +++ b/apps/mail/components/mail/thread-subject.tsx @@ -48,7 +48,7 @@ export default function ThreadSubject({ subject, isMobile }: ThreadSubjectProps) !subject && 'opacity-50', )} > - {subjectContent} + {subjectContent.trim()} {isOverflowing && ( diff --git a/apps/mail/components/mail/use-mail.ts b/apps/mail/components/mail/use-mail.ts index eb34429dea..9ad4b3c601 100644 --- a/apps/mail/components/mail/use-mail.ts +++ b/apps/mail/components/mail/use-mail.ts @@ -15,3 +15,11 @@ const configAtom = atom({ export function useMail() { return useAtom(configAtom); } + +export const clearBulkSelectionAtom = atom( + null, + (get, set) => { + const current = get(configAtom); + set(configAtom, { ...current, bulkSelected: [] }); + } +); diff --git a/apps/mail/components/ui/nav-main.tsx b/apps/mail/components/ui/nav-main.tsx index 7c020c913f..c9808ec2a8 100644 --- a/apps/mail/components/ui/nav-main.tsx +++ b/apps/mail/components/ui/nav-main.tsx @@ -3,6 +3,7 @@ import { SidebarGroup, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from './sidebar'; import { Collapsible, CollapsibleTrigger } from '@/components/ui/collapsible'; import { usePathname, useSearchParams } from 'next/navigation'; +import { clearBulkSelectionAtom } from '../mail/use-mail'; import { useFeaturebase } from '@/hooks/use-featurebase'; import { type MessageKey } from '@/config/navigation'; import { Badge } from '@/components/ui/badge'; @@ -11,6 +12,7 @@ import { useTranslations } from 'next-intl'; import { useRef, useCallback } from 'react'; import { BASE_URL } from '@/lib/constants'; import { cn } from '@/lib/utils'; +import { useAtom } from 'jotai'; import * as React from 'react'; import Link from 'next/link'; @@ -180,10 +182,11 @@ export function NavMain({ items }: NavMainProps) { } function NavItem(item: NavItemProps & { href: string }) { - const iconRef = useRef(null); - const { data: stats } = useStats(); - const { openFeaturebase } = useFeaturebase(); - const t = useTranslations(); + const iconRef = useRef(null); + const { data: stats } = useStats(); + const { openFeaturebase } = useFeaturebase(); + const t = useTranslations(); + const [, clearBulkSelection] = useAtom(clearBulkSelectionAtom); if (item.disabled) { return ( @@ -197,12 +200,14 @@ function NavItem(item: NavItemProps & { href: string }) { ); } - // Handle Featurebase button click - const handleClick = (e: React.MouseEvent) => { - if (item.isFeaturebaseButton) { - e.preventDefault(); - openFeaturebase(); - } + // Handle Featurebase button click + const handleClick = (e: React.MouseEvent) => { + clearBulkSelection(); + + if (item.isFeaturebaseButton) { + e.preventDefault(); + openFeaturebase(); + } if (item.onClick) { item.onClick(e); diff --git a/apps/mail/config/navigation.ts b/apps/mail/config/navigation.ts index c2fecf3cad..5e9461d150 100644 --- a/apps/mail/config/navigation.ts +++ b/apps/mail/config/navigation.ts @@ -68,7 +68,6 @@ export const navigationConfig: Record = { title: "navigation.sidebar.archive", url: "/mail/archive", icon: ArchiveIcon, - disabled: true, }, { title: "navigation.sidebar.bin", diff --git a/apps/mail/hooks/use-threads.ts b/apps/mail/hooks/use-threads.ts index e81d50ec52..f3551e47d8 100644 --- a/apps/mail/hooks/use-threads.ts +++ b/apps/mail/hooks/use-threads.ts @@ -1,15 +1,18 @@ "use client"; +import { getMail, getMails, markAsRead } from '@/actions/mail'; +import { useParams, useSearchParams } from 'next/navigation'; import type { InitialThread, ParsedMessage } from '@/types'; -import { getMail, getMails } from '@/actions/mail'; +import { useSearchValue } from '@/hooks/use-search-value'; import { useSession } from '@/lib/auth-client'; +import { defaultPageSize } from '@/lib/utils'; import useSWRInfinite from 'swr/infinite'; import useSWR, { preload } from 'swr'; import { useMemo } from 'react'; export const preloadThread = async (userId: string, threadId: string, connectionId: string) => { console.log(`🔄 Prefetching email ${threadId}...`); - await preload([userId, threadId, connectionId], fetchThread); + await preload([userId, threadId, connectionId], fetchThread(undefined)); }; type FetchEmailsTuple = [ @@ -39,10 +42,20 @@ const fetchEmails = async ([ } }; -const fetchThread = async (args: any[]) => { +const fetchThread = (cb: any) => async (args: any[]) => { const [_, id] = args; try { - return await getMail({ id }); + return await getMail({ id }).then((response) => { + if (response) { + const unreadMessages = response.filter(e=>e.unread).map(e=>e.id) + if (unreadMessages.length) { + markAsRead({ids: unreadMessages}).then(()=>{ + if (cb && typeof cb === 'function') cb() + }); + } + return response + } + }); } catch (error) { console.error("Error fetching email:", error); throw error; @@ -65,13 +78,15 @@ const getKey = ( return [connectionId, folder, query, max, labelIds, previousPageData?.nextPageToken]; }; -export const useThreads = (folder: string, labelIds?: string[], query?: string, max?: number) => { +export const useThreads = () => { + const { folder } = useParams<{ folder: string }>(); + const [searchValue] = useSearchValue(); const { data: session } = useSession(); const { data, error, size, setSize, isLoading, isValidating, mutate } = useSWRInfinite( (_, previousPageData) => { if (!session?.user.id || !session.connectionId) return null; - return getKey(previousPageData, [session.connectionId, folder, query, max, labelIds]); + return getKey(previousPageData, [session.connectionId, folder, searchValue.value, defaultPageSize]); }, fetchEmails, { @@ -111,12 +126,15 @@ export const useThreads = (folder: string, labelIds?: string[], query?: string, }; }; -export const useThread = (id: string | null) => { +export const useThread = () => { const { data: session } = useSession(); + const searchParams = useSearchParams(); + const id = searchParams.get('threadId'); + const {mutate: mutateThreads} = useThreads() const { data, isLoading, error, mutate } = useSWR( session?.user.id && id ? [session.user.id, id, session.connectionId] : null, - fetchThread as any, + fetchThread(mutateThreads) as any, ); const hasUnread = useMemo(() => data?.some((e) => e.unread), [data]); diff --git a/apps/mail/lib/email-utils.client.ts b/apps/mail/lib/email-utils.client.tsx similarity index 78% rename from apps/mail/lib/email-utils.client.ts rename to apps/mail/lib/email-utils.client.tsx index fc31a625b3..1081c9df14 100644 --- a/apps/mail/lib/email-utils.client.ts +++ b/apps/mail/lib/email-utils.client.tsx @@ -55,3 +55,23 @@ export const handleUnsubscribe = async ({ emailData }: { emailData: ParsedMessag throw error; } }; + +export const highlightText = (text: string, highlight: string) => { + if (!highlight?.trim()) return text; + + const regex = new RegExp(`(${highlight})`, 'gi'); + const parts = text.split(regex); + + return parts.map((part, i) => { + return i % 2 === 1 ? ( + + {part} + + ) : ( + part + ); + }); +}; diff --git a/apps/mail/lib/thread-actions.ts b/apps/mail/lib/thread-actions.ts new file mode 100644 index 0000000000..94122e73d4 --- /dev/null +++ b/apps/mail/lib/thread-actions.ts @@ -0,0 +1,103 @@ +import { modifyLabels } from '@/actions/mail'; +import { LABELS, FOLDERS } from '@/lib/utils'; +import { toast } from 'sonner'; +import { getTranslations } from 'next-intl/server'; + +export type ThreadDestination = 'inbox' | 'archive' | 'spam' | null; +export type FolderLocation = 'inbox' | 'archive' | 'spam' | 'sent' | string; + +interface MoveThreadOptions { + threadIds: string[]; + currentFolder: FolderLocation; + destination: ThreadDestination; + onSuccess?: () => Promise | void; + onError?: (error: any) => void; +} + +export function isActionAvailable(folder: FolderLocation, action: ThreadDestination): boolean { + if (!action) return false; + + const pattern = `${folder}_to_${action}`; + + switch (pattern) { + // From inbox rules + case `${FOLDERS.INBOX}_to_spam`: + return true; + case `${FOLDERS.INBOX}_to_archive`: + return true; + + // From archive rules + case `${FOLDERS.ARCHIVE}_to_inbox`: + return true; + + // From spam rules + case `${FOLDERS.SPAM}_to_inbox`: + return true; + + default: + return false; + } +} + +export function getAvailableActions(folder: FolderLocation): ThreadDestination[] { + const allPossibleActions: ThreadDestination[] = ['inbox', 'archive', 'spam']; + return allPossibleActions.filter(action => isActionAvailable(folder, action)); +} + +export async function moveThreadsTo({ + threadIds, + currentFolder, + destination, + onSuccess, + onError +}: MoveThreadOptions) { + try { + if (!threadIds.length) return; + + const t = await getTranslations('common.mail'); + + const formattedIds = threadIds.map(id => + id.startsWith('thread:') ? id : `thread:${id}` + ); + + const isInInbox = currentFolder === FOLDERS.INBOX || !currentFolder; + const isInSpam = currentFolder === FOLDERS.SPAM; + + let addLabel = ''; + let removeLabel = ''; + + switch(destination) { + case 'inbox': + addLabel = LABELS.INBOX; + removeLabel = isInSpam ? LABELS.SPAM : ''; + break; + case 'archive': + removeLabel = isInInbox ? LABELS.INBOX : (isInSpam ? LABELS.SPAM : ''); + break; + case 'spam': + addLabel = LABELS.SPAM; + removeLabel = isInInbox ? LABELS.INBOX : ''; + break; + default: + break; + } + + return toast.promise( + modifyLabels({ + threadId: formattedIds, + addLabels: addLabel ? [addLabel] : [], + removeLabels: removeLabel ? [removeLabel] : [], + }).then(async () => { + if (onSuccess) await onSuccess(); + }), + { + loading: t('moving'), + success: () => t('moved'), + error: t('errorMoving'), + }, + ); + } catch (error) { + console.error(`Error moving thread(s):`, error); + if (onError) onError(error); + } +} \ No newline at end of file diff --git a/apps/mail/locales/en.json b/apps/mail/locales/en.json index 6c9d19d36e..fdaa033e42 100644 --- a/apps/mail/locales/en.json +++ b/apps/mail/locales/en.json @@ -1,321 +1,326 @@ { - "common": { - "actions": { - "logout": "Logout", - "back": "Back", - "create": "Create Email", - "saveChanges": "Save changes", - "saving": "Saving...", - "resetToDefaults": "Reset to Defaults", - "close": "Close", - "signingOut": "Signing out...", - "signedOutSuccess": "Signed out successfully!", - "signOutError": "Error signing out" - }, - "themes": { - "dark": "Dark", - "light": "Light", - "system": "System" - }, - "commandPalette": { - "title": "Command Palette", - "description": "Quick navigation and actions for Mail-0", - "placeholder": "Type a command or search...", - "noResults": "No results found", - "groups": { - "mail": "Mail", - "settings": "Settings", - "actions": "Actions", - "help": "Help", - "navigation": "Navigation" - }, - "commands": { - "goToInbox": "Go to Inbox", - "goToDrafts": "Go to Drafts", - "goToSent": "Go to Sent", - "goToSpam": "Go to Spam", - "goToArchive": "Go to Archive", - "goToBin": "Go to Bin", - "goToSettings": "Go to Settings", - "newEmail": "New Email", - "composeMessage": "Compose message", - "searchEmails": "Search Emails", - "toggleTheme": "Toggle Theme", - "backToMail": "Back to Mail", - "goToDocs": "Go to docs", - "helpWithShortcuts": "Help with shortcuts" - } - }, - "searchBar": { - "pickDateRange": "Pick a date or a range", - "search": "Search", - "clearSearch": "Clear search", - "advancedSearch": "Advanced search", - "quickFilters": "Quick filters", - "searchIn": "Search in", - "recipient": "Recipient", - "sender": "Sender", - "subject": "Subject", - "dateRange": "Date range", - "category": "Category", - "folder": "Folder", - "allMail": "All Mail", - "unread": "Unread", - "hasAttachment": "Has Attachment", - "starred": "Starred", - "applyFilters": "Apply filters", - "reset": "Reset" - }, - "navUser": { - "customerSupport": "Customer Support", - "documentation": "Documentation", - "appTheme": "App Theme", - "accounts": "Accounts", - "signIn": "Sign in" - }, - "mailCategories": { - "primary": "Primary", - "important": "Important", - "personal": "Personal", - "updates": "Updates", - "promotions": "Promotions", - "social": "Social", - "favourites": "Favourites" - }, - "replyCompose": { - "replyTo": "Reply to", - "thisEmail": "this email", - "dropFiles": "Drop files to attach", - "attachments": "Attachments", - "attachmentCount": "{count, plural, =0 {attachments} one {attachment} other {attachments}}", - "fileCount": "{count, plural, =0 {files} one {file} other {files}}", - "saveDraft": "Save draft", - "send": "Send" - }, - "mailDisplay": { - "details": "Details", - "from": "From", - "to": "To", - "cc": "Cc", - "date": "Date", - "mailedBy": "Mailed-By", - "signedBy": "Signed-By", - "security": "Security", - "standardEncryption": "Standard encryption (TLS)", - "loadingMailContent": "Loading mail content...", - "unsubscribe": "Unsubscribe", - "unsubscribed": "Unsubscribed", - "unsubscribeDescription": "Are you sure you want to unsubscribe from this mailing list?", - "unsubscribeOpenSiteDescription": "To stop getting messages from this mailing list, go to their website to unsubscribe.", - "cancel": "Cancel", - "goToWebsite": "Go to website", - "failedToUnsubscribe": "Failed to unsubscribe from mailing list" - }, - "threadDisplay": { - "exitFullscreen": "Exit fullscreen", - "enterFullscreen": "Enter fullscreen", - "archive": "Archive", - "reply": "Reply", - "moreOptions": "More options", - "moveToSpam": "Move to spam", - "replyAll": "Reply all", - "forward": "Forward", - "markAsUnread": "Mark as unread", - "addLabel": "Add label", - "muteThread": "Mute thread", - "favourites": "Favourites" - }, - "notes": { - "title": "Notes", - "empty": "No notes for this email", - "emptyDescription": "Add notes to keep track of important information or follow-ups.", - "addNote": "Add a note", - "addYourNote": "Add your note here...", - "editNote": "Edit note", - "deleteNote": "Delete note", - "deleteConfirm": "Are you sure you want to delete this note?", - "deleteConfirmDescription": "This action cannot be undone.", - "cancel": "Cancel", - "delete": "Delete", - "save": "Save note", - "toSave": "to save", - "label": "Label:", - "search": "Search notes...", - "noteCount": "{count, plural, =0 {Add notes} one {# note} other {# notes}}", - "notePinned": "Note pinned", - "noteUnpinned": "Note unpinned", - "colorChanged": "Note color updated", - "noteUpdated": "Note updated", - "noteDeleted": "Note deleted", - "noteCopied": "Copied to clipboard", - "noteAdded": "Note added", - "notesReordered": "Notes reordered", - "noMatchingNotes": "No notes matching \"{query}\"", - "clearSearch": "Clear search", - "pinnedNotes": "Pinned notes", - "otherNotes": "Other notes", - "created": "Created", - "updated": "Updated", - "errors": { - "failedToLoadNotes": "Failed to load notes", - "failedToLoadThreadNotes": "Failed to load thread notes", - "failedToAddNote": "Failed to add note", - "failedToUpdateNote": "Failed to update note", - "failedToDeleteNote": "Failed to delete note", - "failedToUpdateNoteColor": "Failed to update note color", - "noValidNotesToReorder": "No valid notes to reorder", - "failedToReorderNotes": "Failed to reorder notes" - }, - "colors": { - "default": "Default", - "red": "Red", - "orange": "Orange", - "yellow": "Yellow", - "green": "Green", - "blue": "Blue", - "purple": "Purple", - "pink": "Pink" - }, - "actions": { - "pin": "Pin note", - "unpin": "Unpin note", - "edit": "Edit note", - "delete": "Delete note", - "copy": "Copy note", - "changeColor": "Change color" - } - }, - "mail": { - "replies": "{count, plural, =0 {replies} one {# reply} other {# replies}}", - "deselectAll": "Deselected all emails", - "selectedEmails": "Selected {count} emails", - "noEmailsToSelect": "No emails to select", - "markedAsRead": "Marked as read", - "markedAsUnread": "Marked as unread", - "failedToMarkAsRead": "Failed to mark as read", - "failedToMarkAsUnread": "Failed to mark as unread", - "selected": "{count} selected", - "clearSelection": "Clear Selection", - "mute": "Mute", - "moveToSpam": "Move to Spam", - "moveToInbox": "Move to Inbox", - "unarchive": "Unarchive", - "archive": "Archive", - "moveToTrash": "Move to Trash", - "markAsUnread": "Mark as Unread", - "addStar": "Add Star", - "muteThread": "Mute Thread", - "moving": "Moving...", - "moved": "Moved", - "errorMoving": "Error moving" - } - }, - "navigation": { - "sidebar": { - "inbox": "Inbox", - "drafts": "Drafts", - "sent": "Sent", - "spam": "Spam", - "archive": "Archive", - "bin": "Bin", - "feedback": "Feedback", - "contact": "Contact", - "settings": "Settings" - }, - "settings": { - "general": "General", - "connections": "Connections", - "security": "Security", - "appearance": "Appearance", - "shortcuts": "Shortcuts" - } - }, - "pages": { - "error": { - "notFound": { - "title": "Page Not Found", - "description": "Oops! The page you're looking for doesn't exist or has been moved.", - "goBack": "Go Back" - }, - "settingsNotFound": "404 - Settings page not found" - }, - "settings": { - "general": { - "title": "General", - "description": "Manage settings for your language and email display preferences.", - "language": "Language", - "timezone": "Timezone", - "dynamicContent": "Dynamic Content", - "dynamicContentDescription": "Allow emails to display dynamic content.", - "externalImages": "Display External Images", - "externalImagesDescription": "Allow emails to display images from external sources.", - "languageChangedTo": "Language changed to {language}" - }, - "connections": { - "title": "Email Connections", - "description": "Connect your email accounts to Zero.", - "disconnectTitle": "Disconnect Email Account", - "disconnectDescription": "Are you sure you want to disconnect this email?", - "cancel": "Cancel", - "remove": "Remove", - "disconnectSuccess": "Account disconnected successfully", - "disconnectError": "Failed to disconnect account", - "addEmail": "Add Connection", - "connectEmail": "Connect Email", - "connectEmailDescription": "Select an email provider to connect", - "moreComingSoon": "More coming soon" - }, - "security": { - "title": "Security", - "description": "Manage your security preferences and account protection.", - "twoFactorAuth": "Two-Factor Authentication", - "twoFactorAuthDescription": "Add an extra layer of security to your account", - "loginNotifications": "Login Notifications", - "loginNotificationsDescription": "Get notified about new login attempts", - "deleteAccount": "Delete Account" - }, - "appearance": { - "title": "Appearance", - "description": "Customize colors, fonts and view options.", - "theme": "Theme", - "inboxType": "Inbox Type" - }, - "shortcuts": { - "title": "Keyboard Shortcuts", - "description": "View and customize keyboard shortcuts for quick actions.", - "actions": { - "newEmail": "New Email", - "sendEmail": "Send Email", - "reply": "Reply", - "replyAll": "Reply All", - "forward": "Forward", - "drafts": "Drafts", - "inbox": "Inbox", - "sentMail": "Sent Mail", - "delete": "Delete", - "search": "Search", - "markAsUnread": "Mark as Unread", - "muteThread": "Mute Thread", - "printEmail": "Print Email", - "archiveEmail": "Archive Email", - "markAsSpam": "Mark as Spam", - "moveToFolder": "Move to Folder", - "undoLastAction": "Undo Last Action", - "viewEmailDetails": "View Email Details", - "goToDrafts": "Go to Drafts", - "expandEmailView": "Expand Email View", - "helpWithShortcuts": "Help with shortcuts" - } - } - }, - "createEmail": { - "body": "Body", - "example": "zero@0.email", - "attachments": "Attachments", - "dropFilesToAttach": "Drop files to attach", - "writeYourMessageHere": "Write your message here...", - "emailSentSuccessfully": "Email sent successfully", - "failedToSendEmail": "Failed to send email. Please try again." - } - } + "common": { + "actions": { + "logout": "Logout", + "back": "Back", + "create": "Create Email", + "saveChanges": "Save changes", + "saving": "Saving...", + "resetToDefaults": "Reset to Defaults", + "close": "Close", + "signingOut": "Signing out...", + "signedOutSuccess": "Signed out successfully!", + "signOutError": "Error signing out" + }, + "themes": { + "dark": "Dark", + "light": "Light", + "system": "System" + }, + "commandPalette": { + "title": "Command Palette", + "description": "Quick navigation and actions for Mail-0", + "placeholder": "Type a command or search...", + "noResults": "No results found", + "groups": { + "mail": "Mail", + "settings": "Settings", + "actions": "Actions", + "help": "Help", + "navigation": "Navigation" + }, + "commands": { + "goToInbox": "Go to Inbox", + "goToDrafts": "Go to Drafts", + "goToSent": "Go to Sent", + "goToSpam": "Go to Spam", + "goToArchive": "Go to Archive", + "goToBin": "Go to Bin", + "goToSettings": "Go to Settings", + "newEmail": "New Email", + "composeMessage": "Compose message", + "searchEmails": "Search Emails", + "toggleTheme": "Toggle Theme", + "backToMail": "Back to Mail", + "goToDocs": "Go to docs", + "helpWithShortcuts": "Help with shortcuts" + } + }, + "searchBar": { + "pickDateRange": "Pick a date or a range", + "search": "Search", + "clearSearch": "Clear search", + "advancedSearch": "Advanced search", + "quickFilters": "Quick filters", + "searchIn": "Search in", + "recipient": "Recipient", + "sender": "Sender", + "subject": "Subject", + "dateRange": "Date range", + "category": "Category", + "folder": "Folder", + "allMail": "All Mail", + "unread": "Unread", + "hasAttachment": "Has Attachment", + "starred": "Starred", + "applyFilters": "Apply filters", + "reset": "Reset" + }, + "navUser": { + "customerSupport": "Customer Support", + "documentation": "Documentation", + "appTheme": "App Theme", + "accounts": "Accounts", + "signIn": "Sign in" + }, + "mailCategories": { + "primary": "Primary", + "important": "Important", + "personal": "Personal", + "updates": "Updates", + "promotions": "Promotions", + "social": "Social" + }, + "replyCompose": { + "replyTo": "Reply to", + "thisEmail": "this email", + "dropFiles": "Drop files to attach", + "attachments": "Attachments", + "attachmentCount": "{count, plural, =0 {attachments} one {attachment} other {attachments}}", + "fileCount": "{count, plural, =0 {files} one {file} other {files}}", + "saveDraft": "Save draft", + "send": "Send" + }, + "mailDisplay": { + "details": "Details", + "from": "From", + "to": "To", + "cc": "Cc", + "date": "Date", + "mailedBy": "Mailed-By", + "signedBy": "Signed-By", + "security": "Security", + "standardEncryption": "Standard encryption (TLS)", + "loadingMailContent": "Loading mail content...", + "unsubscribe": "Unsubscribe", + "unsubscribed": "Unsubscribed", + "unsubscribeDescription": "Are you sure you want to unsubscribe from this mailing list?", + "unsubscribeOpenSiteDescription": "To stop getting messages from this mailing list, go to their website to unsubscribe.", + "cancel": "Cancel", + "goToWebsite": "Go to website", + "failedToUnsubscribe": "Failed to unsubscribe from mailing list" + }, + "threadDisplay": { + "exitFullscreen": "Exit fullscreen", + "enterFullscreen": "Enter fullscreen", + "archive": "Archive", + "reply": "Reply", + "moreOptions": "More options", + "moveToSpam": "Move to spam", + "replyAll": "Reply all", + "forward": "Forward", + "markAsUnread": "Mark as unread", + "addLabel": "Add label", + "muteThread": "Mute thread", + "favourites": "Favourites" + }, + "notes": { + "title": "Notes", + "empty": "No notes for this email", + "emptyDescription": "Add notes to keep track of important information or follow-ups.", + "addNote": "Add a note", + "addYourNote": "Add your note here...", + "editNote": "Edit note", + "deleteNote": "Delete note", + "deleteConfirm": "Are you sure you want to delete this note?", + "deleteConfirmDescription": "This action cannot be undone.", + "cancel": "Cancel", + "delete": "Delete", + "save": "Save note", + "toSave": "to save", + "label": "Label:", + "search": "Search notes...", + "noteCount": "{count, plural, =0 {Add notes} one {# note} other {# notes}}", + "notePinned": "Note pinned", + "noteUnpinned": "Note unpinned", + "colorChanged": "Note color updated", + "noteUpdated": "Note updated", + "noteDeleted": "Note deleted", + "noteCopied": "Copied to clipboard", + "noteAdded": "Note added", + "notesReordered": "Notes reordered", + "noMatchingNotes": "No notes matching \"{query}\"", + "clearSearch": "Clear search", + "pinnedNotes": "Pinned notes", + "otherNotes": "Other notes", + "created": "Created", + "updated": "Updated", + "errors": { + "failedToLoadNotes": "Failed to load notes", + "failedToLoadThreadNotes": "Failed to load thread notes", + "failedToAddNote": "Failed to add note", + "failedToUpdateNote": "Failed to update note", + "failedToDeleteNote": "Failed to delete note", + "failedToUpdateNoteColor": "Failed to update note color", + "noValidNotesToReorder": "No valid notes to reorder", + "failedToReorderNotes": "Failed to reorder notes" + }, + "colors": { + "default": "Default", + "red": "Red", + "orange": "Orange", + "yellow": "Yellow", + "green": "Green", + "blue": "Blue", + "purple": "Purple", + "pink": "Pink" + }, + "actions": { + "pin": "Pin note", + "unpin": "Unpin note", + "edit": "Edit note", + "delete": "Delete note", + "copy": "Copy note", + "changeColor": "Change color" + } + }, + "mail": { + "replies": "{count, plural, =0 {replies} one {# reply} other {# replies}}", + "deselectAll": "Deselected all emails", + "selectedEmails": "Selected {count} emails", + "noEmailsToSelect": "No emails to select", + "markedAsRead": "Marked as read", + "markedAsUnread": "Marked as unread", + "failedToMarkAsRead": "Failed to mark as read", + "failedToMarkAsUnread": "Failed to mark as unread", + "selected": "{count} selected", + "clearSelection": "Clear Selection", + "mute": "Mute", + "moveToSpam": "Move to Spam", + "moveToInbox": "Move to Inbox", + "unarchive": "Unarchive", + "archive": "Archive", + "moveToTrash": "Move to Trash", + "markAsUnread": "Mark as Unread", + "addStar": "Add Star", + "muteThread": "Mute Thread", + "moving": "Moving...", + "moved": "Moved", + "errorMoving": "Error moving", + "reply": "Reply", + "replyAll": "Reply All", + "forward": "Forward", + "labels": "Labels", + "createNewLabel": "Create New Label", + "noLabelsAvailable": "No labels available" + } + }, + "navigation": { + "sidebar": { + "inbox": "Inbox", + "drafts": "Drafts", + "sent": "Sent", + "spam": "Spam", + "archive": "Archive", + "bin": "Bin", + "feedback": "Feedback", + "contact": "Contact", + "settings": "Settings" + }, + "settings": { + "general": "General", + "connections": "Connections", + "security": "Security", + "appearance": "Appearance", + "shortcuts": "Shortcuts" + } + }, + "pages": { + "error": { + "notFound": { + "title": "Page Not Found", + "description": "Oops! The page you're looking for doesn't exist or has been moved.", + "goBack": "Go Back" + }, + "settingsNotFound": "404 - Settings page not found" + }, + "settings": { + "general": { + "title": "General", + "description": "Manage settings for your language and email display preferences.", + "language": "Language", + "timezone": "Timezone", + "dynamicContent": "Dynamic Content", + "dynamicContentDescription": "Allow emails to display dynamic content.", + "externalImages": "Display External Images", + "externalImagesDescription": "Allow emails to display images from external sources.", + "languageChangedTo": "Language changed to {language}" + }, + "connections": { + "title": "Email Connections", + "description": "Connect your email accounts to Zero.", + "disconnectTitle": "Disconnect Email Account", + "disconnectDescription": "Are you sure you want to disconnect this email?", + "cancel": "Cancel", + "remove": "Remove", + "disconnectSuccess": "Account disconnected successfully", + "disconnectError": "Failed to disconnect account", + "addEmail": "Add Connection", + "connectEmail": "Connect Email", + "connectEmailDescription": "Select an email provider to connect", + "moreComingSoon": "More coming soon" + }, + "security": { + "title": "Security", + "description": "Manage your security preferences and account protection.", + "twoFactorAuth": "Two-Factor Authentication", + "twoFactorAuthDescription": "Add an extra layer of security to your account", + "loginNotifications": "Login Notifications", + "loginNotificationsDescription": "Get notified about new login attempts", + "deleteAccount": "Delete Account" + }, + "appearance": { + "title": "Appearance", + "description": "Customize colors, fonts and view options.", + "theme": "Theme", + "inboxType": "Inbox Type" + }, + "shortcuts": { + "title": "Keyboard Shortcuts", + "description": "View and customize keyboard shortcuts for quick actions.", + "actions": { + "newEmail": "New Email", + "sendEmail": "Send Email", + "reply": "Reply", + "replyAll": "Reply All", + "forward": "Forward", + "drafts": "Drafts", + "inbox": "Inbox", + "sentMail": "Sent Mail", + "delete": "Delete", + "search": "Search", + "markAsUnread": "Mark as Unread", + "muteThread": "Mute Thread", + "printEmail": "Print Email", + "archiveEmail": "Archive Email", + "markAsSpam": "Mark as Spam", + "moveToFolder": "Move to Folder", + "undoLastAction": "Undo Last Action", + "viewEmailDetails": "View Email Details", + "goToDrafts": "Go to Drafts", + "expandEmailView": "Expand Email View", + "helpWithShortcuts": "Help with shortcuts" + } + } + }, + "createEmail": { + "body": "Body", + "example": "zero@0.email", + "attachments": "Attachments", + "dropFilesToAttach": "Drop files to attach", + "writeYourMessageHere": "Write your message here...", + "emailSentSuccessfully": "Email sent successfully", + "failedToSendEmail": "Failed to send email. Please try again." + } + } } diff --git a/apps/mail/types/index.ts b/apps/mail/types/index.ts index c5fe0b92da..22cf596676 100644 --- a/apps/mail/types/index.ts +++ b/apps/mail/types/index.ts @@ -99,7 +99,7 @@ export type ThreadProps = { message: InitialThread; selectMode: MailSelectMode; // TODO: enforce types instead of sprinkling "any" - onClick?: (message: InitialThread) => () => Promise | undefined; + onClick?: (message: InitialThread) => () => any; isCompact?: boolean; };