Skip to content

Version upgrade toast #44

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 3 commits into from
Oct 18, 2024
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/web/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"aliases": {
"components": "@/components",
"hooks": "@/components/hooks",
"utils": "@/lib/utils"
}
}
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"] });

Expand All @@ -25,6 +26,7 @@ export default function RootLayout({
suppressHydrationWarning
>
<body className={inter.className}>
<Toaster />
<PHProvider>
<ThemeProvider
attribute="class"
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { RepositoryCarousel } from "./repositoryCarousel";
import { SearchBar } from "./searchBar";
import { Separator } from "@/components/ui/separator";
import { SymbolIcon } from "@radix-ui/react-icons";
import { UpgradeToast } from "./upgradeToast";


export default async function Home() {
return (
<div className="flex flex-col items-center overflow-hidden">
{/* TopBar */}
<NavigationMenu />
<UpgradeToast />

<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 max-w-[90%]">
<div className="max-h-44 w-auto">
Expand Down
103 changes: 103 additions & 0 deletions packages/web/src/app/upgradeToast.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(
"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: (
<div className="flex flex-col gap-1">
<ToastAction
altText="Upgrade"
onClick={() => {
window.open("https://github.com/sourcebot-dev/sourcebot/releases/latest", "_blank");
}}
>
Upgrade
</ToastAction>
</div>
)
});

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;
}
194 changes: 194 additions & 0 deletions packages/web/src/components/hooks/use-toast.ts
Original file line number Diff line number Diff line change
@@ -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<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}

interface State {
toasts: ToasterToast[]
}

const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()

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<ToasterToast, "id">

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<State>(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 }
Loading