Skip to content

Commit 4c3753e

Browse files
retrogtxgraphite-app[bot]MrgSub
authored
feat: ability to snooze emails (#1477)
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: Adam <[email protected]>
1 parent b0177bc commit 4c3753e

File tree

23 files changed

+623
-77
lines changed

23 files changed

+623
-77
lines changed

apps/mail/app/(routes)/mail/[folder]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { authProxy } from '@/lib/auth-proxy';
66
import { useEffect, useState } from 'react';
77
import type { Route } from './+types/page';
88

9-
const ALLOWED_FOLDERS = new Set(['inbox', 'draft', 'sent', 'spam', 'bin', 'archive']);
9+
const ALLOWED_FOLDERS = new Set(['inbox', 'draft', 'sent', 'spam', 'bin', 'archive', 'snoozed']);
1010

1111
export async function clientLoader({ params, request }: Route.ClientLoaderArgs) {
1212
if (!params.folder) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`);

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

Lines changed: 101 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-st
2727
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
2828
import { type ThreadDestination } from '@/lib/thread-actions';
2929
import { useThread, useThreads } from '@/hooks/use-threads';
30-
import { ExclamationCircle, Mail } from '../icons/icons';
31-
import { useMemo, type ReactNode } from 'react';
30+
import { ExclamationCircle, Mail, Clock } from '../icons/icons';
31+
import { useMemo, type ReactNode, useState } from 'react';
3232
import { useLabels } from '@/hooks/use-labels';
3333
import { FOLDERS, LABELS } from '@/lib/utils';
3434
import { useMail } from '../mail/use-mail';
@@ -37,6 +37,7 @@ import { m } from '@/paraglide/messages';
3737
import { useParams } from 'react-router';
3838
import { useQueryState } from 'nuqs';
3939
import { toast } from 'sonner';
40+
import { SnoozeDialog } from '@/components/mail/snooze-dialog';
4041

4142
interface EmailAction {
4243
id: string;
@@ -68,12 +69,11 @@ const LabelsList = ({ threadId, bulkSelected }: { threadId: string; bulkSelected
6869

6970
if (!labels || !thread) return null;
7071

71-
const handleToggleLabel = async (labelId: string) => {
72+
const handleToggleLabel = (labelId: string) => {
7273
if (!labelId) return;
7374

74-
let shouldAddLabel = false;
75-
76-
let hasLabel = thread.labels?.map((label) => label.id).includes(labelId) || false;
75+
// Determine current label state considering optimistic updates
76+
let hasLabel = thread!.labels?.some((l) => l.id === labelId) ?? false;
7777

7878
if (rightClickedThreadOptimisticState.optimisticLabels) {
7979
if (rightClickedThreadOptimisticState.optimisticLabels.addedLabelIds.includes(labelId)) {
@@ -85,24 +85,21 @@ const LabelsList = ({ threadId, bulkSelected }: { threadId: string; bulkSelected
8585
}
8686
}
8787

88-
shouldAddLabel = !hasLabel;
89-
90-
optimisticToggleLabel(targetThreadIds, labelId, shouldAddLabel);
88+
optimisticToggleLabel(targetThreadIds, labelId, !hasLabel);
9189
};
9290

9391
return (
9492
<>
9593
{labels
9694
.filter((label) => label.id)
9795
.map((label) => {
98-
let isChecked = label.id ? thread.labels?.map((l) => l.id).includes(label.id) : false;
96+
let isChecked = label.id ? thread!.labels?.some((l) => l.id === label.id) ?? false : false;
9997

100-
const checkboxOptimisticState = useOptimisticThreadState(threadId);
101-
if (label.id && checkboxOptimisticState.optimisticLabels) {
102-
if (checkboxOptimisticState.optimisticLabels.addedLabelIds.includes(label.id)) {
98+
if (rightClickedThreadOptimisticState.optimisticLabels) {
99+
if (rightClickedThreadOptimisticState.optimisticLabels.addedLabelIds.includes(label.id)) {
103100
isChecked = true;
104101
} else if (
105-
checkboxOptimisticState.optimisticLabels.removedLabelIds.includes(label.id)
102+
rightClickedThreadOptimisticState.optimisticLabels.removedLabelIds.includes(label.id)
106103
) {
107104
isChecked = false;
108105
}
@@ -138,7 +135,7 @@ export function ThreadContextMenu({
138135
const [{ isLoading, isFetching }] = useThreads();
139136
const currentFolder = folder ?? '';
140137
const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE;
141-
138+
const isSnoozedFolder = currentFolder === FOLDERS.SNOOZED;
142139
const [, setMode] = useQueryState('mode');
143140
const [, setThreadId] = useQueryState('threadId');
144141
const { data: threadData } = useThread(threadId);
@@ -151,6 +148,8 @@ export function ThreadContextMenu({
151148
optimisticMarkAsRead,
152149
optimisticMarkAsUnread,
153150
optimisticDeleteThreads,
151+
optimisticSnooze,
152+
optimisticUnsnooze,
154153
} = useOptimisticActions();
155154

156155
const { isUnread, isStarred, isImportant } = useMemo(() => {
@@ -359,6 +358,31 @@ export function ThreadContextMenu({
359358
];
360359
}
361360

361+
if (isSnoozedFolder) {
362+
return [
363+
{
364+
id: 'unsnooze',
365+
label: 'Unsnooze',
366+
icon: <Inbox className="mr-2.5 h-4 w-4 opacity-60" />,
367+
action: () => {
368+
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
369+
optimisticUnsnooze(targets, currentFolder);
370+
if (mail.bulkSelected.length) {
371+
setMail({ ...mail, bulkSelected: [] });
372+
}
373+
},
374+
disabled: false,
375+
},
376+
{
377+
id: 'move-to-bin',
378+
label: m['common.mail.moveToBin'](),
379+
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
380+
action: handleMove(LABELS.SNOOZED, LABELS.TRASH),
381+
disabled: false,
382+
},
383+
];
384+
}
385+
362386
if (isArchiveFolder || !isInbox) {
363387
return [
364388
{
@@ -422,6 +446,14 @@ export function ThreadContextMenu({
422446
];
423447
}, [isSpam, isBin, isArchiveFolder, isInbox, isSent, handleMove, handleDelete]);
424448

449+
const [snoozeOpen, setSnoozeOpen] = useState(false);
450+
451+
const handleSnoozeConfirm = (wakeAt: Date) => {
452+
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
453+
optimisticSnooze(targets, currentFolder, wakeAt);
454+
setSnoozeOpen(false);
455+
};
456+
425457
const otherActions: EmailAction[] = useMemo(
426458
() => [
427459
{
@@ -453,8 +485,23 @@ export function ThreadContextMenu({
453485
),
454486
action: handleFavorites,
455487
},
488+
{
489+
id: 'snooze',
490+
label: 'Snooze',
491+
icon: <Clock className="mr-2.5 h-4 w-4 opacity-60" />,
492+
action: () => setSnoozeOpen(true),
493+
disabled: false,
494+
},
495+
],
496+
[
497+
isUnread,
498+
isImportant,
499+
isStarred,
500+
m,
501+
handleReadUnread,
502+
handleToggleImportant,
503+
handleFavorites,
456504
],
457-
[isUnread, isImportant, isStarred, m, handleReadUnread, handleToggleImportant, handleFavorites],
458505
);
459506

460507
const renderAction = (action: EmailAction) => {
@@ -473,36 +520,43 @@ export function ThreadContextMenu({
473520
};
474521

475522
return (
476-
<ContextMenu>
477-
<ContextMenuTrigger disabled={isLoading || isFetching} className="w-full">
478-
{children}
479-
</ContextMenuTrigger>
480-
<ContextMenuContent
481-
className="dark:bg-panelDark w-56 overflow-y-auto bg-white"
482-
onContextMenu={(e) => e.preventDefault()}
483-
>
484-
{primaryActions.map(renderAction)}
485-
486-
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
487-
488-
<ContextMenuSub>
489-
<ContextMenuSubTrigger className="font-normal">
490-
<Tag className="mr-2.5 h-4 w-4 opacity-60" />
491-
{m['common.mail.labels']()}
492-
</ContextMenuSubTrigger>
493-
<ContextMenuSubContent className="dark:bg-panelDark max-h-[520px] w-48 overflow-y-auto bg-white">
494-
<LabelsList threadId={threadId} bulkSelected={mail.bulkSelected} />
495-
</ContextMenuSubContent>
496-
</ContextMenuSub>
497-
498-
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
499-
500-
{getActions.map(renderAction)}
501-
502-
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
503-
504-
{otherActions.map(renderAction)}
505-
</ContextMenuContent>
506-
</ContextMenu>
523+
<>
524+
<ContextMenu>
525+
<ContextMenuTrigger disabled={isLoading || isFetching} className="w-full">
526+
{children}
527+
</ContextMenuTrigger>
528+
<ContextMenuContent
529+
className="dark:bg-panelDark w-56 overflow-y-auto bg-white"
530+
onContextMenu={(e) => e.preventDefault()}
531+
>
532+
{primaryActions.map(renderAction)}
533+
534+
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
535+
536+
<ContextMenuSub>
537+
<ContextMenuSubTrigger className="font-normal">
538+
<Tag className="mr-2.5 h-4 w-4 opacity-60" />
539+
{m['common.mail.labels']()}
540+
</ContextMenuSubTrigger>
541+
<ContextMenuSubContent className="dark:bg-panelDark max-h-[520px] w-48 overflow-y-auto bg-white">
542+
<LabelsList threadId={threadId} bulkSelected={mail.bulkSelected} />
543+
</ContextMenuSubContent>
544+
</ContextMenuSub>
545+
546+
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
547+
548+
{getActions.map(renderAction)}
549+
550+
<ContextMenuSeparator className="bg-[#E7E7E7] dark:bg-[#252525]" />
551+
552+
{otherActions.map(renderAction)}
553+
</ContextMenuContent>
554+
</ContextMenu>
555+
<SnoozeDialog
556+
open={snoozeOpen}
557+
onOpenChange={setSnoozeOpen}
558+
onConfirm={handleSnoozeConfirm}
559+
/>
560+
</>
507561
);
508562
}

apps/mail/components/icons/icons.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,6 +1625,8 @@ export const Clock = ({ className }: { className?: string }) => (
16251625
fillRule="evenodd"
16261626
clipRule="evenodd"
16271627
d="M0 7C0 3.13401 3.13401 0 7 0C10.866 0 14 3.13401 14 7C14 10.866 10.866 14 7 14C3.13401 14 0 10.866 0 7ZM7.75 2.75C7.75 2.33579 7.41421 2 7 2C6.58579 2 6.25 2.33579 6.25 2.75V7C6.25 7.41421 6.58579 7.75 7 7.75H10.25C10.6642 7.75 11 7.41421 11 7C11 6.58579 10.6642 6.25 10.25 6.25H7.75V2.75Z"
1628+
fill="var(--icon-color)"
1629+
fillOpacity="0.5"
16281630
/>
16291631
</svg>
16301632
);

apps/mail/components/mail/optimistic-thread-state.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ export function useOptimisticThreadState(threadId: string) {
7272
states.isImportant = true;
7373
states.optimisticImportant = action.important;
7474
break;
75+
76+
case 'SNOOZE':
77+
states.shouldHide = true;
78+
states.optimisticDestination = 'snoozed';
79+
break;
80+
81+
case 'UNSNOOZE':
82+
states.shouldHide = true;
83+
states.optimisticDestination = 'inbox';
84+
break;
7585
}
7686
});
7787

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
2+
import { Button } from '@/components/ui/button';
3+
import { Input } from '@/components/ui/input';
4+
import { useState } from 'react';
5+
import { toast } from 'sonner';
6+
7+
type SnoozeDialogProps = {
8+
trigger?: React.ReactElement;
9+
onConfirm: (wakeAt: Date) => void;
10+
open?: boolean;
11+
onOpenChange?: (open: boolean) => void;
12+
};
13+
14+
export function SnoozeDialog({ trigger, onConfirm, open: controlledOpen, onOpenChange }: SnoozeDialogProps) {
15+
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
16+
const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
17+
const setOpen = onOpenChange ?? setUncontrolledOpen;
18+
const now = new Date();
19+
const pad = (n: number) => n.toString().padStart(2, '0');
20+
const defaultDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
21+
const defaultTime = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
22+
23+
const [date, setDate] = useState<string>(defaultDate);
24+
const [time, setTime] = useState<string>(defaultTime);
25+
26+
const timeZoneLabel = (() => {
27+
try {
28+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
29+
} catch {
30+
const offsetMin = new Date().getTimezoneOffset();
31+
const sign = offsetMin > 0 ? '-' : '+';
32+
const abs = Math.abs(offsetMin);
33+
const hrs = Math.floor(abs / 60)
34+
.toString()
35+
.padStart(2, '0');
36+
const mins = (abs % 60).toString().padStart(2, '0');
37+
return `UTC${sign}${hrs}:${mins}`;
38+
}
39+
})();
40+
41+
const handleSubmit = () => {
42+
let wakeDate: Date;
43+
44+
if (date) {
45+
wakeDate = new Date(`${date}T${time || defaultTime}`);
46+
} else {
47+
const today = new Date();
48+
const [hours, minutes] = (time || defaultTime).split(':').map((v) => parseInt(v, 10));
49+
today.setHours(hours, minutes, 0, 0);
50+
wakeDate = today;
51+
}
52+
53+
if (wakeDate.getTime() <= Date.now()) {
54+
toast.error('Please choose a future date and time.');
55+
return;
56+
}
57+
58+
onConfirm(wakeDate);
59+
setOpen(false);
60+
};
61+
62+
return (
63+
<Dialog open={open} onOpenChange={setOpen}>
64+
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
65+
<DialogContent className="max-w-sm">
66+
<DialogHeader>
67+
<DialogTitle>Snooze until…</DialogTitle>
68+
<DialogDescription>Select date and time you'd like this email to return.</DialogDescription>
69+
</DialogHeader>
70+
<div className="flex flex-col gap-4 py-2">
71+
<label className="text-sm font-medium">
72+
Date
73+
<Input
74+
type="date"
75+
min={defaultDate}
76+
value={date}
77+
onChange={(e) => setDate(e.target.value)}
78+
/>
79+
</label>
80+
<label className="text-sm font-medium">
81+
Time <span className="text-xs font-normal text-muted-foreground">({timeZoneLabel})</span>
82+
<Input
83+
type="time"
84+
value={time}
85+
onChange={(e) => setTime(e.target.value)}
86+
/>
87+
</label>
88+
</div>
89+
<DialogFooter className="flex justify-end gap-2">
90+
<Button variant="ghost" onClick={() => setOpen(false)}>
91+
Cancel
92+
</Button>
93+
<Button onClick={handleSubmit}>Snooze</Button>
94+
</DialogFooter>
95+
</DialogContent>
96+
</Dialog>
97+
);
98+
}

apps/mail/config/navigation.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Sheet,
1414
Plane2,
1515
LockIcon,
16+
Clock,
1617
} from '@/components/icons/icons';
1718
import { MessageSquareIcon } from 'lucide-react';
1819
import { m } from '@/paraglide/messages';
@@ -81,6 +82,13 @@ export const navigationConfig: Record<string, NavConfig> = {
8182
icon: Archive,
8283
shortcut: 'g + a',
8384
},
85+
{
86+
id: 'snoozed',
87+
title: m['navigation.sidebar.snoozed'](),
88+
url: '/mail/snoozed',
89+
icon: Clock,
90+
shortcut: 'g + z',
91+
},
8492
{
8593
id: 'spam',
8694
title: m['navigation.sidebar.spam'](),

0 commit comments

Comments
 (0)