Skip to content

Commit fffd23c

Browse files
committed
feat: dismissible reminder
1 parent f449e11 commit fffd23c

6 files changed

Lines changed: 274 additions & 13 deletions

File tree

app/web/features/dashboard/ReminderCarousel.test.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
23
import { service } from "service";
4+
import liteUsers from "test/fixtures/liteUsers.json";
35
import wrapper from "test/hookWrapper";
46
import i18n from "test/i18n";
57
import { assertErrorAlert, mockConsoleError } from "test/utils";
68

79
import ReminderCarousel from "./ReminderCarousel";
810

11+
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
12+
const DISMISSED_REMINDERS_KEY = "dismissedReminders";
13+
914
const { t } = i18n;
1015

1116
const getReminders = service.account.getReminders as jest.MockedFunction<
1217
typeof service.account.getReminders
1318
>;
1419

1520
describe("ReminderCarousel", () => {
21+
beforeEach(() => {
22+
localStorage.removeItem(DISMISSED_REMINDERS_KEY);
23+
});
24+
1625
it("renders nothing when backend returns no reminders", async () => {
1726
getReminders.mockResolvedValue({ remindersList: [] });
1827

@@ -48,4 +57,135 @@ describe("ReminderCarousel", () => {
4857

4958
await assertErrorAlert("Failed to fetch reminders");
5059
});
60+
61+
it("dismisses a reminder via the dismiss control and persists the choice", async () => {
62+
const user = userEvent.setup();
63+
getReminders.mockResolvedValue({
64+
remindersList: [{ completeProfileReminder: {} }],
65+
});
66+
67+
render(<ReminderCarousel />, { wrapper });
68+
69+
expect(
70+
await screen.findByText(t("dashboard:reminder.complete_profile.title")),
71+
).toBeVisible();
72+
73+
await user.click(
74+
screen.getByRole("button", {
75+
name: t("dashboard:reminder.carousel_dismiss_button_a11y"),
76+
}),
77+
);
78+
79+
expect(
80+
screen.queryByText(t("dashboard:reminder.complete_profile.title")),
81+
).not.toBeInTheDocument();
82+
83+
const stored: Record<string, number> = JSON.parse(
84+
localStorage.getItem(DISMISSED_REMINDERS_KEY) ?? "{}",
85+
);
86+
expect(typeof stored.complete_profile).toBe("number");
87+
});
88+
89+
it("does not show a reminder that was dismissed within the last week", async () => {
90+
const now = 1_700_000_000_000;
91+
jest.spyOn(Date, "now").mockReturnValue(now);
92+
localStorage.setItem(
93+
DISMISSED_REMINDERS_KEY,
94+
JSON.stringify({
95+
complete_profile: now - 24 * 60 * 60 * 1000,
96+
}),
97+
);
98+
99+
getReminders.mockResolvedValue({
100+
remindersList: [{ completeProfileReminder: {} }],
101+
});
102+
103+
const { container } = render(<ReminderCarousel />, { wrapper });
104+
105+
await waitFor(() => expect(getReminders).toHaveBeenCalled());
106+
expect(container).toBeEmptyDOMElement();
107+
});
108+
109+
it("shows a reminder again once the dismiss is older than one week", async () => {
110+
const now = 1_700_000_000_000;
111+
jest.spyOn(Date, "now").mockReturnValue(now);
112+
localStorage.setItem(
113+
DISMISSED_REMINDERS_KEY,
114+
JSON.stringify({
115+
complete_profile: now - ONE_WEEK_MS - 1,
116+
}),
117+
);
118+
119+
getReminders.mockResolvedValue({
120+
remindersList: [{ completeProfileReminder: {} }],
121+
});
122+
123+
render(<ReminderCarousel />, { wrapper });
124+
125+
expect(
126+
await screen.findByText(t("dashboard:reminder.complete_profile.title")),
127+
).toBeVisible();
128+
});
129+
130+
it("prunes stale dismiss entries from storage when a reminder is dismissed", async () => {
131+
const user = userEvent.setup();
132+
const now = 2_000_000_000_000;
133+
jest.spyOn(Date, "now").mockReturnValue(now);
134+
localStorage.setItem(
135+
DISMISSED_REMINDERS_KEY,
136+
JSON.stringify({
137+
"write_reference:7": now - ONE_WEEK_MS - 1000,
138+
}),
139+
);
140+
141+
getReminders.mockResolvedValue({
142+
remindersList: [{ completeProfileReminder: {} }],
143+
});
144+
145+
render(<ReminderCarousel />, { wrapper });
146+
await screen.findByText(t("dashboard:reminder.complete_profile.title"));
147+
148+
await user.click(
149+
screen.getByRole("button", {
150+
name: t("dashboard:reminder.carousel_dismiss_button_a11y"),
151+
}),
152+
);
153+
154+
const stored: Record<string, number> = JSON.parse(
155+
localStorage.getItem(DISMISSED_REMINDERS_KEY) ?? "{}",
156+
);
157+
expect(stored["write_reference:7"]).toBeUndefined();
158+
expect(stored.complete_profile).toBe(now);
159+
});
160+
161+
it("dismissing one host request reminder does not dismiss another", async () => {
162+
const user = userEvent.setup();
163+
const [alice, bob] = liteUsers;
164+
getReminders.mockResolvedValue({
165+
remindersList: [
166+
{ respondToHostRequestReminder: { hostRequestId: 42, surferUser: alice } },
167+
{ respondToHostRequestReminder: { hostRequestId: 99, surferUser: bob } },
168+
],
169+
});
170+
171+
render(<ReminderCarousel />, { wrapper });
172+
173+
const aliceTitle = t("dashboard:reminder.respond_to_host_request.title", {
174+
name: alice.name,
175+
});
176+
const bobTitle = t("dashboard:reminder.respond_to_host_request.title", {
177+
name: bob.name,
178+
});
179+
180+
expect(await screen.findByText(aliceTitle)).toBeVisible();
181+
expect(screen.getByText(bobTitle)).toBeVisible();
182+
183+
const dismissButtons = screen.getAllByRole("button", {
184+
name: t("dashboard:reminder.carousel_dismiss_button_a11y"),
185+
});
186+
await user.click(dismissButtons[0]);
187+
188+
expect(screen.queryByText(aliceTitle)).not.toBeInTheDocument();
189+
expect(screen.getByText(bobTitle)).toBeVisible();
190+
});
51191
});

app/web/features/dashboard/ReminderCarousel.tsx

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { useQuery } from "@tanstack/react-query";
77
import Alert from "components/Alert";
88
import IconButton from "components/IconButton";
99
import { RpcError } from "grpc-web";
10-
import { GetRemindersRes } from "proto/account_pb";
10+
import { usePersistedState } from "platform/usePersistedState";
11+
import { GetRemindersRes, Reminder } from "proto/account_pb";
1112
import { useEffect, useRef, useState } from "react";
1213
import { service } from "service";
1314

@@ -19,6 +20,47 @@ const CARD_WIDTH_DESKTOP = 280;
1920
const CARD_WIDTH_MOBILE = 240;
2021
const CARD_GAP = 16;
2122

23+
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
24+
25+
function getReminderId(reminder: Reminder.AsObject): string | null {
26+
if (reminder.respondToHostRequestReminder?.hostRequestId != null) {
27+
return `respond_host_request:${reminder.respondToHostRequestReminder.hostRequestId}`;
28+
}
29+
if (reminder.writeReferenceReminder?.hostRequestId != null) {
30+
return `write_reference:${reminder.writeReferenceReminder.hostRequestId}`;
31+
}
32+
if (reminder.completeProfileReminder) {
33+
return "complete_profile";
34+
}
35+
if (reminder.completeVerificationReminder) {
36+
return "complete_verification";
37+
}
38+
return null;
39+
}
40+
41+
function pruneStaleEntries(
42+
dismissedReminders: Record<string, number>,
43+
now: number,
44+
): Record<string, number> {
45+
const out: Record<string, number> = {};
46+
for (const [k, ts] of Object.entries(dismissedReminders)) {
47+
if (now - ts < ONE_WEEK_MS) {
48+
out[k] = ts;
49+
}
50+
}
51+
return out;
52+
}
53+
54+
function isStillDismissed(
55+
dismissedReminders: Record<string, number>,
56+
key: string,
57+
now: number,
58+
): boolean {
59+
const ts = dismissedReminders[key];
60+
if (ts === undefined) return false;
61+
return now - ts < ONE_WEEK_MS;
62+
}
63+
2264
const StyledContainer = styled(Box)(({ theme }) => ({
2365
display: "flex",
2466
alignItems: "center",
@@ -58,12 +100,21 @@ const StyledArrow = styled(IconButton)({
58100
},
59101
});
60102

103+
interface ReminderWithId {
104+
id: string;
105+
reminder: Reminder.AsObject;
106+
}
107+
61108
export default function ReminderCarousel() {
62109
const { data, error } = useQuery<GetRemindersRes.AsObject, RpcError>({
63110
queryKey: [remindersKey],
64111
queryFn: () => service.account.getReminders(),
65112
});
66113

114+
const [dismissedReminders, setDismissedReminders] = usePersistedState<
115+
Record<string, number>
116+
>("dismissedReminders", {});
117+
67118
const scrollerRef = useRef<HTMLDivElement>(null);
68119
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
69120

@@ -72,6 +123,21 @@ export default function ReminderCarousel() {
72123

73124
const reminders = data?.remindersList ?? [];
74125

126+
const now = Date.now();
127+
const visibleReminders = reminders.reduce<ReminderWithId[]>((acc, r) => {
128+
const id = getReminderId(r);
129+
if (id && !isStillDismissed(dismissedReminders, id, now)) {
130+
acc.push({ id, reminder: r });
131+
}
132+
return acc;
133+
}, []);
134+
135+
const handleDismiss = (id: string) => {
136+
const dismissTime = Date.now();
137+
const pruned = pruneStaleEntries(dismissedReminders, dismissTime);
138+
setDismissedReminders({ ...pruned, [id]: dismissTime });
139+
};
140+
75141
const updateScrollState = () => {
76142
const el = scrollerRef.current;
77143
if (!el) return;
@@ -81,21 +147,21 @@ export default function ReminderCarousel() {
81147

82148
useEffect(() => {
83149
updateScrollState();
84-
}, [reminders.length]);
150+
}, [visibleReminders.length]);
85151

86152
const scrollByCard = (direction: 1 | -1) => {
87153
scrollerRef.current?.scrollBy({
88154
left:
89155
direction *
90156
(isMobile
91-
? CARD_WIDTH_DESKTOP + CARD_GAP
92-
: CARD_WIDTH_MOBILE + CARD_GAP),
157+
? CARD_WIDTH_MOBILE + CARD_GAP
158+
: CARD_WIDTH_DESKTOP + CARD_GAP),
93159
behavior: "smooth",
94160
});
95161
};
96162

97163
if (error) return <Alert severity="error">{error.message}</Alert>;
98-
if (!reminders.length) return null;
164+
if (!visibleReminders.length) return null;
99165

100166
return (
101167
<StyledContainer>
@@ -108,9 +174,12 @@ export default function ReminderCarousel() {
108174
</StyledArrow>
109175

110176
<StyledScroller ref={scrollerRef} onScroll={updateScrollState}>
111-
{reminders.map((reminder, i) => (
112-
<StyledCardSlot key={i}>
113-
<ReminderItem reminder={reminder} />
177+
{visibleReminders.map(({ id, reminder }) => (
178+
<StyledCardSlot key={id}>
179+
<ReminderItem
180+
reminder={reminder}
181+
onDismiss={() => handleDismiss(id)}
182+
/>
114183
</StyledCardSlot>
115184
))}
116185
</StyledScroller>

app/web/features/dashboard/ReminderItem.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
23
import { ReferenceType } from "proto/references_pb";
34
import liteUsers from "test/fixtures/liteUsers.json";
45
import wrapper from "test/hookWrapper";
@@ -120,4 +121,24 @@ describe("ReminderItem", () => {
120121

121122
expect(container).toBeEmptyDOMElement();
122123
});
124+
125+
it("calls onDismiss when the dismiss button is clicked", async () => {
126+
const user = userEvent.setup();
127+
const onDismiss = jest.fn();
128+
render(
129+
<ReminderItem
130+
reminder={{ completeProfileReminder: {} }}
131+
onDismiss={onDismiss}
132+
/>,
133+
{ wrapper },
134+
);
135+
136+
await user.click(
137+
screen.getByRole("button", {
138+
name: t("dashboard:reminder.carousel_dismiss_button_a11y"),
139+
}),
140+
);
141+
142+
expect(onDismiss).toHaveBeenCalledTimes(1);
143+
});
123144
});

app/web/features/dashboard/ReminderItem.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { alpha, Box, Typography } from "@mui/material";
22
import { useMediaQuery } from "@mui/system";
33
import Button from "components/Button";
4+
import IconButton from "components/IconButton";
5+
import { CloseIcon } from "components/Icons";
46
import { useTranslation } from "i18n";
57
import { DASHBOARD } from "i18n/namespaces";
68
import Link from "next/link";
@@ -17,8 +19,10 @@ import { theme } from "../../theme";
1719

1820
export default function ReminderItem({
1921
reminder,
22+
onDismiss,
2023
}: {
2124
reminder: Reminder.AsObject;
25+
onDismiss?: () => void;
2226
}) {
2327
const { t } = useTranslation([DASHBOARD]);
2428

@@ -66,13 +70,29 @@ export default function ReminderItem({
6670
return (
6771
<Box
6872
sx={(theme) => ({
73+
position: "relative",
6974
backgroundColor: alpha(theme.palette.secondary.main, 0.08),
7075
padding: isMobile ? "20px" : "24px",
7176
display: "flex",
7277
flexDirection: "column",
7378
height: "100%",
7479
})}
7580
>
81+
{onDismiss && (
82+
<IconButton
83+
aria-label={t("reminder.carousel_dismiss_button_a11y")}
84+
onClick={onDismiss}
85+
size="small"
86+
sx={(theme) => ({
87+
position: "absolute",
88+
top: theme.spacing(1),
89+
right: theme.spacing(1),
90+
})}
91+
>
92+
<CloseIcon />
93+
</IconButton>
94+
)}
95+
7696
<Box sx={{ flexGrow: 1 }}>
7797
<Typography
7898
variant="h3"

app/web/features/dashboard/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"title": "Strong verification",
8787
"description": "Verify your identity for a safer community",
8888
"button": "Verify your account"
89-
}
89+
},
90+
"carousel_dismiss_button_a11y": "Dismiss reminder"
9091
}
9192
}

0 commit comments

Comments
 (0)