-
Notifications
You must be signed in to change notification settings - Fork 876
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
Changes from 3 commits
c182295
a696bd7
328854d
281dadf
4d0f010
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -124,6 +124,33 @@ 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) => { | ||
const res = JSON.parse(event.data) as TypesGen.GetInboxNotificationResponse; | ||
onNewNotification(res); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! I think it is always good to handle errors. In this case, I think we can just log a warn. What do you think? |
||
}); | ||
|
||
socket.addEventListener("error", (event) => { | ||
console.warn("Watch inbox notifications error: ", event); | ||
socket.close(); | ||
}); | ||
|
||
return socket; | ||
}; | ||
|
||
export const getURLWithSearchParams = ( | ||
basePath: string, | ||
options?: SearchParamOptions, | ||
|
@@ -184,15 +211,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), | ||
); | ||
|
@@ -214,21 +237,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[]; | ||
|
@@ -267,13 +290,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), | ||
|
@@ -2406,6 +2427,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, | ||
|
@@ -2457,6 +2497,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; | ||
|
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"; | ||
|
@@ -65,6 +67,18 @@ export const NavbarView: FC<NavbarViewProps> = ({ | |
canViewHealth={canViewHealth} | ||
/> | ||
|
||
<NotificationsInbox | ||
fetchNotifications={API.getInboxNotifications} | ||
markAllAsRead={(): Promise<void> => { | ||
throw new Error("Function not implemented."); | ||
}} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to be on the extra safe side, the return type can be changed to |
||
markNotificationAsRead={(notificationId) => | ||
API.updateInboxNotificationReadStatus(notificationId, { | ||
is_read: true, | ||
}) | ||
} | ||
/> | ||
|
||
{user && ( | ||
<UserDropdown | ||
user={user} | ||
|
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, | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Appreciate the |
||
unreadCount: number; | ||
error: unknown; | ||
onRetry: () => void; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,22 +1,23 @@ | ||
import { API, watchInboxNotifications } from "api/api"; | ||
import { getErrorDetail, getErrorMessage } from "api/errors"; | ||
import type { | ||
ListInboxNotificationsResponse, | ||
UpdateInboxNotificationReadStatusResponse, | ||
} from "api/typesGenerated"; | ||
import { displayError } from "components/GlobalSnackbar/utils"; | ||
import type { FC } from "react"; | ||
import { type FC, useEffect, useRef } from "react"; | ||
import { useMutation, useQuery, useQueryClient } from "react-query"; | ||
import { InboxPopover } from "./InboxPopover"; | ||
import type { Notification } from "./types"; | ||
|
||
const NOTIFICATIONS_QUERY_KEY = ["notifications"]; | ||
|
||
type NotificationsResponse = { | ||
notifications: Notification[]; | ||
unread_count: number; | ||
}; | ||
|
||
type NotificationsInboxProps = { | ||
defaultOpen?: boolean; | ||
fetchNotifications: () => Promise<NotificationsResponse>; | ||
fetchNotifications: () => Promise<ListInboxNotificationsResponse>; | ||
markAllAsRead: () => Promise<void>; | ||
markNotificationAsRead: (notificationId: string) => Promise<void>; | ||
markNotificationAsRead: ( | ||
notificationId: string, | ||
) => Promise<UpdateInboxNotificationReadStatusResponse>; | ||
}; | ||
|
||
export const NotificationsInbox: FC<NotificationsInboxProps> = ({ | ||
|
@@ -36,6 +37,24 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({ | |
queryFn: fetchNotifications, | ||
}); | ||
|
||
useEffect(() => { | ||
const socket = watchInboxNotifications( | ||
(res) => { | ||
safeUpdateNotificationsCache((prev) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JavaScript rules will ensure that this works, but just to help make sure the code stays readable, could There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, I'm really surprised that Biome didn't flag this as a missing effect dependency. I would expect that we would need to wrap the callback in |
||
return { | ||
unread_count: res.unread_count, | ||
notifications: [res.notification, ...prev.notifications], | ||
}; | ||
}); | ||
}, | ||
{ read_status: "unread" }, | ||
); | ||
|
||
return () => { | ||
socket.close(); | ||
}; | ||
}, []); | ||
|
||
const markAllAsReadMutation = useMutation({ | ||
mutationFn: markAllAsRead, | ||
onSuccess: () => { | ||
|
@@ -59,15 +78,15 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({ | |
|
||
const markNotificationAsReadMutation = useMutation({ | ||
mutationFn: markNotificationAsRead, | ||
onSuccess: (_, notificationId) => { | ||
onSuccess: (res) => { | ||
safeUpdateNotificationsCache((prev) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same note, here, too. When I read the code top to bottom, I find it slightly harder to follow the logic when we reference values that haven't been explicitly declared yet |
||
return { | ||
unread_count: prev.unread_count - 1, | ||
unread_count: res.unread_count, | ||
notifications: prev.notifications.map((n) => { | ||
if (n.id !== notificationId) { | ||
if (n.id !== res.notification.id) { | ||
return n; | ||
} | ||
return { ...n, read_status: "read" }; | ||
return res.notification; | ||
}), | ||
}; | ||
}); | ||
|
@@ -81,10 +100,12 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({ | |
}); | ||
|
||
async function safeUpdateNotificationsCache( | ||
callback: (res: NotificationsResponse) => NotificationsResponse, | ||
callback: ( | ||
res: ListInboxNotificationsResponse, | ||
) => ListInboxNotificationsResponse, | ||
) { | ||
await queryClient.cancelQueries(NOTIFICATIONS_QUERY_KEY); | ||
queryClient.setQueryData<NotificationsResponse>( | ||
queryClient.setQueryData<ListInboxNotificationsResponse>( | ||
NOTIFICATIONS_QUERY_KEY, | ||
(prev) => { | ||
if (!prev) { | ||
|
This file was deleted.
There was a problem hiding this comment.
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