Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions apps/mail/components/mail/mail-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -150,20 +150,13 @@ const MailDisplay = ({ emailData, isMuted, index, demo }: Props) => {
<div className="flex flex-col gap-4 p-4 pb-2 transition-all duration-200">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start justify-center gap-4">
<Avatar className="rounded-md">
<AvatarImage alt={emailData?.sender?.name} className="rounded-md" />
<AvatarFallback
className={cn(
'rounded-md',
demo && 'compose-gradient-animated font-bold text-black',
)}
>
{emailData?.sender?.name
?.split(' ')
.map((chunk) => chunk[0]?.toUpperCase())
.filter((char) => char?.match(/[A-Z]/))
.slice(0, 2)
.join('')}
<Avatar className="h-8 w-8 rounded-full">
<AvatarImage
src={getEmailLogo(emailData?.sender?.email)}
className="rounded-full"
/>
<AvatarFallback className="rounded-full">
{emailData?.sender?.name[0]}
</AvatarFallback>
</Avatar>
<div className="relative bottom-1 flex-1">
Expand Down
215 changes: 120 additions & 95 deletions apps/mail/components/mail/mail-list.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ReturnType<typeof setTimeout> | undefined>(undefined);
const isHovering = useRef<boolean>(false);
const hasPrefetched = useRef<boolean>(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<ReturnType<typeof setTimeout> | undefined>(undefined);
const isHovering = useRef<boolean>(false);
const hasPrefetched = useRef<boolean>(false);
const [isHovered, setIsHovered] = useState(false);

const isMailSelected = useMemo(() => {
const threadId = message.threadId ?? message.id;
Expand Down Expand Up @@ -115,6 +124,7 @@ const Thread = memo(

return (
<div className="p-1">
{/**/}
<div
data-thread-id={message.id}
onClick={onClick ? onClick(message) : undefined}
Expand All @@ -123,9 +133,10 @@ const Thread = memo(
key={message.id}
className={cn(
'hover:bg-offsetLight hover:bg-primary/5 group relative flex cursor-pointer flex-col items-start overflow-clip rounded-lg border border-transparent px-4 py-3 text-left text-sm transition-all hover:opacity-100',
isMailSelected || !message.unread && 'opacity-50',
(isMailSelected || isMailBulkSelected || isKeyboardFocused) && 'border-border bg-primary/5 opacity-100',
isKeyboardFocused && 'ring-2 ring-primary/50'
isMailSelected || (!message.unread && 'opacity-50'),
(isMailSelected || isMailBulkSelected || isKeyboardFocused) &&
'border-border bg-primary/5 opacity-100',
isKeyboardFocused && 'ring-primary/50 ring-2',
)}
>
<div
Expand All @@ -134,66 +145,77 @@ const Thread = memo(
isMailBulkSelected && 'translate-x-0',
)}
/>
<MailQuickActions
message={message}
<MailQuickActions
message={message}
isHovered={isHovered || isKeyboardFocused}
isInQuickActionMode={isInQuickActionMode}
selectedQuickActionIndex={selectedQuickActionIndex}
resetNavigation={resetNavigation}
/>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-1">
<p
className={cn(
message.unread && !isMailSelected ? 'font-bold' : 'font-medium',
'text-md flex items-baseline gap-1 group-hover:opacity-100',
)}
>
<span className={cn(threadIdParam && 'max-w-[120px] truncate')}>
{highlightText(message.sender.name, searchValue.highlight)}
</span>{' '}
{message.unread && !isMailSelected ? <span className="size-2 rounded bg-[#006FFE]" /> : null}
</p>
<MailLabels labels={threadLabels} />
<div className="flex items-center gap-1">
{message.totalReplies > 1 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="rounded-md border border-dotted px-[5px] py-[1px] text-xs opacity-70">
{message.totalReplies}
</span>
</TooltipTrigger>
<TooltipContent className="px-1 py-0 text-xs">
{t('common.mail.replies', { count: message.totalReplies })}
</TooltipContent>
</Tooltip>
) : null}

<div className="flex w-full items-center justify-between gap-4">
<Avatar className="h-8 w-8 rounded-full">
<AvatarImage src={getEmailLogo(message.sender.email)} className="rounded-full" />
<AvatarFallback className="rounded-full">{message.sender.name[0]}</AvatarFallback>
</Avatar>
<div className="flex w-full justify-between">
<div className="w-full">
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-row items-center gap-1">
<p
className={cn(
message.unread && !isMailSelected ? 'font-bold' : 'font-medium',
'text-md flex items-baseline gap-1 group-hover:opacity-100',
)}
>
<span className={cn(threadIdParam && 'max-w-[120px] truncate')}>
{highlightText(message.sender.name, searchValue.highlight)}
</span>{' '}
{message.unread && !isMailSelected ? (
<span className="size-2 rounded bg-[#006FFE]" />
) : null}
</p>
<MailLabels labels={threadLabels} />
{message.totalReplies > 1 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="rounded-md border border-dotted px-[5px] py-[1px] text-xs opacity-70">
{message.totalReplies}
</span>
</TooltipTrigger>
<TooltipContent className="px-1 py-0 text-xs">
{t('common.mail.replies', { count: message.totalReplies })}
</TooltipContent>
</Tooltip>
) : null}
</div>
{message.receivedOn ? (
<p
className={cn(
'text-nowrap text-xs font-normal opacity-70 transition-opacity group-hover:opacity-100',
isMailSelected && 'opacity-100',
)}
>
{formatDate(message.receivedOn.split('.')[0] || '')}
</p>
) : null}
</div>
<p
className={cn(
'mt-1 line-clamp-1 text-xs opacity-70 transition-opacity',
mail.selected ? 'line-clamp-1' : 'line-clamp-2',
isMailSelected && 'opacity-100',
)}
>
{highlightText(message.subject, searchValue.highlight)}
</p>
</div>
</div>
{message.receivedOn ? (
<p
className={cn(
'text-xs font-normal opacity-70 transition-opacity group-hover:opacity-100',
isMailSelected && 'opacity-100',
)}
>
{formatDate(message.receivedOn.split('.')[0] || '')}
</p>
) : null}
</div>
<p
className={cn(
'mt-1 line-clamp-1 text-xs opacity-70 transition-opacity',
mail.selected ? 'line-clamp-1' : 'line-clamp-2',
isMailSelected && 'opacity-100',
)}
>
{highlightText(message.subject, searchValue.highlight)}
</p>
</div>
</div>
);
},
);
},
);

