Skip to content

feat: add notifications widget in the navbar #16983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 87 additions & 26 deletions site/src/api/api.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just making a note for myself: this looks like a good candidate for swapping in the OneWayWebSocket class once that PR is merged in

Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,39 @@ export const watchWorkspace = (workspaceId: string): EventSource => {
);
};

type WatchInboxNotificationsParams = {
read_status?: "read" | "unread" | "all";
};

export const watchInboxNotifications = (
onNewNotification: (res: TypesGen.GetInboxNotificationResponse) => void,
params?: WatchInboxNotificationsParams,
) => {
const searchParams = new URLSearchParams(params);
const socket = createWebSocket(
"/api/v2/notifications/inbox/watch",
searchParams,
);

socket.addEventListener("message", (event) => {
try {
const res = JSON.parse(
event.data,
) as TypesGen.GetInboxNotificationResponse;
onNewNotification(res);
} catch (error) {
console.warn("Error parsing inbox notification: ", error);
}
});

socket.addEventListener("error", (event) => {
console.warn("Watch inbox notifications error: ", event);
socket.close();
});

return socket;
};

export const getURLWithSearchParams = (
basePath: string,
options?: SearchParamOptions,
Expand Down Expand Up @@ -184,15 +217,11 @@ export const watchBuildLogsByTemplateVersionId = (
searchParams.append("after", after.toString());
}

const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${proto}//${
location.host
}/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`,
const socket = createWebSocket(
`/api/v2/templateversions/${versionId}/logs`,
searchParams,
);

socket.binaryType = "blob";

socket.addEventListener("message", (event) =>
onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog),
);
Expand All @@ -214,21 +243,21 @@ export const watchWorkspaceAgentLogs = (
agentId: string,
{ after, onMessage, onDone, onError }: WatchWorkspaceAgentLogsOptions,
) => {
// WebSocket compression in Safari (confirmed in 16.5) is broken when
// the server sends large messages. The following error is seen:
//
// WebSocket connection to 'wss://.../logs?follow&after=0' failed: The operation couldn’t be completed. Protocol error
//
const noCompression =
userAgentParser(navigator.userAgent).browser.name === "Safari"
? "&no_compression"
: "";
const searchParams = new URLSearchParams({ after: after.toString() });

const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${proto}//${location.host}/api/v2/workspaceagents/${agentId}/logs?follow&after=${after}${noCompression}`,
/**
* WebSocket compression in Safari (confirmed in 16.5) is broken when
* the server sends large messages. The following error is seen:
* WebSocket connection to 'wss://...' failed: The operation couldn’t be completed.
*/
if (userAgentParser(navigator.userAgent).browser.name === "Safari") {
searchParams.set("no_compression", "");
}

const socket = createWebSocket(
`/api/v2/workspaceagents/${agentId}/logs`,
searchParams,
);
socket.binaryType = "blob";

