1import { useEffect, useId, useState } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import { create } from "zustand";
4import {
5 localSnoozeDate,
6 localSnoozeTime,
7 resolveCustomSnooze,
8 type CustomSnoozeInput,
9} from "@t3tools/client-runtime/state/thread-settled";
10import { Button } from "./ui/button";
11import { CalendarIcon, Settings2Icon } from "lucide-react";
12import { Calendar } from "./ui/calendar";
13import { weekStartsOn } from "../timestampFormat";
14import { useNowMinute } from "../hooks/useNowMinute";
15import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover";
16import { Input } from "./ui/input";
17import { Label } from "./ui/label";
18import { Toggle, ToggleGroup } from "./ui/toggle-group";
19import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
20import {
21 NumberField,
22 NumberFieldGroup,
23 NumberFieldInput,
24 NumberFieldDecrement,
25 NumberFieldIncrement,
26} from "./ui/number-field";
27import {
28 Dialog,
29 DialogPopup,
30 DialogHeader,
31 DialogTitle,
32 DialogDescription,
33 DialogPanel,
34 DialogFooter,
35} from "./ui/dialog";
36
37type SnoozeChoice = { readonly snoozedUntil: string };
38type Request = { readonly resolve: (choice: SnoozeChoice | null) => void };
39const useRequest = create<{ request: Request | null }>(() => ({ request: null }));
40
41export function requestCustomSnooze(): Promise<SnoozeChoice | null> {
42 useRequest.getState().request?.resolve(null);
43 return new Promise((resolve) => useRequest.setState({ request: { resolve } }));
44}
45
46function finish(choice: SnoozeChoice | null) {
47 const request = useRequest.getState().request;
48 useRequest.setState({ request: null });
49 request?.resolve(choice);
50}
51
52function startOfLocalDay(ms: number) {
53 const day = new Date(ms);
54 day.setHours(0, 0, 0, 0);
55 return day;
56}
57
58export function CustomSnoozeDialogHost() {
59 const request = useRequest((state) => state.request);
60 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resolves any pending snooze request promise with null when the dialog host unmounts
61 useEffect(() => () => finish(null), []);
62 return request ? <CustomSnoozeDialog /> : null;
63}
64
65function CustomSnoozeDialog() {
66 const id = useId();
67 const navigate = useNavigate();
68 const [initial] = useState(() => new Date(Date.now() + 3_600_000));
69 const nowMinute = useNowMinute();
70 // Follows the clock so a dialog left open past midnight stops offering yesterday.
71 const today = startOfLocalDay(Date.parse(`${nowMinute}Z`));
72 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
73 const [date, setDate] = useState(initial);
74 const [calendarOpen, setCalendarOpen] = useState(false);
75 const [time, setTime] = useState(localSnoozeTime(initial));
76 const [amount, setAmount] = useState("2");
77 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
78 const [error, setError] = useState<string | null>(null);
79 const input: CustomSnoozeInput =
80 mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit };
81 return (
82 <Dialog
83 open
84 onOpenChange={(open) => {
85 if (!open) finish(null);
86 }}
87 >
88 <DialogPopup className="sm:max-w-md">
89 <form
90 className="flex min-h-0 flex-col"
91 onSubmit={(event) => {
92 event.preventDefault();
93 const snoozedUntil = resolveCustomSnooze(input, new Date());
94 if (!snoozedUntil) {
95 setError(
96 mode === "date"
97 ? "Choose a valid date and time in the future."
98 : "Enter a positive duration.",
99 );
100 return;
101 }
102 finish({ snoozedUntil });
103 }}
104 >
105 <DialogHeader>
106 <DialogTitle>Custom snooze</DialogTitle>
107 <DialogDescription>Choose when snoozed threads return to your inbox.</DialogDescription>
108 </DialogHeader>
109 <DialogPanel>
110 <div className="flex flex-col gap-4">
111 <ToggleGroup
112 aria-label="Schedule type"
113 className="w-full *:flex-1"
114 value={[mode]}
115 onValueChange={(next) => {
116 const value = next[0];
117 if (value === "date" || value === "duration") setMode(value);
118 setError(null);
119 }}
120 >
121 <Toggle value="date">Date and time</Toggle>
122 <Toggle value="duration">Duration</Toggle>
123 </ToggleGroup>
124 <div className="flex flex-col gap-4">
125 {mode === "date" ? (
126 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
127 <div className="flex min-w-0 flex-col gap-2">
128 <Label htmlFor={`${id}-date`}>Date</Label>
129 <Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
130 <PopoverTrigger
131 render={
132 <Button
133 id={`${id}-date`}
134 variant="outline"
135 className="w-full justify-between"
136 />
137 }
138 >
139 {date.toLocaleDateString(undefined, {
140 month: "short",
141 day: "numeric",
142 year: "numeric",
143 })}
144 <CalendarIcon className="size-4 text-muted-foreground" />
145 </PopoverTrigger>
146 <PopoverPopup align="start" aria-label="Choose snooze date">
147 <Calendar
148 mode="single"
149 required
150 selected={date}
151 defaultMonth={date}
152 {...(weekStartsOn === undefined ? {} : { weekStartsOn })}
153 disabled={{ before: today }}
154 onSelect={(selected) => {
155 setDate(selected);
156 setCalendarOpen(false);
157 setError(null);
158 }}
159 />
160 </PopoverPopup>
161 </Popover>
162 </div>
163 <Label className="flex min-w-0 flex-col items-stretch" htmlFor={`${id}-time`}>
164 Time
165 <Input
166 nativeInput
167 id={`${id}-time`}
168 className="h-9 sm:h-8"
169 type="time"
170 required
171 value={time}
172 onChange={(event) => {
173 setTime(event.target.value);
174 setError(null);
175 }}
176 />
177 </Label>
178 </div>
179 ) : (
180 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
181 <NumberField
182 id={`${id}-amount`}
183 min={0}
184 step="any"
185 value={amount === "" ? null : Number(amount)}
186 onValueChange={(value) => {
187 setAmount(value === null ? "" : String(value));
188 setError(null);
189 }}
190 >
191 <Label htmlFor={`${id}-amount`}>Snooze for</Label>
192 <NumberFieldGroup>
193 <NumberFieldDecrement aria-label="Decrease duration" />
194 <NumberFieldInput required />
195 <NumberFieldIncrement aria-label="Increase duration" />
196 </NumberFieldGroup>
197 </NumberField>
198 <Label className="flex min-w-0 flex-col items-stretch" htmlFor={`${id}-unit`}>
199 Unit
200 <Select
201 value={unit}
202 items={{ minutes: "Minutes", hours: "Hours", days: "Days" }}
203 onValueChange={(value) => {
204 if (value === "minutes" || value === "hours" || value === "days")
205 setUnit(value);
206 setError(null);
207 }}
208 >
209 <SelectTrigger id={`${id}-unit`} className="min-w-0">
210 <SelectValue />
211 </SelectTrigger>
212 <SelectPopup>
213 <SelectItem value="minutes">Minutes</SelectItem>
214 <SelectItem value="hours">Hours</SelectItem>
215 <SelectItem value="days">Days</SelectItem>
216 </SelectPopup>
217 </Select>
218 </Label>
219 </div>
220 )}
221 </div>
222 </div>
223 {error && (
224 <p role="alert" className="text-destructive">
225 {error}
226 </p>
227 )}
228 </DialogPanel>
229 <DialogFooter>
230 <Button
231 type="button"
232 variant="ghost-muted"
233 className="sm:mr-auto"
234 onClick={() => {
235 finish(null);
236 void navigate({
237 to: "/settings/threads",
238 hash: "snooze-options",
239 });
240 }}
241 >
242 <Settings2Icon />
243 Edit snooze options
244 </Button>
245 <Button type="button" variant="outline" onClick={() => finish(null)}>
246 Cancel
247 </Button>
248 <Button type="submit">Snooze</Button>
249 </DialogFooter>
250 </form>
251 </DialogPopup>
252 </Dialog>
253 );
254}