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"