diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index ff53f96e97..ce036508f6 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -603,18 +603,16 @@ const Thread = memo( ) : null; - return latestMessage ? ( - !optimisticState.shouldHide && idToUse ? ( - - {content} - - ) : null + return latestMessage && idToUse ? ( + + {content} + ) : null; }, (prev, next) => { diff --git a/apps/mail/components/ui/app-sidebar.tsx b/apps/mail/components/ui/app-sidebar.tsx index a598f375c8..35fb0fa199 100644 --- a/apps/mail/components/ui/app-sidebar.tsx +++ b/apps/mail/components/ui/app-sidebar.tsx @@ -39,7 +39,7 @@ import { useBilling } from '@/hooks/use-billing'; import { useIsMobile } from '@/hooks/use-mobile'; import { Button } from '@/components/ui/button'; import { useAIFullScreen } from './ai-sidebar'; -import { useStats } from '@/hooks/use-stats'; +import { useAdjustedStats } from '@/hooks/use-stats'; import { useLocation } from 'react-router'; import { useForm } from 'react-hook-form'; import { m } from '@/paraglide/messages'; @@ -63,7 +63,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const { isFullScreen } = useAIFullScreen(); - const { data: stats } = useStats(); + const { data: stats } = useAdjustedStats(); const location = useLocation(); const { data: session, isPending: isSessionPending } = useSession(); diff --git a/apps/mail/components/ui/nav-main.tsx b/apps/mail/components/ui/nav-main.tsx index 397963c188..f4ef201c1a 100644 --- a/apps/mail/components/ui/nav-main.tsx +++ b/apps/mail/components/ui/nav-main.tsx @@ -14,7 +14,7 @@ import { m } from '../../paraglide/messages.js'; import { Button } from '@/components/ui/button'; import { useLabels } from '@/hooks/use-labels'; import { Badge } from '@/components/ui/badge'; -import { useStats } from '@/hooks/use-stats'; +import { useAdjustedStats } from '@/hooks/use-stats'; import SidebarLabels from './sidebar-labels'; import { useCallback, useRef } from 'react'; import { BASE_URL } from '@/lib/constants'; @@ -56,7 +56,7 @@ export function NavMain({ items }: NavMainProps) { const searchParams = new URLSearchParams(); const [category] = useQueryState('category'); const { data: connections } = useConnections(); - const { data: stats } = useStats(); + const { data: stats } = useAdjustedStats(); const { data: activeConnection } = useActiveConnection(); const trpc = useTRPC(); const { data: intercomToken } = useQuery(trpc.user.getIntercomToken.queryOptions()); @@ -260,7 +260,7 @@ export function NavMain({ items }: NavMainProps) { {activeAccount ? ( - + ) : null} @@ -272,7 +272,7 @@ export function NavMain({ items }: NavMainProps) { function NavItem(item: NavItemProps & { href: string }) { const iconRef = useRef(null); - const { data: stats } = useStats(); + const { data: stats } = useAdjustedStats(); const { state, setOpenMobile } = useSidebar(); diff --git a/apps/mail/components/ui/sidebar-labels.tsx b/apps/mail/components/ui/sidebar-labels.tsx index 454089090e..3e87c27cea 100644 --- a/apps/mail/components/ui/sidebar-labels.tsx +++ b/apps/mail/components/ui/sidebar-labels.tsx @@ -2,19 +2,16 @@ import type { IConnection, Label as LabelType } from '@/types'; import { RecursiveFolder } from './recursive-folder'; import { Tree } from '../magicui/file-tree'; import { useCallback } from 'react'; +import { useAdjustedStats } from '@/hooks/use-stats'; type Props = { data: LabelType[]; activeAccount: IConnection | null | undefined; - stats: - | { - count?: number; - label?: string; - }[] - | undefined; }; -const SidebarLabels = ({ data, activeAccount, stats }: Props) => { +const SidebarLabels = ({ data, activeAccount }: Props) => { + const { data: stats } = useAdjustedStats(); + const getLabelCount = useCallback( (labelName: string | undefined): number => { if (!stats || !labelName) return 0; diff --git a/apps/mail/hooks/use-mail-navigation.ts b/apps/mail/hooks/use-mail-navigation.ts index f7c18f3d0b..10ecc65b79 100644 --- a/apps/mail/hooks/use-mail-navigation.ts +++ b/apps/mail/hooks/use-mail-navigation.ts @@ -4,6 +4,7 @@ import { useOptimisticActions } from './use-optimistic-actions'; import { useMail } from '@/components/mail/use-mail'; import { useHotkeys } from 'react-hotkeys-hook'; import { atom, useAtom } from 'jotai'; +import { useQueryState } from 'nuqs'; export const focusedIndexAtom = atom(null); export const mailNavigationCommandAtom = atom(null); @@ -23,7 +24,10 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa itemsRef.current = items; const onNavigateRef = useRef(onNavigate); onNavigateRef.current = onNavigate; - const { open: isCommandPaletteOpen } = useCommandPalette(); + const [isCommandPaletteOpen] = useQueryState('isCommandPaletteOpen'); + + // Track the previously focused thread ID to detect when it gets deleted + const prevFocusedThreadId = useRef(null); const hoveredMailRef = useRef(null); const keyboardActiveRef = useRef(false); @@ -39,6 +43,7 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa setFocusedIndex(null); onNavigateRef.current(null); keyboardActiveRef.current = false; + prevFocusedThreadId.current = null; }, [setFocusedIndex, onNavigateRef]); const getThreadElement = useCallback( @@ -77,6 +82,9 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa const message = itemsRef.current[index]; const threadId = message.id; + // Update the tracked focused thread ID + prevFocusedThreadId.current = threadId; + const currentThreadId = window.location.search.includes('threadId='); if (currentThreadId) { onNavigateRef.current(threadId); @@ -98,36 +106,101 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa const firstItem = itemsRef.current[0]; if (firstItem) { onNavigateRef.current(firstItem.id); + prevFocusedThreadId.current = firstItem.id; } scrollIntoView(0, 'auto'); return 0; } onNavigateRef.current(null); + prevFocusedThreadId.current = null; return null; } - if (prevIndex < itemsRef.current.length - 1) { - const newIndex = prevIndex; - const nextItem = itemsRef.current[prevIndex + 1]; + // Current focused index is beyond the available items (thread was deleted) + if (prevIndex >= itemsRef.current.length) { + const newIndex = Math.max(0, itemsRef.current.length - 1); + const nextItem = itemsRef.current[newIndex]; if (nextItem) { onNavigateRef.current(nextItem.id); + prevFocusedThreadId.current = nextItem.id; + scrollIntoView(newIndex, 'auto'); + return newIndex; + } else { + onNavigateRef.current(null); + prevFocusedThreadId.current = null; + return null; } - scrollIntoView(newIndex, 'auto'); - return newIndex; - } else { - const newIndex = itemsRef.current.length > 1 ? prevIndex - 1 : null; + } - if (newIndex !== null) { + // Check if the focused thread was deleted by comparing thread IDs + const currentItem = itemsRef.current[prevIndex]; + const currentThreadId = currentItem?.id; + const wasFocusedThreadDeleted = prevFocusedThreadId.current && + (!currentThreadId || currentThreadId !== prevFocusedThreadId.current); + + if (wasFocusedThreadDeleted) { + // The focused thread was deleted, navigate to the same position + // which now contains the next email in the list + if (prevIndex < itemsRef.current.length) { + const nextItem = itemsRef.current[prevIndex]; + if (nextItem) { + onNavigateRef.current(nextItem.id); + prevFocusedThreadId.current = nextItem.id; + scrollIntoView(prevIndex, 'auto'); + return prevIndex; + } + } + + // If no item at current position, try the previous position + if (prevIndex > 0) { + const newIndex = prevIndex - 1; + const nextItem = itemsRef.current[newIndex]; + if (nextItem) { + onNavigateRef.current(nextItem.id); + prevFocusedThreadId.current = nextItem.id; + scrollIntoView(newIndex, 'auto'); + return newIndex; + } + } + + onNavigateRef.current(null); + prevFocusedThreadId.current = null; + return null; + } else if (currentItem) { + // Current item existts and wasnt deleted, then navigate to the next one + if (prevIndex < itemsRef.current.length - 1) { + const newIndex = prevIndex + 1; const nextItem = itemsRef.current[newIndex]; if (nextItem) { onNavigateRef.current(nextItem.id); + prevFocusedThreadId.current = nextItem.id; } scrollIntoView(newIndex, 'auto'); return newIndex; } else { - onNavigateRef.current(null); - return null; + // we're at the end so stay at the current thread + onNavigateRef.current(currentItem.id); + prevFocusedThreadId.current = currentItem.id; + scrollIntoView(prevIndex, 'auto'); + return prevIndex; + } + } else { + // no current item so try to find any available thread + if (itemsRef.current.length > 0) { + const newIndex = Math.min(prevIndex, itemsRef.current.length - 1); + const nextItem = itemsRef.current[newIndex]; + if (nextItem) { + onNavigateRef.current(nextItem.id); + prevFocusedThreadId.current = nextItem.id; + scrollIntoView(newIndex, 'auto'); + return newIndex; + } } + + // 0 threads available + onNavigateRef.current(null); + prevFocusedThreadId.current = null; + return null; } }); }, [onNavigateRef, scrollIntoView, setFocusedIndex]); @@ -186,18 +259,22 @@ export function useMailNavigation({ items, containerRef, onNavigate }: UseMailNa if (focusedIndex === null) return; const message = itemsRef.current[focusedIndex]; - if (message) onNavigateRef.current(message.id); + if (message) { + onNavigateRef.current(message.id); + prevFocusedThreadId.current = message.id; + } }, [focusedIndex]); const handleEscape = useCallback(() => { setFocusedIndex(null); onNavigateRef.current(null); keyboardActiveRef.current = false; + prevFocusedThreadId.current = null; }, [setFocusedIndex, onNavigateRef]); useHotkeys('ArrowUp', handleArrowUp, { preventDefault: true, enabled: !isCommandPaletteOpen }); useHotkeys('ArrowDown', handleArrowDown, { preventDefault: true, enabled: !isCommandPaletteOpen }); - useHotkeys('j', handleArrowDown,{enabled: !isCommandPaletteOpen }); + useHotkeys('j', handleArrowDown, { enabled: !isCommandPaletteOpen }); useHotkeys('k', handleArrowUp, { enabled: !isCommandPaletteOpen }); useHotkeys('Enter', handleEnter, { preventDefault: true,enabled: !isCommandPaletteOpen }); useHotkeys('Escape', handleEscape, { preventDefault: true,enabled: !isCommandPaletteOpen }); diff --git a/apps/mail/hooks/use-optimistic-actions.ts b/apps/mail/hooks/use-optimistic-actions.ts index f4dc08f4fb..c94d1e9644 100644 --- a/apps/mail/hooks/use-optimistic-actions.ts +++ b/apps/mail/hooks/use-optimistic-actions.ts @@ -276,6 +276,7 @@ export function useOptimisticActions() { const optimisticId = addOptimisticAction({ type: 'MOVE', threadIds, + source: currentFolder, destination, }); @@ -335,6 +336,7 @@ export function useOptimisticActions() { const optimisticId = addOptimisticAction({ type: 'MOVE', threadIds, + source: currentFolder, destination: 'bin', }); diff --git a/apps/mail/hooks/use-stats.ts b/apps/mail/hooks/use-stats.ts index 4b65df7fe4..379c637473 100644 --- a/apps/mail/hooks/use-stats.ts +++ b/apps/mail/hooks/use-stats.ts @@ -1,6 +1,9 @@ import { useTRPC } from '@/providers/query-provider'; import { useQuery } from '@tanstack/react-query'; import { useSession } from '@/lib/auth-client'; +import { useAtomValue } from 'jotai'; +import { optimisticActionsAtom } from '@/store/optimistic-updates'; +import { useMemo } from 'react'; export const useStats = () => { const { data: session } = useSession(); @@ -15,3 +18,49 @@ export const useStats = () => { return statsQuery; }; + +export const useAdjustedStats = () => { + const { data: session } = useSession(); + const trpc = useTRPC(); + const optimisticActions = useAtomValue(optimisticActionsAtom); + + const statsQuery = useQuery( + trpc.mail.count.queryOptions(void 0, { + enabled: !!session?.user.id, + staleTime: 1000 * 60 * 60, + }), + ); + + const adjustedStats = useMemo(() => { + if (!statsQuery.data) return statsQuery.data; + + const deltas: Record = {}; + + Object.values(optimisticActions).forEach((action) => { + if (action.type !== 'MOVE') return; + + const source = action.source.toLowerCase(); + const destination = action.destination?.toLowerCase(); + + if (source) { + deltas[source] = (deltas[source] ?? 0) - action.threadIds.length; + } + if (destination && destination !== source) { + deltas[destination] = (deltas[destination] ?? 0) + action.threadIds.length; + } + }); + + return statsQuery.data.map((stat) => { + const delta = deltas[stat.label?.toLowerCase() ?? ''] ?? 0; + return { + ...stat, + count: Math.max(0, (stat.count ?? 0) + delta), + }; + }); + }, [statsQuery.data, optimisticActions]); + + return { + ...statsQuery, + data: adjustedStats, + }; +}; diff --git a/apps/mail/hooks/use-threads.ts b/apps/mail/hooks/use-threads.ts index ea3e21a4a9..c266f05804 100644 --- a/apps/mail/hooks/use-threads.ts +++ b/apps/mail/hooks/use-threads.ts @@ -10,6 +10,7 @@ import { usePrevious } from './use-previous'; import { useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; import { useQueryState } from 'nuqs'; +import { optimisticActionsAtom } from '@/store/optimistic-updates'; export const useThreads = () => { const { folder } = useParams<{ folder: string }>(); @@ -17,6 +18,7 @@ export const useThreads = () => { const { data: session } = useSession(); const [backgroundQueue] = useAtom(backgroundQueueAtom); const isInQueue = useAtomValue(isThreadInBackgroundQueueAtom); + const optimisticActions = useAtomValue(optimisticActionsAtom); const trpc = useTRPC(); const { labels, setLabels } = useSearchLabels(); @@ -37,7 +39,17 @@ export const useThreads = () => { ), ); - // Flatten threads from all pages and sort by receivedOn date (newest first) + const shouldHideThread = useMemo(() => { + const hideSet = new Set(); + + Object.values(optimisticActions).forEach((action) => { + if (action.type === 'MOVE' && action.source === folder) { + action.threadIds.forEach((id) => hideSet.add(id)); + } + }); + + return (threadId: string) => hideSet.has(threadId); + }, [optimisticActions, folder]); const threads = useMemo(() => { return threadsQuery.data @@ -45,8 +57,53 @@ export const useThreads = () => { .flatMap((e) => e.threads) .filter(Boolean) .filter((e) => !isInQueue(`thread:${e.id}`)) + .filter((e) => !shouldHideThread(e.id)) : []; - }, [threadsQuery.data, threadsQuery.dataUpdatedAt, isInQueue, backgroundQueue]); + }, [threadsQuery.data, threadsQuery.dataUpdatedAt, isInQueue, backgroundQueue, shouldHideThread]); + + const THRESHOLD = 100; + const PREFETCH_PAGES = 3; + + useEffect(() => { + if ( + threads.length < THRESHOLD && + threadsQuery.hasNextPage && + !threadsQuery.isFetchingNextPage && + !threadsQuery.isLoading + ) { + void threadsQuery.fetchNextPage(); + } + }, [threads.length, threadsQuery.hasNextPage, threadsQuery.isFetchingNextPage, threadsQuery.isLoading]); + + useEffect(() => { + const loadedPages = threadsQuery.data?.pages.length ?? 0; + if ( + loadedPages < PREFETCH_PAGES && + threadsQuery.hasNextPage && + !threadsQuery.isFetchingNextPage && + !threadsQuery.isLoading + ) { + void threadsQuery.fetchNextPage(); + } + }, [threadsQuery.data?.pages.length, threadsQuery.hasNextPage, threadsQuery.isFetchingNextPage, threadsQuery.isLoading]); + + useEffect(() => { + const optimisticlyRemovedCount = Object.values(optimisticActions).reduce((count, action) => { + if (action.type === 'MOVE' && action.source === folder) { + return count + action.threadIds.length; + } + return count; + }, 0); + + if ( + optimisticlyRemovedCount > 20 && + threadsQuery.hasNextPage && + !threadsQuery.isFetchingNextPage && + !threadsQuery.isLoading + ) { + void threadsQuery.fetchNextPage(); + } + }, [optimisticActions, folder, threadsQuery.hasNextPage, threadsQuery.isFetchingNextPage, threadsQuery.isLoading]); const isEmpty = useMemo(() => threads.length === 0, [threads]); const isReachingEnd = diff --git a/apps/mail/lib/hotkeys/navigation-hotkeys.tsx b/apps/mail/lib/hotkeys/navigation-hotkeys.tsx index b0eec0f1d6..aca5e8f8c7 100644 --- a/apps/mail/lib/hotkeys/navigation-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/navigation-hotkeys.tsx @@ -1,22 +1,29 @@ import { keyboardShortcuts } from '@/config/shortcuts'; import { useShortcuts } from './use-hotkey-utils'; import { useNavigate } from 'react-router'; +import React from 'react'; export function NavigationHotkeys() { const navigate = useNavigate(); const scope = 'navigation'; - const handlers = { - goToDrafts: () => navigate('/mail/draft'), - inbox: () => navigate('/mail/inbox'), - sentMail: () => navigate('/mail/sent'), - goToArchive: () => navigate('/mail/archive'), - goToBin: () => navigate('/mail/bin'), - goToSettings: () => navigate('/settings'), - helpWithShortcuts: () => navigate('/settings/shortcuts'), - }; + const handlers = React.useMemo( + () => ({ + goToDrafts: () => navigate('/mail/draft'), + inbox: () => navigate('/mail/inbox'), + sentMail: () => navigate('/mail/sent'), + goToArchive: () => navigate('/mail/archive'), + goToBin: () => navigate('/mail/bin'), + goToSettings: () => navigate('/settings'), + helpWithShortcuts: () => navigate('/settings/shortcuts'), + }), + [navigate], + ); - const globalShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope); + const globalShortcuts = React.useMemo( + () => keyboardShortcuts.filter((shortcut) => shortcut.scope === scope), + [], + ); useShortcuts(globalShortcuts, handlers, { scope }); diff --git a/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx b/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx index cdcc68efcc..d022b889fb 100644 --- a/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/thread-display-hotkeys.tsx @@ -1,8 +1,8 @@ import { mailNavigationCommandAtom } from '@/hooks/use-mail-navigation'; -import { useThread, useThreads } from '@/hooks/use-threads'; +import { useThread } from '@/hooks/use-threads'; import { keyboardShortcuts } from '@/config/shortcuts'; import useMoveTo from '@/hooks/driver/use-move-to'; -import useDelete from '@/hooks/driver/use-delete'; +import { useOptimisticActions } from '@/hooks/use-optimistic-actions'; import { useShortcuts } from './use-hotkey-utils'; import { useParams } from 'react-router'; import { useQueryState } from 'nuqs'; @@ -21,7 +21,7 @@ export function ThreadDisplayHotkeys() { const params = useParams<{ folder: string; }>(); - const { mutate: deleteThread } = useDelete(); + const { optimisticDeleteThreads } = useOptimisticActions(); const { mutate: moveTo } = useMoveTo(); const setMailNavigationCommand = useSetAtom(mailNavigationCommandAtom); @@ -42,7 +42,7 @@ export function ThreadDisplayHotkeys() { delete: () => { if (!openThreadId) return; if (params.folder === 'bin') { - deleteThread(openThreadId); + optimisticDeleteThreads([openThreadId], 'bin'); setMailNavigationCommand('next'); } else { moveTo({ diff --git a/apps/mail/store/optimistic-updates.ts b/apps/mail/store/optimistic-updates.ts index 8fe4b53c14..67217b1d91 100644 --- a/apps/mail/store/optimistic-updates.ts +++ b/apps/mail/store/optimistic-updates.ts @@ -2,7 +2,7 @@ import type { ThreadDestination } from '@/lib/thread-actions'; import { atom } from 'jotai'; export type OptimisticAction = - | { type: 'MOVE'; threadIds: string[]; destination: ThreadDestination } + | { type: 'MOVE'; threadIds: string[]; source: string; destination: ThreadDestination } | { type: 'STAR'; threadIds: string[]; starred: boolean } | { type: 'READ'; threadIds: string[]; read: boolean } | { type: 'LABEL'; threadIds: string[]; labelIds: string[]; add: boolean } @@ -45,3 +45,10 @@ export const removeOptimisticActionAtom = atom(null, (get, set, id: string) => { const { [id]: _, ...rest } = currentActions; set(optimisticActionsAtom, rest); }); + +// Derived atom that adjusts server stats with optimistic actions +export const adjustedStatsAtom = atom((get) => { + // We can't directly access trpc query data here, so this will be used differently + // This is a placeholder - actual implementation will be in the hook + return null; +});