diff --git a/CHANGELOG.md b/CHANGELOG.md
index 079d6984..3f660c90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Added a toast notification when a new Sourcebot version is available ([#44](https://github.com/sourcebot-dev/sourcebot/pull/44))
+
## [2.0.1] - 2024-10-17
### Added
diff --git a/packages/web/components.json b/packages/web/components.json
index 8c574b77..32fffae3 100644
--- a/packages/web/components.json
+++ b/packages/web/components.json
@@ -12,6 +12,7 @@
},
"aliases": {
"components": "@/components",
+ "hooks": "@/components/hooks",
"utils": "@/lib/utils"
}
}
\ No newline at end of file
diff --git a/packages/web/package.json b/packages/web/package.json
index 0f17fe04..ae7cb054 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -33,6 +33,7 @@
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-toast": "^1.2.2",
"@replit/codemirror-lang-csharp": "^6.2.0",
"@replit/codemirror-vim": "^6.2.1",
"@tanstack/react-query": "^5.53.3",
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index fb1f6ac3..304605d9 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -5,6 +5,7 @@ import { ThemeProvider } from "next-themes";
import { Suspense } from "react";
import { QueryClientProvider } from "./queryClientProvider";
import { PHProvider } from "./posthogProvider";
+import { Toaster } from "@/components/ui/toaster";
const inter = Inter({ subsets: ["latin"] });
@@ -25,6 +26,7 @@ export default function RootLayout({
suppressHydrationWarning
>
+
- {/* TopBar */}
+
diff --git a/packages/web/src/app/upgradeToast.tsx b/packages/web/src/app/upgradeToast.tsx
new file mode 100644
index 00000000..73342266
--- /dev/null
+++ b/packages/web/src/app/upgradeToast.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import { useToast } from "@/components/hooks/use-toast";
+import { ToastAction } from "@/components/ui/toast";
+import { NEXT_PUBLIC_SOURCEBOT_VERSION } from "@/lib/environment.client";
+import { useEffect } from "react";
+import { useLocalStorage } from "usehooks-ts";
+
+const GITHUB_TAGS_URL = "https://api.github.com/repos/sourcebot-dev/sourcebot/tags";
+const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/;
+const TOAST_TIMEOUT_MS = 1000 * 60 * 60 * 24;
+
+type Version = {
+ major: number;
+ minor: number;
+ patch: number;
+};
+
+export const UpgradeToast = () => {
+ const { toast } = useToast();
+ const [ upgradeToastLastShownDate, setUpgradeToastLastShownDate ] = useLocalStorage
(
+ "upgradeToastLastShownDate",
+ new Date(0).toUTCString()
+ );
+
+ useEffect(() => {
+ const currentVersion = getVersionFromString(NEXT_PUBLIC_SOURCEBOT_VERSION);
+ if (!currentVersion) {
+ return;
+ }
+
+ if (Date.now() - new Date(upgradeToastLastShownDate).getTime() < TOAST_TIMEOUT_MS) {
+ return;
+ }
+
+ fetch(GITHUB_TAGS_URL)
+ .then((response) => response.json())
+ .then((data: { name: string }[]) => {
+ const versions = data
+ .map(({ name }) => getVersionFromString(name))
+ .filter((version) => version !== null)
+ .sort((a, b) => compareVersions(a, b))
+ .reverse();
+
+ if (versions.length === 0) {
+ return;
+ }
+
+ const latestVersion = versions[0];
+ if (compareVersions(currentVersion, latestVersion) >= 0) {
+ return;
+ }
+
+ toast({
+ title: "New version available 📣 ",
+ description: `Upgrade from ${getVersionString(currentVersion)} to ${getVersionString(latestVersion)}`,
+ duration: 10 * 1000,
+ action: (
+
+ {
+ window.open("https://github.com/sourcebot-dev/sourcebot/releases/latest", "_blank");
+ }}
+ >
+ Upgrade
+
+
+ )
+ });
+
+ setUpgradeToastLastShownDate(new Date().toUTCString());
+ });
+ }, [setUpgradeToastLastShownDate, toast, upgradeToastLastShownDate]);
+
+ return null;
+}
+
+const getVersionFromString = (version: string): Version | null => {
+ const match = version.match(SEMVER_REGEX);
+ if (!match) {
+ return null;
+ }
+ return {
+ major: parseInt(match[1]),
+ minor: parseInt(match[2]),
+ patch: parseInt(match[3]),
+ } satisfies Version;
+}
+
+const getVersionString = (version: Version) => {
+ return `v${version.major}.${version.minor}.${version.patch}`;
+}
+
+const compareVersions = (a: Version, b: Version) => {
+ if (a.major !== b.major) {
+ return a.major - b.major;
+ }
+ if (a.minor !== b.minor) {
+ return a.minor - b.minor;
+ }
+ return a.patch - b.patch;
+}
diff --git a/packages/web/src/components/hooks/use-toast.ts b/packages/web/src/components/hooks/use-toast.ts
new file mode 100644
index 00000000..02e111d8
--- /dev/null
+++ b/packages/web/src/components/hooks/use-toast.ts
@@ -0,0 +1,194 @@
+"use client"
+
+// Inspired by react-hot-toast library
+import * as React from "react"
+
+import type {
+ ToastActionElement,
+ ToastProps,
+} from "@/components/ui/toast"
+
+const TOAST_LIMIT = 1
+const TOAST_REMOVE_DELAY = 1000000
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+}
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
+ return count.toString()
+}
+
+type ActionType = typeof actionTypes
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"]
+ toast: ToasterToast
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+type Toast = Omit
+
+function toast({ ...props }: Toast) {
+ const id = genId()
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ })
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ }
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState)
+
+ React.useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
+ }
+ }, [state])
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ }
+}
+
+export { useToast, toast }
diff --git a/packages/web/src/components/ui/toast.tsx b/packages/web/src/components/ui/toast.tsx
new file mode 100644
index 00000000..521b94b0
--- /dev/null
+++ b/packages/web/src/components/ui/toast.tsx
@@ -0,0 +1,129 @@
+"use client"
+
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background text-foreground",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/packages/web/src/components/ui/toaster.tsx b/packages/web/src/components/ui/toaster.tsx
new file mode 100644
index 00000000..97730208
--- /dev/null
+++ b/packages/web/src/components/ui/toaster.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import { useToast } from "@/components/hooks/use-toast"
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/yarn.lock b/yarn.lock
index 1bb06ae1..51b7b9a4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1180,6 +1180,24 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.0"
+"@radix-ui/react-toast@^1.2.2":
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.2.2.tgz#fdd8ed0b80f47d6631dfd90278fee6debc06bf33"
+ integrity sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==
+ dependencies:
+ "@radix-ui/primitive" "1.1.0"
+ "@radix-ui/react-collection" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.1"
+ "@radix-ui/react-dismissable-layer" "1.1.1"
+ "@radix-ui/react-portal" "1.1.2"
+ "@radix-ui/react-presence" "1.1.1"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-use-callback-ref" "1.1.0"
+ "@radix-ui/react-use-controllable-state" "1.1.0"
+ "@radix-ui/react-use-layout-effect" "1.1.0"
+ "@radix-ui/react-visually-hidden" "1.1.0"
+
"@radix-ui/react-use-callback-ref@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"