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 `
`;
}
-
+
// 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;
+};