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 &