socket.addEventListener("message", (event) => {
const logs = JSON.parse(event.data) as TypesGen.WorkspaceAgentLog[];
Expand Down Expand Up @@ -267,13 +296,11 @@ export const watchBuildLogsByBuildId = (
if (after !== undefined) {
searchParams.append("after", after.toString());
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${proto}//${
location.host
}/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`,

const socket = createWebSocket(
`/api/v2/workspacebuilds/${buildId}/logs`,
searchParams,
);
socket.binaryType = "blob";

socket.addEventListener("message", (event) =>
onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog),
Expand Down Expand Up @@ -2406,6 +2433,25 @@ class ApiMethods {
);
return res.data;
};

getInboxNotifications = async () => {
const res = await this.axios.get<TypesGen.ListInboxNotificationsResponse>(
"/api/v2/notifications/inbox",
);
return res.data;
};

updateInboxNotificationReadStatus = async (
notificationId: string,
req: TypesGen.UpdateInboxNotificationReadStatusRequest,
) => {
const res =
await this.axios.put<TypesGen.UpdateInboxNotificationReadStatusResponse>(
`/api/v2/notifications/inbox/${notificationId}/read-status`,
req,
);
return res.data;
};
}

// This is a hard coded CSRF token/cookie pair for local development. In prod,
Expand Down Expand Up @@ -2457,6 +2503,21 @@ function getConfiguredAxiosInstance(): AxiosInstance {
return instance;
}

/**
* Utility function to help create a WebSocket connection with Coder's API.
*/
function createWebSocket(
path: string,
params: URLSearchParams = new URLSearchParams(),
) {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${protocol}//${location.host}${path}?${params.toString()}`,
);
socket.binaryType = "blob";
return socket;
}

// Other non-API methods defined here to make it a little easier to find them.
interface ClientApi extends ApiMethods {
getCsrfToken: () => string;
Expand Down
14 changes: 14 additions & 0 deletions site/src/modules/dashboard/Navbar/NavbarView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import type { ProxyContextValue } from "contexts/ProxyContext";
import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox";
import type { FC } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { cn } from "utils/cn";
Expand Down Expand Up @@ -65,6 +67,18 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewHealth={canViewHealth}
/>

<NotificationsInbox
fetchNotifications={API.getInboxNotifications}
markAllAsRead={() => {
throw new Error("Function not implemented.");
}}
markNotificationAsRead={(notificationId) =>
API.updateInboxNotificationReadStatus(notificationId, {
is_read: true,
})
}
/>

{user && (
<UserDropdown
user={user}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Button, type ButtonProps } from "components/Button/Button";
import { BellIcon } from "lucide-react";
import { type FC, forwardRef } from "react";
import { forwardRef } from "react";
import { UnreadBadge } from "./UnreadBadge";

type InboxButtonProps = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { MockNotification } from "testHelpers/entities";
import { daysAgo } from "utils/time";
import { InboxItem } from "./InboxItem";

const meta: Meta<typeof InboxItem> = {
Expand All @@ -22,7 +23,7 @@ export const Read: Story = {
args: {
notification: {
...MockNotification,
read_status: "read",
read_at: daysAgo(1),
},
},
};
Expand All @@ -31,7 +32,7 @@ export const Unread: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
read_at: null,
},
},
};
Expand All @@ -40,7 +41,7 @@ export const UnreadFocus: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
read_at: null,
},
},
play: async ({ canvasElement }) => {
Expand All @@ -54,7 +55,7 @@ export const OnMarkNotificationAsRead: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
read_at: null,
},
onMarkNotificationAsRead: fn(),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { InboxNotification } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import { SquareCheckBig } from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { relativeTime } from "utils/time";
import type { Notification } from "./types";

type InboxItemProps = {
notification: Notification;
notification: InboxNotification;
onMarkNotificationAsRead: (notificationId: string) => void;
};

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

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

<div className="w-12 flex flex-col items-end flex-shrink-0">
{notification.read_status === "unread" && (
{notification.read_at === null && (
<>
<div className="group-focus:hidden group-hover:hidden size-2.5 rounded-full bg-highlight-sky">
<span className="sr-only">Unread</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { InboxNotification } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Popover,
Expand All @@ -13,10 +14,9 @@ import { cn } from "utils/cn";
import { InboxButton } from "./InboxButton";
import { InboxItem } from "./InboxItem";
import { UnreadBadge } from "./UnreadBadge";
import type { Notification } from "./types";

type InboxPopoverProps = {
notifications: Notification[] | undefined;
notifications: readonly InboxNotification[] | undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the readonly update!

unreadCount: number;
error: unknown;
onRetry: () => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,13 @@ export const MarkNotificationAsRead: Story = {
notifications: MockNotifications,
unread_count: 2,
})),
markNotificationAsRead: fn(),
markNotificationAsRead: fn(async () => ({
unread_count: 1,
notification: {
...MockNotifications[1],
read_at: new Date().toISOString(),
},
})),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
Expand Down
Loading
Loading