Skip to content

Commit c182295

Browse files
committed
feat: add notifications widget in the navbar
1 parent 564b387 commit c182295

File tree

9 files changed

+155
-72
lines changed

9 files changed

+155
-72
lines changed

site/src/api/api.ts

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,33 @@ export const watchWorkspace = (workspaceId: string): EventSource => {
124124
);
125125
};
126126

127+
type WatchInboxNotificationsParams = {
128+
read_status?: "read" | "unread" | "all";
129+
};
130+
131+
export const watchInboxNotifications = (
132+
onNewNotification: (res: TypesGen.GetInboxNotificationResponse) => void,
133+
params?: WatchInboxNotificationsParams,
134+
) => {
135+
const searchParams = new URLSearchParams(params);
136+
const socket = createWebSocket(
137+
"/api/v2/notifications/inbox/watch",
138+
searchParams,
139+
);
140+
141+
socket.addEventListener("message", (event) => {
142+
const res = JSON.parse(event.data) as TypesGen.GetInboxNotificationResponse;
143+
onNewNotification(res);
144+
});
145+
146+
socket.addEventListener("error", (event) => {
147+
console.log("Watch inbox notifications error: ", event);
148+
socket.close();
149+
});
150+
151+
return socket;
152+
};
153+
127154
export const getURLWithSearchParams = (
128155
basePath: string,
129156
options?: SearchParamOptions,
@@ -184,15 +211,11 @@ export const watchBuildLogsByTemplateVersionId = (
184211
searchParams.append("after", after.toString());
185212
}
186213

187-
const proto = location.protocol === "https:" ? "wss:" : "ws:";
188-
const socket = new WebSocket(
189-
`${proto}//${
190-
location.host
191-
}/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`,
214+
const socket = createWebSocket(
215+
`/api/v2/templateversions/${versionId}/logs`,
216+
searchParams,
192217
);
193218

194-
socket.binaryType = "blob";
195-
196219
socket.addEventListener("message", (event) =>
197220
onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog),
198221
);
@@ -214,21 +237,21 @@ export const watchWorkspaceAgentLogs = (
214237
agentId: string,
215238
{ after, onMessage, onDone, onError }: WatchWorkspaceAgentLogsOptions,
216239
) => {
217-
// WebSocket compression in Safari (confirmed in 16.5) is broken when
218-
// the server sends large messages. The following error is seen:
219-
//
220-
// WebSocket connection to 'wss://.../logs?follow&after=0' failed: The operation couldn’t be completed. Protocol error
221-
//
222-
const noCompression =
223-
userAgentParser(navigator.userAgent).browser.name === "Safari"
224-
? "&no_compression"
225-
: "";
240+
const searchParams = new URLSearchParams({ after: after.toString() });
226241

227-
const proto = location.protocol === "https:" ? "wss:" : "ws:";
228-
const socket = new WebSocket(
229-
`${proto}//${location.host}/api/v2/workspaceagents/${agentId}/logs?follow&after=${after}${noCompression}`,
242+
/**
243+
* WebSocket compression in Safari (confirmed in 16.5) is broken when
244+
* the server sends large messages. The following error is seen:
245+
* WebSocket connection to 'wss://...' failed: The operation couldn’t be completed.
246+
*/
247+
if (userAgentParser(navigator.userAgent).browser.name === "Safari") {
248+
searchParams.set("no_compression", "");
249+
}
250+
251+
const socket = createWebSocket(
252+
`/api/v2/workspaceagents/${agentId}/logs`,
253+
searchParams,
230254
);
231-
socket.binaryType = "blob";
232255

233256
socket.addEventListener("message", (event) => {
234257
const logs = JSON.parse(event.data) as TypesGen.WorkspaceAgentLog[];
@@ -267,13 +290,11 @@ export const watchBuildLogsByBuildId = (
267290
if (after !== undefined) {
268291
searchParams.append("after", after.toString());
269292
}
270-
const proto = location.protocol === "https:" ? "wss:" : "ws:";
271-
const socket = new WebSocket(
272-
`${proto}//${
273-
location.host
274-
}/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`,
293+
294+
const socket = createWebSocket(
295+
`/api/v2/workspacebuilds/${buildId}/logs`,
296+
searchParams,
275297
);
276-
socket.binaryType = "blob";
277298

278299
socket.addEventListener("message", (event) =>
279300
onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog),
@@ -2388,6 +2409,25 @@ class ApiMethods {
23882409
);
23892410
return res.data;
23902411
};
2412+
2413+
getInboxNotifications = async () => {
2414+
const res = await this.axios.get<TypesGen.ListInboxNotificationsResponse>(
2415+
"/api/v2/notifications/inbox",
2416+
);
2417+
return res.data;
2418+
};
2419+
2420+
updateInboxNotificationReadStatus = async (
2421+
notificationId: string,
2422+
req: TypesGen.UpdateInboxNotificationReadStatusRequest,
2423+
) => {
2424+
const res =
2425+
await this.axios.put<TypesGen.UpdateInboxNotificationReadStatusResponse>(
2426+
`/api/v2/notifications/inbox/${notificationId}/read-status`,
2427+
req,
2428+
);
2429+
return res.data;
2430+
};
23912431
}
23922432

23932433
// This is a hard coded CSRF token/cookie pair for local development. In prod,
@@ -2439,6 +2479,21 @@ function getConfiguredAxiosInstance(): AxiosInstance {
24392479
return instance;
24402480
}
24412481

2482+
/**
2483+
* Utility function to help create a WebSocket connection with Coder's API.
2484+
*/
2485+
function createWebSocket(
2486+
path: string,
2487+
params: URLSearchParams = new URLSearchParams(),
2488+
) {
2489+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
2490+
const socket = new WebSocket(
2491+
`${protocol}//${location.host}${path}?${params.toString()}`,
2492+
);
2493+
socket.binaryType = "blob";
2494+
return socket;
2495+
}
2496+
24422497
// Other non-API methods defined here to make it a little easier to find them.
24432498
interface ClientApi extends ApiMethods {
24442499
getCsrfToken: () => string;

site/src/modules/dashboard/Navbar/NavbarView.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { DeploymentDropdown } from "./DeploymentDropdown";
99
import { MobileMenu } from "./MobileMenu";
1010
import { ProxyMenu } from "./ProxyMenu";
1111
import { UserDropdown } from "./UserDropdown/UserDropdown";
12+
import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox";
13+
import { API } from "api/api";
1214

1315
export interface NavbarViewProps {
1416
logo_url?: string;
@@ -65,6 +67,18 @@ export const NavbarView: FC<NavbarViewProps> = ({
6567
canViewHealth={canViewHealth}
6668
/>
6769

70+
<NotificationsInbox
71+
fetchNotifications={API.getInboxNotifications}
72+
markAllAsRead={(): Promise<void> => {
73+
throw new Error("Function not implemented.");
74+
}}
75+
markNotificationAsRead={(notificationId) =>
76+
API.updateInboxNotificationReadStatus(notificationId, {
77+
is_read: true,
78+
})
79+
}
80+
/>
81+
6882
{user && (
6983
<UserDropdown
7084
user={user}

site/src/modules/notifications/NotificationsInbox/InboxButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Button, type ButtonProps } from "components/Button/Button";
22
import { BellIcon } from "lucide-react";
3-
import { type FC, forwardRef } from "react";
3+
import { forwardRef } from "react";
44
import { UnreadBadge } from "./UnreadBadge";
55

66
type InboxButtonProps = {

site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
22
import { expect, fn, userEvent, within } from "@storybook/test";
33
import { MockNotification } from "testHelpers/entities";
44
import { InboxItem } from "./InboxItem";
5+
import { daysAgo } from "utils/time";
56

67
const meta: Meta<typeof InboxItem> = {
78
title: "modules/notifications/NotificationsInbox/InboxItem",
@@ -22,7 +23,7 @@ export const Read: Story = {
2223
args: {
2324
notification: {
2425
...MockNotification,
25-
read_status: "read",
26+
read_at: daysAgo(1),
2627
},
2728
},
2829
};
@@ -31,7 +32,7 @@ export const Unread: Story = {
3132
args: {
3233
notification: {
3334
...MockNotification,
34-
read_status: "unread",
35+
read_at: null,
3536
},
3637
},
3738
};
@@ -40,7 +41,7 @@ export const UnreadFocus: Story = {
4041
args: {
4142
notification: {
4243
...MockNotification,
43-
read_status: "unread",
44+
read_at: null,
4445
},
4546
},
4647
play: async ({ canvasElement }) => {
@@ -54,7 +55,7 @@ export const OnMarkNotificationAsRead: Story = {
5455
args: {
5556
notification: {
5657
...MockNotification,
57-
read_status: "unread",
58+
read_at: null,
5859
},
5960
onMarkNotificationAsRead: fn(),
6061
},

site/src/modules/notifications/NotificationsInbox/InboxItem.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import type { InboxNotification } from "api/typesGenerated";
12
import { Avatar } from "components/Avatar/Avatar";
23
import { Button } from "components/Button/Button";
34
import { SquareCheckBig } from "lucide-react";
45
import type { FC } from "react";
56
import { Link as RouterLink } from "react-router-dom";
67
import { relativeTime } from "utils/time";
7-
import type { Notification } from "./types";
88

99
type InboxItemProps = {
10-
notification: Notification;
10+
notification: InboxNotification;
1111
onMarkNotificationAsRead: (notificationId: string) => void;
1212
};
1313

@@ -25,7 +25,7 @@ export const InboxItem: FC<InboxItemProps> = ({
2525
<Avatar fallback="AR" />
2626
</div>
2727

28-
<div className="flex flex-col gap-3">
28+
<div className="flex flex-col gap-3 flex-1">
2929
<span className="text-content-secondary text-sm font-medium">
3030
{notification.content}
3131
</span>
@@ -41,7 +41,7 @@ export const InboxItem: FC<InboxItemProps> = ({
4141
</div>
4242

4343
<div className="w-12 flex flex-col items-end flex-shrink-0">
44-
{notification.read_status === "unread" && (
44+
{notification.read_at === null && (
4545
<>
4646
<div className="group-focus:hidden group-hover:hidden size-2.5 rounded-full bg-highlight-sky">
4747
<span className="sr-only">Unread</span>

site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import { cn } from "utils/cn";
1313
import { InboxButton } from "./InboxButton";
1414
import { InboxItem } from "./InboxItem";
1515
import { UnreadBadge } from "./UnreadBadge";
16-
import type { Notification } from "./types";
16+
import type { InboxNotification } from "api/typesGenerated";
1717

1818
type InboxPopoverProps = {
19-
notifications: Notification[] | undefined;
19+
notifications: readonly InboxNotification[] | undefined;
2020
unreadCount: number;
2121
error: unknown;
2222
onRetry: () => void;

site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
import { getErrorDetail, getErrorMessage } from "api/errors";
22
import { displayError } from "components/GlobalSnackbar/utils";
3-
import type { FC } from "react";
3+
import { useEffect, useRef, type FC } from "react";
44
import { useMutation, useQuery, useQueryClient } from "react-query";
55
import { InboxPopover } from "./InboxPopover";
6-
import type { Notification } from "./types";
6+
import type {
7+
ListInboxNotificationsResponse,
8+
UpdateInboxNotificationReadStatusResponse,
9+
} from "api/typesGenerated";
10+
import { API, watchInboxNotifications } from "api/api";
711

812
const NOTIFICATIONS_QUERY_KEY = ["notifications"];
913

10-
type NotificationsResponse = {
11-
notifications: Notification[];
12-
unread_count: number;
13-
};
14-
1514
type NotificationsInboxProps = {
1615
defaultOpen?: boolean;
17-
fetchNotifications: () => Promise<NotificationsResponse>;
16+
fetchNotifications: () => Promise<ListInboxNotificationsResponse>;
1817
markAllAsRead: () => Promise<void>;
19-
markNotificationAsRead: (notificationId: string) => Promise<void>;
18+
markNotificationAsRead: (
19+
notificationId: string,
20+
) => Promise<UpdateInboxNotificationReadStatusResponse>;
2021
};
2122

2223
export const NotificationsInbox: FC<NotificationsInboxProps> = ({
@@ -36,6 +37,24 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({
3637
queryFn: fetchNotifications,
3738
});
3839

40+
useEffect(() => {
41+
const socket = watchInboxNotifications(
42+
(res) => {
43+
safeUpdateNotificationsCache((prev) => {
44+
return {
45+
unread_count: res.unread_count,
46+
notifications: [res.notification, ...prev.notifications],
47+
};
48+
});
49+
},
50+
{ read_status: "unread" },
51+
);
52+
53+
return () => {
54+
socket.close();
55+
};
56+
}, []);
57+
3958
const markAllAsReadMutation = useMutation({
4059
mutationFn: markAllAsRead,
4160
onSuccess: () => {
@@ -59,15 +78,15 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({
5978

6079
const markNotificationAsReadMutation = useMutation({
6180
mutationFn: markNotificationAsRead,
62-
onSuccess: (_, notificationId) => {
81+
onSuccess: (res) => {
6382
safeUpdateNotificationsCache((prev) => {
6483
return {
65-
unread_count: prev.unread_count - 1,
84+
unread_count: res.unread_count,
6685
notifications: prev.notifications.map((n) => {
67-
if (n.id !== notificationId) {
86+
if (n.id !== res.notification.id) {
6887
return n;
6988
}
70-
return { ...n, read_status: "read" };
89+
return res.notification;
7190
}),
7291
};
7392
});
@@ -81,10 +100,12 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({
81100
});
82101

83102
async function safeUpdateNotificationsCache(
84-
callback: (res: NotificationsResponse) => NotificationsResponse,
103+
callback: (
104+
res: ListInboxNotificationsResponse,
105+
) => ListInboxNotificationsResponse,
85106
) {
86107
await queryClient.cancelQueries(NOTIFICATIONS_QUERY_KEY);
87-
queryClient.setQueryData<NotificationsResponse>(
108+
queryClient.setQueryData<ListInboxNotificationsResponse>(
88109
NOTIFICATIONS_QUERY_KEY,
89110
(prev) => {
90111
if (!prev) {

site/src/modules/notifications/NotificationsInbox/types.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)