diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx
index 869de003e0..a013a69140 100644
--- a/apps/mail/components/mail/mail-list.tsx
+++ b/apps/mail/components/mail/mail-list.tsx
@@ -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';
@@ -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 = ({
@@ -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();
@@ -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
@@ -135,6 +146,8 @@ const Thread = memo(
const handleMouseLeave = () => {
isHovering.current = false;
+ setHoveredMailId?.(null);
+
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
@@ -306,6 +319,33 @@ const Thread = memo(
) : null}
+ {isMailBulkSelected && (
+
+
+
+
+
+ {t('common.mail.clearSelection')}
+
+ )}
+
+
+
+ {highlightText(message.subject, searchValue.highlight)}
+
{message.receivedOn ? (
) : null}
+
{highlightText(message.subject, searchValue.highlight)}
@@ -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(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: [],
@@ -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) => {
@@ -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 => {
@@ -572,6 +617,17 @@ 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;
@@ -579,6 +635,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
// Update local state immediately for optimistic UI
setMail((prev) => ({
...prev,
+ selected: messageThreadId,
replyComposerOpen: false,
forwardComposerOpen: false,
}));
@@ -586,7 +643,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
// Update URL param without navigation
void setThreadId(messageThreadId);
},
- [handleMouseEnter, setThreadId, t, setMail],
+ [handleMouseEnter, setThreadId, t, setMail, mail],
);
const isEmpty = items.length === 0;
@@ -645,6 +702,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
isInQuickActionMode={isQuickActionMode && focusedIndex === index}
selectedQuickActionIndex={quickActionIndex}
resetNavigation={resetNavigation}
+ setHoveredMailId={setHoveredMailId}
/>
);
})}
diff --git a/apps/mail/types/index.ts b/apps/mail/types/index.ts
index f5075d207a..c753dbe11a 100644
--- a/apps/mail/types/index.ts
+++ b/apps/mail/types/index.ts
@@ -107,6 +107,7 @@ export type ThreadProps = {
isInQuickActionMode?: boolean;
selectedQuickActionIndex?: number;
resetNavigation?: () => void;
+ setHoveredMailId?: (index: string | null) => void;
};
export type ConditionalThreadProps = ThreadProps &