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;
+});