Skip to content

Commit 29fbf5b

Browse files
committed
harden draft persistence and dirty tracking
1 parent 29123d6 commit 29fbf5b

5 files changed

Lines changed: 122 additions & 36 deletions

File tree

e2e/chat.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ test("chat restores unsent drafts after leaving and returning in the same tab",
210210
await expect(page.getByTestId("thread-list")).toBeVisible();
211211

212212
await page.goto(`/chats/${SEEDED_THREAD_ID}`);
213-
await expect(page.getByTestId("chat-composer-input")).toHaveValue(
214-
draftMessage
215-
);
213+
await expect(
214+
page.getByRole("textbox", { name: "Send a message to Avery..." })
215+
).toHaveValue(draftMessage);
216216
});
217217

218218
test("unsent chat drafts warn before closing or reloading the page", async ({

src/components/ChatWindow/ChatWindow.tsx

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { theme } from "@/styles/theme.yak";
4-
import { memo, useEffect, useMemo, useRef, useState } from "react";
4+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
55
import type { User } from "@supabase/supabase-js";
66

77
import { createClient } from "@/utils/supabase/client";
@@ -85,6 +85,42 @@ function getChatDraftStorageKey({
8585
return `peels:chat-draft:${userId}:${threadId}`;
8686
}
8787

88+
function readChatDraft(key: string) {
89+
if (typeof window === "undefined") {
90+
return "";
91+
}
92+
93+
try {
94+
return window.sessionStorage.getItem(key) || "";
95+
} catch {
96+
return "";
97+
}
98+
}
99+
100+
function writeChatDraft(key: string, message: string) {
101+
if (typeof window === "undefined") {
102+
return;
103+
}
104+
105+
try {
106+
window.sessionStorage.setItem(key, message);
107+
} catch {
108+
// Ignore storage failures, such as private browsing restrictions.
109+
}
110+
}
111+
112+
function removeChatDraft(key: string) {
113+
if (typeof window === "undefined") {
114+
return;
115+
}
116+
117+
try {
118+
window.sessionStorage.removeItem(key);
119+
} catch {
120+
// Ignore storage failures, such as private browsing restrictions.
121+
}
122+
}
123+
88124
function getClientTimeZone() {
89125
return (
90126
Intl.DateTimeFormat().resolvedOptions().timeZone ?? CHAT_RENDER_TIME_ZONE
@@ -247,14 +283,14 @@ const ChatWindow = memo(function ChatWindow({
247283
return errorMessage;
248284
}
249285

250-
function clearPendingDraftWrite() {
286+
const clearPendingDraftWrite = useCallback(() => {
251287
if (draftWriteTimeoutRef.current) {
252288
clearTimeout(draftWriteTimeoutRef.current);
253289
draftWriteTimeoutRef.current = null;
254290
}
255-
}
291+
}, []);
256292

257-
function flushPendingDraftWrite() {
293+
const flushPendingDraftWrite = useCallback(() => {
258294
clearPendingDraftWrite();
259295

260296
if (!pendingDraftWriteRef.current) {
@@ -263,28 +299,55 @@ const ChatWindow = memo(function ChatWindow({
263299

264300
const { key, message } = pendingDraftWriteRef.current;
265301
pendingDraftWriteRef.current = null;
266-
sessionStorage.setItem(key, message);
267-
}
302+
writeChatDraft(key, message);
303+
}, [clearPendingDraftWrite]);
268304

269-
function scheduleDraftWrite(key: string, nextMessage: string) {
270-
clearPendingDraftWrite();
271-
pendingDraftWriteRef.current = {
272-
key,
273-
message: nextMessage,
274-
};
275-
draftWriteTimeoutRef.current = setTimeout(() => {
305+
const scheduleDraftWrite = useCallback(
306+
(key: string, nextMessage: string) => {
307+
clearPendingDraftWrite();
308+
pendingDraftWriteRef.current = {
309+
key,
310+
message: nextMessage,
311+
};
312+
draftWriteTimeoutRef.current = setTimeout(() => {
313+
flushPendingDraftWrite();
314+
}, CHAT_DRAFT_WRITE_DELAY_MS);
315+
},
316+
[clearPendingDraftWrite, flushPendingDraftWrite]
317+
);
318+
319+
const removeDraftWrite = useCallback(
320+
(key: string) => {
321+
if (pendingDraftWriteRef.current?.key === key) {
322+
clearPendingDraftWrite();
323+
pendingDraftWriteRef.current = null;
324+
}
325+
326+
removeChatDraft(key);
327+
},
328+
[clearPendingDraftWrite]
329+
);
330+
331+
useEffect(() => {
332+
const handlePageHide = () => {
276333
flushPendingDraftWrite();
277-
}, CHAT_DRAFT_WRITE_DELAY_MS);
278-
}
334+
};
335+
const handleVisibilityChange = () => {
336+
if (document.visibilityState === "hidden") {
337+
flushPendingDraftWrite();
338+
}
339+
};
279340

280-
function removeDraftWrite(key: string) {
281-
if (pendingDraftWriteRef.current?.key === key) {
282-
clearPendingDraftWrite();
283-
pendingDraftWriteRef.current = null;
284-
}
341+
window.addEventListener("beforeunload", handlePageHide);
342+
window.addEventListener("pagehide", handlePageHide);
343+
document.addEventListener("visibilitychange", handleVisibilityChange);
285344

286-
sessionStorage.removeItem(key);
287-
}
345+
return () => {
346+
window.removeEventListener("beforeunload", handlePageHide);
347+
window.removeEventListener("pagehide", handlePageHide);
348+
document.removeEventListener("visibilitychange", handleVisibilityChange);
349+
};
350+
}, [flushPendingDraftWrite]);
288351

289352
useEffect(() => {
290353
setClientTimeZone(getClientTimeZone());
@@ -294,17 +357,15 @@ const ChatWindow = memo(function ChatWindow({
294357
flushPendingDraftWrite();
295358
setThreadId(existingThread?.id ?? null);
296359
setMessages(getThreadMessages(existingThread));
297-
setMessage(
298-
draftStorageKey ? sessionStorage.getItem(draftStorageKey) || "" : ""
299-
);
360+
setMessage(draftStorageKey ? readChatDraft(draftStorageKey) : "");
300361
lastReadSignatureRef.current = null;
301-
}, [draftStorageKey, existingThread]);
362+
}, [draftStorageKey, existingThread, flushPendingDraftWrite]);
302363

303364
useEffect(
304365
() => () => {
305366
flushPendingDraftWrite();
306367
},
307-
[]
368+
[flushPendingDraftWrite]
308369
);
309370

310371
useEffect(() => {

src/components/ListingWrite/ListingWrite.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { theme } from "@/styles/theme.yak";
44
import {
5+
useCallback,
56
useEffect,
67
useMemo,
78
useState,
@@ -224,6 +225,9 @@ export default function ListingWrite({
224225
);
225226
const [pendingPhotos, setPendingPhotos] = useState<string[]>([]);
226227
const [hasInteractedWithForm, setHasInteractedWithForm] = useState(false);
228+
const markFormInteracted = useCallback(() => {
229+
setHasInteractedWithForm(true);
230+
}, []);
227231

228232
const isMutating = submitMutation.isPending || deleteMutation.isPending;
229233
const initialListingValues = useMemo(
@@ -490,14 +494,28 @@ export default function ListingWrite({
490494
});
491495
};
492496

497+
const handlePhotosChange = useCallback(
498+
(nextPhotos: string[]) => {
499+
markFormInteracted();
500+
501+
if (initialListing) {
502+
setPhotos(nextPhotos);
503+
return;
504+
}
505+
506+
setPendingPhotos(nextPhotos);
507+
},
508+
[initialListing, markFormInteracted]
509+
);
510+
493511
return (
494512
<>
495513
{initialListing?.is_stub && <Lozenge>{t("Common.stub")}</Lozenge>}
496514

497515
<Form
498516
onSubmit={handleSubmit}
499-
onChange={() => setHasInteractedWithForm(true)}
500-
onInput={() => setHasInteractedWithForm(true)}
517+
onChange={markFormInteracted}
518+
onInput={markFormInteracted}
501519
data-testid="listing-write-form"
502520
data-hydrated={isHydrated ? "true" : "false"}
503521
>
@@ -578,6 +596,7 @@ export default function ListingWrite({
578596
areaName={areaName}
579597
setAreaName={setAreaName}
580598
autoDetectCountry={!initialListing}
599+
onLocationInteract={markFormInteracted}
581600
error={errors.location}
582601
/>
583602

@@ -698,7 +717,7 @@ export default function ListingWrite({
698717
<ListingPhotosManager
699718
initialPhotos={initialListing ? photos : pendingPhotos}
700719
listingSlug={initialListing?.slug}
701-
onPhotosChange={initialListing ? setPhotos : setPendingPhotos}
720+
onPhotosChange={handlePhotosChange}
702721
isNewListing={!initialListing}
703722
/>
704723
</FieldsetWithGap>

src/components/ListingWrite/listingWriteController.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export type LocationSelectProps = {
5353
setAreaName: Dispatch<SetStateAction<string>>;
5454
initialPlaceholderText?: string;
5555
autoDetectCountry?: boolean;
56+
onLocationInteract?: () => void;
5657
error?: string;
5758
};
5859

src/components/LocationSelect/LocationSelect.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type LocationSelectProps = {
5858
setAreaName: Dispatch<SetStateAction<string>>;
5959
initialPlaceholderText?: string;
6060
autoDetectCountry?: boolean;
61+
onLocationInteract?: () => void;
6162
error?: string;
6263
};
6364

@@ -150,6 +151,7 @@ export default function LocationSelect({
150151
setAreaName,
151152
initialPlaceholderText,
152153
autoDetectCountry = true,
154+
onLocationInteract,
153155
error,
154156
}: LocationSelectProps) {
155157
const t = useTranslations();
@@ -191,12 +193,13 @@ export default function LocationSelect({
191193

192194
const handleCountryChange = useCallback(
193195
(e: ChangeEvent<HTMLSelectElement>) => {
196+
onLocationInteract?.();
194197
setCountryCode(e.target.value);
195198
console.log("Country changed, focusing input...");
196199
setMapShown(false);
197200
inputRef.current?.focus();
198201
},
199-
[setCountryCode]
202+
[onLocationInteract, setCountryCode]
200203
);
201204

202205
const handleDragStart = useCallback(() => {
@@ -209,6 +212,7 @@ export default function LocationSelect({
209212
const handleDragEnd = useCallback(
210213
async (event: any) => {
211214
console.log("Drag end. Location:", event.lngLat);
215+
onLocationInteract?.();
212216

213217
const nextCoordinates = {
214218
latitude: event.lngLat.lat,
@@ -224,7 +228,7 @@ export default function LocationSelect({
224228
setAreaName(nextAreaName);
225229
setPlaceholderText(nextAreaName);
226230
},
227-
[setCoordinates, setAreaName]
231+
[onLocationInteract, setCoordinates, setAreaName]
228232
);
229233

230234
const handlePick = useCallback(
@@ -235,6 +239,7 @@ export default function LocationSelect({
235239

236240
// Otherwise continue as normal
237241
console.log("Picked:", event, event.feature?.center);
242+
onLocationInteract?.();
238243

239244
const nextCoordinates = {
240245
latitude: event.feature?.center[1],
@@ -269,7 +274,7 @@ export default function LocationSelect({
269274
setCoordinates(nextCoordinates);
270275
}
271276
},
272-
[mapShown, coordinates, setCoordinates, setAreaName]
277+
[mapShown, coordinates, onLocationInteract, setCoordinates, setAreaName]
273278
);
274279

275280
return (

0 commit comments

Comments
 (0)