Skip to content
Closed
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
84 changes: 71 additions & 13 deletions apps/mail/components/mail/mail-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ import {
Tag,
User,
Users,
X
} from 'lucide-react';
import {
type ComponentProps,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode } from '@/types';
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';
Expand All @@ -36,6 +45,7 @@ import { useQueryState } from 'nuqs';
import { Categories } from './mail';
import items from './demo.json';
import { toast } from 'sonner';

const HOVER_DELAY = 1000;

const ThreadWrapper = ({
Expand Down Expand Up @@ -80,8 +90,9 @@ const Thread = memo(
onClick,
sessionData,
isKeyboardFocused,
setHoveredMailId,
}: ConditionalThreadProps) => {
const [mail] = useMail();
const [mail, setEmail] = useMail();
const [searchValue] = useSearchValue();
const t = useTranslations();
const searchParams = useSearchParams();
Expand Down Expand Up @@ -110,7 +121,7 @@ const Thread = memo(
const handleMouseEnter = () => {
if (demo) return;
isHovering.current = true;

setHoveredMailId?.(message?.threadId || message?.id || null);
// Prefetch only in single select mode
if (selectMode === 'single' && sessionData?.userId && !hasPrefetched.current) {
// Clear any existing timeout
Expand All @@ -135,6 +146,8 @@ const Thread = memo(

const handleMouseLeave = () => {
isHovering.current = false;
setHoveredMailId?.(null);

if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
Expand Down Expand Up @@ -306,6 +319,33 @@ const Thread = memo(
</Tooltip>
) : null}
</div>
{isMailBulkSelected && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground absolute right-0 top-0 ml-1.5 h-7 w-fit px-2"
onMouseDown={(e) => {
e.stopPropagation();
setEmail({
...mail,
bulkSelected: mail.bulkSelected.filter((id) => id !== message.id),
});
}}
>
<X />
</Button>
</TooltipTrigger>

<TooltipContent>{t('common.mail.clearSelection')}</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center justify-between">
<p className={cn('mt-1 line-clamp-1 text-xs opacity-70 transition-opacity')}>
{highlightText(message.subject, searchValue.highlight)}
</p>
{message.receivedOn ? (
<p
className={cn(
Expand All @@ -317,6 +357,7 @@ const Thread = memo(
</p>
) : null}
</div>

<div className="flex justify-between">
<p className={cn('mt-1 line-clamp-1 text-xs opacity-70 transition-opacity')}>
{highlightText(message.subject, searchValue.highlight)}
Expand Down Expand Up @@ -468,9 +509,11 @@ export const MailList = memo(({ isCompact }: MailListProps) => {

const isKeyPressed = useKeyState();

const selectAll = useCallback(() => {
// If there are already items selected, deselect them all
if (mail.bulkSelected.length > 0) {
const [hoveredMailId, setHoveredMailId] = useState<string | null>(null);

const selectHoveredAndBelow = useCallback(() => {
// If there are items selected and no mail hovered, deselect them all
if (mail.bulkSelected.length > 0 && !hoveredMailId) {
setMail((prev) => ({
...prev,
bulkSelected: [],
Expand All @@ -480,14 +523,16 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
// Otherwise select all items
else if (items.length > 0) {
const allIds = items.map((item) => item.threadId ?? item.id);
const allIdsFromHoveredAndBelow = allIds.slice(allIds.indexOf(hoveredMailId ?? ''));

setMail((prev) => ({
...prev,
bulkSelected: allIds,
bulkSelected: hoveredMailId ? allIdsFromHoveredAndBelow : allIds,
}));
} else {
toast.info(t('common.mail.noEmailsToSelect'));
}
}, [items, setMail, mail.bulkSelected, t]);
}, [items, setMail, mail.bulkSelected, t, hoveredMailId]);

useHotKey('Meta+Shift+u', () => {
markAsUnread({ ids: mail.bulkSelected }).then((result) => {
Expand Down Expand Up @@ -539,22 +584,22 @@ export const MailList = memo(({ isCompact }: MailListProps) => {

// useHotKey('Meta+a', (event) => {
// event?.preventDefault();
// selectAll();
// selectHoveredAndBelow();
// });

useHotKey('Control+a', (event) => {
event?.preventDefault();
selectAll();
selectHoveredAndBelow();
});

// useHotKey('Meta+n', (event) => {
// event?.preventDefault();
// selectAll();
// selectHoveredAndBelow();
// });

// useHotKey('Control+n', (event) => {
// event?.preventDefault();
// selectAll();
// selectHoveredAndBelow();
// });

const getSelectMode = useCallback((): MailSelectMode => {
Expand All @@ -572,21 +617,33 @@ export const MailList = memo(({ isCompact }: MailListProps) => {

const handleMailClick = useCallback(
(message: InitialThread) => () => {
const isNotselectedDuringBulk =
mail.bulkSelected.length && !mail.bulkSelected.includes(message.threadId ?? message.id);

if (isNotselectedDuringBulk) {
setMail((prev) => ({
...prev,
bulkSelected: [...prev.bulkSelected, message.id],
}));
return;
}

handleMouseEnter(message.id);

const messageThreadId = message.threadId ?? message.id;

// Update local state immediately for optimistic UI
setMail((prev) => ({
...prev,
selected: messageThreadId,
replyComposerOpen: false,
forwardComposerOpen: false,
}));

// Update URL param without navigation
void setThreadId(messageThreadId);
},
[handleMouseEnter, setThreadId, t, setMail],
[handleMouseEnter, setThreadId, t, setMail, mail],
);

const isEmpty = items.length === 0;
Expand Down Expand Up @@ -645,6 +702,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
isInQuickActionMode={isQuickActionMode && focusedIndex === index}
selectedQuickActionIndex={quickActionIndex}
resetNavigation={resetNavigation}
setHoveredMailId={setHoveredMailId}
/>
);
})}
Expand Down
1 change: 1 addition & 0 deletions apps/mail/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export type ThreadProps = {
isInQuickActionMode?: boolean;
selectedQuickActionIndex?: number;
resetNavigation?: () => void;
setHoveredMailId?: (index: string | null) => void;
};

export type ConditionalThreadProps = ThreadProps &
Expand Down