Skip to content

Commit 0f46664

Browse files
authored
Optimistic updates (#1012)
done: - adds new hook `useOptimisticAction` - in mail-lsit - in thread-display - adds undo action to toast (design needs updating) wip: - undo sent email within 10-20 seconds - update toast design/undo button - add shortcut to undo 😎 - safari causing slight issues but it works
1 parent fc985a7 commit 0f46664

File tree

5 files changed

+187
-193
lines changed

5 files changed

+187
-193
lines changed

apps/mail/components/context/thread-context.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ import {
2222
Tag,
2323
Trash,
2424
} from 'lucide-react';
25+
import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-state';
2526
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
2627
import { type ThreadDestination } from '@/lib/thread-actions';
2728
import { useThread, useThreads } from '@/hooks/use-threads';
2829
import { ExclamationCircle, Mail } from '../icons/icons';
29-
import { useNavigate, useParams } from 'react-router';
3030
import { useTRPC } from '@/providers/query-provider';
3131
import { useMutation } from '@tanstack/react-query';
3232
import { useMemo, type ReactNode } from 'react';
@@ -35,6 +35,7 @@ import { FOLDERS, LABELS } from '@/lib/utils';
3535
import { useMail } from '../mail/use-mail';
3636
import { useTranslations } from 'use-intl';
3737
import { Checkbox } from '../ui/checkbox';
38+
import { useParams } from 'react-router';
3839
import { useQueryState } from 'nuqs';
3940
import { toast } from 'sonner';
4041

@@ -127,16 +128,20 @@ export function ThreadContextMenu({
127128
const [, setMode] = useQueryState('mode');
128129
const [, setThreadId] = useQueryState('threadId');
129130
const { data: threadData } = useThread(threadId);
131+
const optimisticState = useOptimisticThreadState(threadId);
130132

131133
const isUnread = useMemo(() => {
132134
return threadData?.hasUnread ?? false;
133135
}, [threadData]);
134136

135137
const isStarred = useMemo(() => {
138+
if (optimisticState.optimisticStarred !== null) {
139+
return optimisticState.optimisticStarred;
140+
}
136141
return threadData?.messages.some((message) =>
137142
message.tags?.some((tag) => tag.name.toLowerCase() === 'starred'),
138143
);
139-
}, [threadData]);
144+
}, [threadData, optimisticState.optimisticStarred]);
140145

141146
const isImportant = useMemo(() => {
142147
return threadData?.messages.some((message) =>

apps/mail/components/mail/mail-list.tsx

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
type ComponentProps,
1717
} from 'react';
1818
import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-state';
19-
import { useIsFetching, useMutation, useQueryClient } from '@tanstack/react-query';
2019
import { focusedIndexAtom, useMailNavigation } from '@/hooks/use-mail-navigation';
2120
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
2221
import type { MailSelectMode, ParsedMessage, ThreadProps } from '@/types';
@@ -25,22 +24,21 @@ import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
2524
import { Archive2, GroupPeople, Star2, Trash } from '../icons/icons';
2625
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
2726
import { useMail, type Config } from '@/components/mail/use-mail';
28-
import { Briefcase, Check, Star, StickyNote } from 'lucide-react';
29-
import { backgroundQueueAtom } from '@/store/backgroundQueue';
3027
import { type ThreadDestination } from '@/lib/thread-actions';
3128
import { useThread, useThreads } from '@/hooks/use-threads';
3229
import { useSearchValue } from '@/hooks/use-search-value';
3330
import { highlightText } from '@/lib/email-utils.client';
3431
import { useHotkeysContext } from 'react-hotkeys-hook';
3532
import { AnimatePresence, motion } from 'motion/react';
33+
import { useIsFetching } from '@tanstack/react-query';
3634
import { useTRPC } from '@/providers/query-provider';
3735
import { useThreadLabels } from '@/hooks/use-labels';
3836
import { useKeyState } from '@/hooks/use-hot-key';
3937
import { VList, type VListHandle } from 'virtua';
4038
import { RenderLabels } from './render-labels';
4139
import { Badge } from '@/components/ui/badge';
4240
import { useDraft } from '@/hooks/use-drafts';
43-
import { useStats } from '@/hooks/use-stats';
41+
import { Check, Star } from 'lucide-react';
4442
import { useTranslations } from 'use-intl';
4543
import { useParams } from 'react-router';
4644
import { useTheme } from 'next-themes';
@@ -73,19 +71,56 @@ const Thread = memo(
7371
}
7472
}, [getThreadData?.latest?.tags]);
7573

76-
// Import the optimistic actions hook
74+
const optimisticState = message.id
75+
? useOptimisticThreadState(message.id)
76+
: useMemo(
77+
() => ({
78+
isMoving: false,
79+
isStarring: false,
80+
isMarkingAsRead: false,
81+
isAddingLabel: false,
82+
isRemoving: false,
83+
shouldHide: false,
84+
optimisticStarred: null,
85+
optimisticRead: null,
86+
optimisticDestination: null,
87+
hasOptimisticState: false,
88+
}),
89+
[],
90+
);
91+
92+
const displayStarred =
93+
optimisticState.optimisticStarred !== null ? optimisticState.optimisticStarred : isStarred;
94+
95+
const optimisticLabels = useMemo(() => {
96+
if (!getThreadData?.labels) return [];
97+
98+
const labels = [...getThreadData.labels];
99+
const hasStarredLabel = labels.some((label) => label.name === 'STARRED');
100+
101+
if (optimisticState.optimisticStarred !== null) {
102+
if (optimisticState.optimisticStarred && !hasStarredLabel) {
103+
labels.push({ id: 'starred-optimistic', name: 'STARRED' });
104+
} else if (!optimisticState.optimisticStarred && hasStarredLabel) {
105+
return labels.filter((label) => label.name !== 'STARRED');
106+
}
107+
}
108+
109+
return labels;
110+
}, [getThreadData?.labels, optimisticState.optimisticStarred]);
111+
77112
const { optimisticToggleStar } = useOptimisticActions();
78113

79114
const handleToggleStar = useCallback(
80115
async (e: React.MouseEvent) => {
81116
e.stopPropagation();
82117
if (!getThreadData || !message.id) return;
83118

84-
const newStarredState = !isStarred;
119+
const newStarredState = !displayStarred;
85120
setIsStarred(newStarredState);
86121
await optimisticToggleStar([message.id], newStarredState);
87122
},
88-
[getThreadData, message.id, isStarred, optimisticToggleStar],
123+
[getThreadData, message.id, displayStarred, optimisticToggleStar],
89124
);
90125

91126
const handleNext = useCallback(
@@ -103,7 +138,6 @@ const Thread = memo(
103138
[threads, id, focusedIndex],
104139
);
105140

106-
// Use the optimistic move function
107141
const { optimisticMoveThreadsTo } = useOptimisticActions();
108142

109143
const moveThreadTo = useCallback(
@@ -118,25 +152,6 @@ const Thread = memo(
118152
const latestMessage = getThreadData?.latest;
119153
const emailContent = getThreadData?.latest?.body;
120154

121-
// Get optimistic state for this thread - only if we have a valid message ID
122-
const optimisticState = message.id
123-
? useOptimisticThreadState(message.id)
124-
: useMemo(
125-
() => ({
126-
isMoving: false,
127-
isStarring: false,
128-
isMarkingAsRead: false,
129-
isAddingLabel: false,
130-
isRemoving: false,
131-
shouldHide: false,
132-
optimisticStarred: null,
133-
optimisticRead: null,
134-
optimisticDestination: null,
135-
hasOptimisticState: false,
136-
}),
137-
[],
138-
);
139-
140155
const { labels: threadLabels } = useThreadLabels(
141156
getThreadData?.labels ? getThreadData.labels.map((l) => l.id) : [],
142157
);
@@ -228,15 +243,17 @@ const Thread = memo(
228243
<Star2
229244
className={cn(
230245
'h-4 w-4',
231-
isStarred
246+
displayStarred
232247
? 'fill-yellow-400 stroke-yellow-400'
233248
: 'fill-transparent stroke-[#9D9D9D] dark:stroke-[#9D9D9D]',
234249
)}
235250
/>
236251
</Button>
237252
</TooltipTrigger>
238-
<TooltipContent className="dark:bg-panelDark mb-1 bg-white">
239-
{isStarred ? t('common.threadDisplay.unstar') : t('common.threadDisplay.star')}
253+
<TooltipContent className="mb-1 bg-white dark:bg-[#1A1A1A]">
254+
{displayStarred
255+
? t('common.threadDisplay.unstar')
256+
: t('common.threadDisplay.star')}
240257
</TooltipContent>
241258
</Tooltip>
242259
{/* <Tooltip>
@@ -382,7 +399,7 @@ const Thread = memo(
382399
</TooltipContent>
383400
</Tooltip>
384401
) : null}
385-
<MailLabels labels={getThreadData.labels} />
402+
<MailLabels labels={optimisticLabels} />
386403
</div>
387404
{latestMessage.receivedOn ? (
388405
<p

0 commit comments

Comments
 (0)