diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b6012335f93d8..f3be2612b61f8 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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, @@ -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), ); @@ -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[]; @@ -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), @@ -2406,6 +2433,25 @@ class ApiMethods { ); return res.data; }; + + getInboxNotifications = async () => { + const res = await this.axios.get( + "/api/v2/notifications/inbox", + ); + return res.data; + }; + + updateInboxNotificationReadStatus = async ( + notificationId: string, + req: TypesGen.UpdateInboxNotificationReadStatusRequest, + ) => { + const res = + await this.axios.put( + `/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 +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; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index d5ee661025f47..56ce03f342118 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -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 = ({ canViewHealth={canViewHealth} /> + { + throw new Error("Function not implemented."); + }} + markNotificationAsRead={(notificationId) => + API.updateInboxNotificationReadStatus(notificationId, { + is_read: true, + }) + } + /> + {user && ( = { @@ -22,7 +23,7 @@ export const Read: Story = { args: { notification: { ...MockNotification, - read_status: "read", + read_at: daysAgo(1), }, }, }; @@ -31,7 +32,7 @@ export const Unread: Story = { args: { notification: { ...MockNotification, - read_status: "unread", + read_at: null, }, }, }; @@ -40,7 +41,7 @@ export const UnreadFocus: Story = { args: { notification: { ...MockNotification, - read_status: "unread", + read_at: null, }, }, play: async ({ canvasElement }) => { @@ -54,7 +55,7 @@ export const OnMarkNotificationAsRead: Story = { args: { notification: { ...MockNotification, - read_status: "unread", + read_at: null, }, onMarkNotificationAsRead: fn(), }, diff --git a/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx index 2086a5f0a7fed..1279fa914fbbb 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx @@ -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; }; @@ -25,7 +25,7 @@ export const InboxItem: FC = ({ -
+
{notification.content} @@ -41,7 +41,7 @@ export const InboxItem: FC = ({
- {notification.read_status === "unread" && ( + {notification.read_at === null && ( <>
Unread diff --git a/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx b/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx index 2b94380ef7e7a..b1808918891cc 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx @@ -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; unreadCount: number; error: unknown; onRetry: () => void; diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx index 18663d521d8da..edc7edaa6d400 100644 --- a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx @@ -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); diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx index cbd573e155956..bf8d3622e35f1 100644 --- a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx @@ -1,22 +1,24 @@ +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 { useEffectEvent } from "hooks/hookPolyfills"; +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; + fetchNotifications: () => Promise; markAllAsRead: () => Promise; - markNotificationAsRead: (notificationId: string) => Promise; + markNotificationAsRead: ( + notificationId: string, + ) => Promise; }; export const NotificationsInbox: FC = ({ @@ -36,15 +38,52 @@ export const NotificationsInbox: FC = ({ queryFn: fetchNotifications, }); + const updateNotificationsCache = useEffectEvent( + async ( + callback: ( + res: ListInboxNotificationsResponse, + ) => ListInboxNotificationsResponse, + ) => { + await queryClient.cancelQueries(NOTIFICATIONS_QUERY_KEY); + queryClient.setQueryData( + NOTIFICATIONS_QUERY_KEY, + (prev) => { + if (!prev) { + return { notifications: [], unread_count: 0 }; + } + return callback(prev); + }, + ); + }, + ); + + useEffect(() => { + const socket = watchInboxNotifications( + (res) => { + updateNotificationsCache((prev) => { + return { + unread_count: res.unread_count, + notifications: [res.notification, ...prev.notifications], + }; + }); + }, + { read_status: "unread" }, + ); + + return () => { + socket.close(); + }; + }, [updateNotificationsCache]); + const markAllAsReadMutation = useMutation({ mutationFn: markAllAsRead, onSuccess: () => { - safeUpdateNotificationsCache((prev) => { + updateNotificationsCache((prev) => { return { unread_count: 0, notifications: prev.notifications.map((n) => ({ ...n, - read_status: "read", + read_at: new Date().toISOString(), })), }; }); @@ -59,15 +98,15 @@ export const NotificationsInbox: FC = ({ const markNotificationAsReadMutation = useMutation({ mutationFn: markNotificationAsRead, - onSuccess: (_, notificationId) => { - safeUpdateNotificationsCache((prev) => { + onSuccess: (res) => { + updateNotificationsCache((prev) => { 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; }), }; }); @@ -80,21 +119,6 @@ export const NotificationsInbox: FC = ({ }, }); - async function safeUpdateNotificationsCache( - callback: (res: NotificationsResponse) => NotificationsResponse, - ) { - await queryClient.cancelQueries(NOTIFICATIONS_QUERY_KEY); - queryClient.setQueryData( - NOTIFICATIONS_QUERY_KEY, - (prev) => { - if (!prev) { - return { notifications: [], unread_count: 0 }; - } - return callback(prev); - }, - ); - } - return ( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy