diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx index 9ec14cbb2b..97f2384cd5 100644 --- a/apps/mail/components/mail/mail-display.tsx +++ b/apps/mail/components/mail/mail-display.tsx @@ -18,13 +18,13 @@ import { useEffect, useMemo, useState } from 'react'; import AttachmentDialog from './attachment-dialog'; import { useSummary } from '@/hooks/use-summary'; import { TextShimmer } from '../ui/text-shimmer'; +import { cn, getEmailLogo } from '@/lib/utils'; import { type ParsedMessage } from '@/types'; import { Separator } from '../ui/separator'; import { useTranslations } from 'next-intl'; import { MailIframe } from './mail-iframe'; import { Button } from '../ui/button'; import { format } from 'date-fns'; -import { cn } from '@/lib/utils'; import Image from 'next/image'; const StreamingText = ({ text }: { text: string }) => { @@ -150,20 +150,13 @@ const MailDisplay = ({ emailData, isMuted, index, demo }: Props) => {
- - - - {emailData?.sender?.name - ?.split(' ') - .map((chunk) => chunk[0]?.toUpperCase()) - .filter((char) => char?.match(/[A-Z]/)) - .slice(0, 2) - .join('')} + + + + {emailData?.sender?.name[0]}
diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index 89fa8d5190..036d127978 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -1,18 +1,29 @@ 'use client'; +import { + type ComponentProps, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode } from '@/types'; import { AlertTriangle, Bell, Briefcase, Star, StickyNote, Tag, User, Users } from 'lucide-react'; -import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { EmptyState, type FolderType } from '@/components/mail/empty-state'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { cn, formatDate } from '@/lib/utils'; +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 { 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 { MailQuickActions } from './mail-quick-actions'; import { useMail } from '@/components/mail/use-mail'; import type { VirtuosoHandle } from 'react-virtuoso'; import { useSession } from '@/lib/auth-client'; @@ -21,37 +32,35 @@ import { useTranslations } from 'next-intl'; import { Virtuoso } from 'react-virtuoso'; import items from './demo.json'; import { toast } from 'sonner'; -import { MailQuickActions } from './mail-quick-actions'; -import { useMailNavigation } from '@/hooks/use-mail-navigation'; const HOVER_DELAY = 1000; // ms before prefetching const Thread = memo( - ({ - message, - selectMode, - demo, - onClick, - sessionData, + ({ + message, + selectMode, + demo, + onClick, + sessionData, isKeyboardFocused, isInQuickActionMode, selectedQuickActionIndex, resetNavigation, - }: ConditionalThreadProps & { - folder?: string; + }: ConditionalThreadProps & { + folder?: string; isKeyboardFocused?: boolean; isInQuickActionMode?: boolean; selectedQuickActionIndex?: number; resetNavigation?: () => 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 [isHovered, setIsHovered] = useState(false); + }) => { + 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 [isHovered, setIsHovered] = useState(false); const isMailSelected = useMemo(() => { const threadId = message.threadId ?? message.id; @@ -115,6 +124,7 @@ const Thread = memo( 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.sender.name[0]} + +
+
+
+
+

+ + {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)} +

- {message.receivedOn ? ( -

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

- ) : null}
-

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

- ); - }, + ); + }, ); Thread.displayName = 'Thread'; @@ -241,11 +263,14 @@ export const MailList = memo(({ isCompact }: MailListProps) => { const parentRef = useRef(null); const scrollRef = useRef(null); - const handleNavigateToThread = useCallback((threadId: string) => { - const currentParams = new URLSearchParams(searchParams.toString()); - currentParams.set('threadId', threadId); - router.push(`/mail/${folder}?${currentParams.toString()}`); - }, [folder, router, searchParams]); + const handleNavigateToThread = useCallback( + (threadId: string) => { + const currentParams = new URLSearchParams(searchParams.toString()); + currentParams.set('threadId', threadId); + router.push(`/mail/${folder}?${currentParams.toString()}`); + }, + [folder, router, searchParams], + ); const { focusedIndex, @@ -253,11 +278,11 @@ export const MailList = memo(({ isCompact }: MailListProps) => { quickActionIndex, handleMouseEnter, keyboardActive, - resetNavigation - } = useMailNavigation({ - items, + resetNavigation, + } = useMailNavigation({ + items, containerRef: parentRef, - onNavigate: handleNavigateToThread + onNavigate: handleNavigateToThread, }); const handleScroll = useCallback(() => { @@ -373,7 +398,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { const handleMailClick = useCallback( (message: InitialThread) => () => { handleMouseEnter(message.id); - + const selectMode = getSelectMode(); if (selectMode === 'mass') { @@ -445,15 +470,15 @@ export const MailList = memo(({ isCompact }: MailListProps) => { [getSelectMode, folder, searchParams, items, handleMouseEnter], ); - const isEmpty = items.length === 0; - const isFiltering = searchValue.value.trim().length > 0; + const isEmpty = items.length === 0; + const isFiltering = searchValue.value.trim().length > 0; - if (isEmpty && session) { - if (isFiltering) { - return ; - } - return ; - } + if (isEmpty && session) { + if (isFiltering) { + return ; + } + return ; + } return ( <> @@ -555,8 +580,8 @@ const MailLabels = memo( labelContent = t('common.mailCategories.social'); break; case 'starred': - labelContent = 'Starred' - break + labelContent = 'Starred'; + break; default: labelContent = capitalize(normalizedLabel); } diff --git a/apps/mail/lib/utils.ts b/apps/mail/lib/utils.ts index 88cd0d1e43..927bf673d8 100644 --- a/apps/mail/lib/utils.ts +++ b/apps/mail/lib/utils.ts @@ -1,36 +1,36 @@ -import { format, isToday, isThisMonth, differenceInCalendarMonths } from "date-fns"; -import { MAX_URL_LENGTH } from "./constants"; -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; -import LZString from "lz-string"; -import axios from "axios"; +import { format, isToday, isThisMonth, differenceInCalendarMonths } from 'date-fns'; +import { MAX_URL_LENGTH } from './constants'; +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; +import LZString from 'lz-string'; +import axios from 'axios'; export const FOLDERS = { - SPAM: "spam", - INBOX: "inbox", - ARCHIVE: "archive", - TRASH: "trash", - DRAFT: "draft", - SENT: "sent", + SPAM: 'spam', + INBOX: 'inbox', + ARCHIVE: 'archive', + TRASH: 'trash', + DRAFT: 'draft', + SENT: 'sent', } as const; export const LABELS = { - SPAM: "SPAM", - INBOX: "INBOX", - UNREAD: "UNREAD", - IMPORTANT: "IMPORTANT", - SENT: "SENT", + SPAM: 'SPAM', + INBOX: 'INBOX', + UNREAD: 'UNREAD', + IMPORTANT: 'IMPORTANT', + SENT: 'SENT', } as const; export const FOLDER_NAMES = [ - "inbox", - "spam", - "trash", - "unread", - "starred", - "important", - "sent", - "draft", + 'inbox', + 'spam', + 'trash', + 'unread', + 'starred', + 'important', + 'sent', + 'draft', ]; export const FOLDER_TAGS: Record = { @@ -52,12 +52,12 @@ export const compressText = (text: string): string => { }; export const decompressText = (compressed: string): string => { - return LZString.decompressFromEncodedURIComponent(compressed) || ""; + return LZString.decompressFromEncodedURIComponent(compressed) || ''; }; export const getCookie = (key: string): string | null => { const cookies = Object.fromEntries( - document.cookie.split("; ").map((v) => v.split(/=(.*)/s).map(decodeURIComponent)), + document.cookie.split('; ').map((v) => v.split(/=(.*)/s).map(decodeURIComponent)), ); return cookies?.[key] ?? null; }; @@ -66,52 +66,52 @@ export const formatDate = (date: string) => { try { // Handle empty or invalid input if (!date) { - return ""; + return ''; } // Parse the date string to a Date object const dateObj = new Date(date); const now = new Date(); - + // Check if the date is valid if (isNaN(dateObj.getTime())) { - console.error("Invalid date", date); - return ""; + console.error('Invalid date', date); + return ''; } - + // If it's today, always show the time if (isToday(dateObj)) { - return format(dateObj, "h:mm a"); + return format(dateObj, 'h:mm a'); } - + // Calculate hours difference between now and the email date const hoursDifference = (now.getTime() - dateObj.getTime()) / (1000 * 60 * 60); - + // If it's not today but within the last 12 hours, show the time if (hoursDifference <= 12) { - return format(dateObj, "h:mm a"); + return format(dateObj, 'h:mm a'); } - + // If it's this month or last month, show the month and day if (isThisMonth(dateObj) || differenceInCalendarMonths(now, dateObj) === 1) { - return format(dateObj, "MMM dd"); + return format(dateObj, 'MMM dd'); } - + // Otherwise show the date in MM/DD/YY format - return format(dateObj, "MM/dd/yy"); + return format(dateObj, 'MM/dd/yy'); } catch (error) { - console.error("Error formatting date", error); - return ""; + console.error('Error formatting date', error); + return ''; } }; -export const cleanEmailAddress = (email: string = "") => { - return email.replace(/[<>]/g, "").trim(); +export const cleanEmailAddress = (email: string = '') => { + return email.replace(/[<>]/g, '').trim(); }; export const truncateFileName = (name: string, maxLength = 15) => { if (name.length <= maxLength) return name; - const extIndex = name.lastIndexOf("."); + const extIndex = name.lastIndexOf('.'); if (extIndex !== -1 && name.length - extIndex <= 5) { return `${name.slice(0, maxLength - 5)}...${name.slice(extIndex)}`; } @@ -128,130 +128,132 @@ export type FilterSuggestion = { }; export const extractFilterValue = (filter: string): string => { - if (!filter || !filter.includes(":")) return ""; + if (!filter || !filter.includes(':')) return ''; - const colonIndex = filter.indexOf(":"); + const colonIndex = filter.indexOf(':'); const value = filter.substring(colonIndex + 1); - return value || ""; + return value || ''; }; export const defaultPageSize = 20; export function createSectionId(title: string) { - return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); } export const formatFileSize = (bytes: number): string => { - if (bytes === 0) return "0 Bytes"; + if (bytes === 0) return '0 Bytes'; const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; export const getFileIcon = (mimeType: string): string => { - if (mimeType === "application/pdf") return "📄"; - if (mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "📊"; - if (mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") - return "📝"; - if (mimeType.includes("image")) return ""; // Empty for images as they're handled separately - return "📎"; // Default icon + if (mimeType === 'application/pdf') return '📄'; + if (mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return '📊'; + if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') + return '📝'; + if (mimeType.includes('image')) return ''; // Empty for images as they're handled separately + return '📎'; // Default icon }; export const convertJSONToHTML = (json: any): string => { - if (!json) return ""; - + if (!json) return ''; + // Handle different types - if (typeof json === "string") return json; - if (typeof json === "number" || typeof json === "boolean") return json.toString(); - if (json === null) return ""; - + if (typeof json === 'string') return json; + if (typeof json === 'number' || typeof json === 'boolean') return json.toString(); + if (json === null) return ''; + // Handle arrays if (Array.isArray(json)) { - return json.map(item => convertJSONToHTML(item)).join(""); + return json.map((item) => convertJSONToHTML(item)).join(''); } - + // Handle objects (assuming they might have specific email content structure) - if (typeof json === "object") { + if (typeof json === 'object') { // Check if it's a text node - if (json.type === "text") { - let text = json.text || ""; - + if (json.type === 'text') { + let text = json.text || ''; + // Apply formatting if present if (json.bold) text = `${text}`; if (json.italic) text = `${text}`; if (json.underline) text = `${text}`; if (json.code) text = `${text}`; - + return text; } - + // Handle paragraph - if (json.type === "paragraph") { + if (json.type === 'paragraph') { return `

${convertJSONToHTML(json.children)}

`; } - + // Handle headings - if (json.type?.startsWith("heading-")) { - const level = json.type.split("-")[1]; + if (json.type?.startsWith('heading-')) { + const level = json.type.split('-')[1]; return `${convertJSONToHTML(json.children)}`; } - + // Handle lists - if (json.type === "bulleted-list") { + if (json.type === 'bulleted-list') { return `
    ${convertJSONToHTML(json.children)}
`; } - - if (json.type === "numbered-list") { + + if (json.type === 'numbered-list') { return `
    ${convertJSONToHTML(json.children)}
`; } - - if (json.type === "list-item") { + + if (json.type === 'list-item') { return `
  • ${convertJSONToHTML(json.children)}
  • `; } - + // Handle links - if (json.type === "link") { + if (json.type === 'link') { return `${convertJSONToHTML(json.children)}`; } - + // Handle images - if (json.type === "image") { + if (json.type === 'image') { return `${json.alt || ''}`; } - + // Handle blockquote - if (json.type === "block-quote") { + if (json.type === 'block-quote') { return `
    ${convertJSONToHTML(json.children)}
    `; } - + // Handle code blocks - if (json.type === "code-block") { + if (json.type === 'code-block') { return `
    ${convertJSONToHTML(json.children)}
    `; } - + // If it has children property, process it if (json.children) { return convertJSONToHTML(json.children); } - + // Process all other properties - return Object.values(json).map(value => convertJSONToHTML(value)).join(""); + return Object.values(json) + .map((value) => convertJSONToHTML(value)) + .join(''); } - - return ""; + + return ''; }; export const createAIJsonContent = (text: string): JSONContent => { // Try to identify common sign-off patterns with a more comprehensive regex const signOffPatterns = [ - /\b((?:Best regards|Regards|Sincerely|Thanks|Thank you|Cheers|Best|All the best|Yours truly|Yours sincerely|Cordially)(?:,)?)\s*\n+\s*([A-Za-z][A-Za-z\s.]*)$/i + /\b((?:Best regards|Regards|Sincerely|Thanks|Thank you|Cheers|Best|All the best|Yours truly|Yours sincerely|Cordially)(?:,)?)\s*\n+\s*([A-Za-z][A-Za-z\s.]*)$/i, ]; - + let mainContent = text; let signatureLines: string[] = []; - + // Extract sign-off if found for (const pattern of signOffPatterns) { const match = text.match(pattern); @@ -261,80 +263,94 @@ export const createAIJsonContent = (text: string): JSONContent => { if (signOffIndex > 0) { // Split the content mainContent = text.substring(0, signOffIndex).trim(); - + // Split the signature part into separate lines const signature = text.substring(signOffIndex).trim(); - signatureLines = signature.split(/\n+/).map(line => line.trim()).filter(Boolean); + signatureLines = signature + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean); break; } } } - + // If no signature was found with regex but there are newlines at the end, // check if the last lines could be a signature if (signatureLines.length === 0) { const allLines = text.split(/\n+/); if (allLines.length > 1) { // Check if last 1-3 lines might be a signature (short lines at the end) - const potentialSigLines = allLines.slice(-3).filter(line => - line.trim().length < 60 && - !line.trim().endsWith('?') && - !line.trim().endsWith('.') - ); - + const potentialSigLines = allLines + .slice(-3) + .filter( + (line) => + line.trim().length < 60 && !line.trim().endsWith('?') && !line.trim().endsWith('.'), + ); + if (potentialSigLines.length > 0) { signatureLines = potentialSigLines; - mainContent = allLines.slice(0, allLines.length - potentialSigLines.length).join('\n').trim(); + mainContent = allLines + .slice(0, allLines.length - potentialSigLines.length) + .join('\n') + .trim(); } } } - + // Split the main content into paragraphs - const paragraphs = mainContent.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean); - + const paragraphs = mainContent + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean); + if (paragraphs.length === 0 && signatureLines.length === 0) { // If no paragraphs and no signature were found, treat the whole text as one paragraph paragraphs.push(text); } - + // Create a content array with appropriate spacing between paragraphs const content = []; - + paragraphs.forEach((paragraph, index) => { // Add the content paragraph content.push({ - type: "paragraph", - content: [{ type: "text", text: paragraph }] + type: 'paragraph', + content: [{ type: 'text', text: paragraph }], }); - + // Add an empty paragraph between main paragraphs if (index < paragraphs.length - 1) { content.push({ - type: "paragraph" + type: 'paragraph', }); } }); - + // If we found a signature, add it with proper spacing if (signatureLines.length > 0) { // Add spacing before the signature if there was content if (paragraphs.length > 0) { content.push({ - type: "paragraph" + type: 'paragraph', }); } - + // Add each line of the signature as a separate paragraph - signatureLines.forEach(line => { + signatureLines.forEach((line) => { content.push({ - type: "paragraph", - content: [{ type: "text", text: line }] + type: 'paragraph', + content: [{ type: 'text', text: line }], }); }); } - + return { - type: "doc", - content: content + type: 'doc', + content: content, }; }; + +export const getEmailLogo = (email: string) => { + return process.env.NEXT_PUBLIC_IMAGE_API_URL + email; +};