Thread.displayName = 'Thread';
Expand Down Expand Up @@ -241,23 +263,26 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
const parentRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<VirtuosoHandle>(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,
isQuickActionMode,
quickActionIndex,
handleMouseEnter,
keyboardActive,
resetNavigation
} = useMailNavigation({
items,
resetNavigation,
} = useMailNavigation({
items,
containerRef: parentRef,
onNavigate: handleNavigateToThread
onNavigate: handleNavigateToThread,
});

const handleScroll = useCallback(() => {
Expand Down Expand Up @@ -373,7 +398,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
const handleMailClick = useCallback(
(message: InitialThread) => () => {
handleMouseEnter(message.id);

const selectMode = getSelectMode();

if (selectMode === 'mass') {
Expand Down Expand Up @@ -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 <EmptyState folder="search" className="min-h-[90vh] md:min-h-[90vh]" />;
}
return <EmptyState folder={folder as FolderType} className="min-h-[90vh] md:min-h-[90vh]" />;
}
if (isEmpty && session) {
if (isFiltering) {
return <EmptyState folder="search" className="min-h-[90vh] md:min-h-[90vh]" />;
}
return <EmptyState folder={folder as FolderType} className="min-h-[90vh] md:min-h-[90vh]" />;
}

return (
<>
Expand Down Expand Up @@ -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);
}
Expand Down
Loading