Skip to content

Commit cde9f46

Browse files
Version upgrade toast (#44)
1 parent c5f80eb commit cde9f46

File tree

10 files changed

+489
-1
lines changed

10 files changed

+489
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added a toast notification when a new Sourcebot version is available ([#44](https://github.com/sourcebot-dev/sourcebot/pull/44))
13+
1014
## [2.0.1] - 2024-10-17
1115

1216
### Added

packages/web/components.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"aliases": {
1414
"components": "@/components",
15+
"hooks": "@/components/hooks",
1516
"utils": "@/lib/utils"
1617
}
1718
}

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@radix-ui/react-scroll-area": "^1.1.0",
3434
"@radix-ui/react-separator": "^1.1.0",
3535
"@radix-ui/react-slot": "^1.1.0",
36+
"@radix-ui/react-toast": "^1.2.2",
3637
"@replit/codemirror-lang-csharp": "^6.2.0",
3738
"@replit/codemirror-vim": "^6.2.1",
3839
"@tanstack/react-query": "^5.53.3",

packages/web/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ThemeProvider } from "next-themes";
55
import { Suspense } from "react";
66
import { QueryClientProvider } from "./queryClientProvider";
77
import { PHProvider } from "./posthogProvider";
8+
import { Toaster } from "@/components/ui/toaster";
89

910
const inter = Inter({ subsets: ["latin"] });
1011

@@ -25,6 +26,7 @@ export default function RootLayout({
2526
suppressHydrationWarning
2627
>
2728
<body className={inter.className}>
29+
<Toaster />
2830
<PHProvider>
2931
<ThemeProvider
3032
attribute="class"

packages/web/src/app/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import { RepositoryCarousel } from "./repositoryCarousel";
99
import { SearchBar } from "./searchBar";
1010
import { Separator } from "@/components/ui/separator";
1111
import { SymbolIcon } from "@radix-ui/react-icons";
12+
import { UpgradeToast } from "./upgradeToast";
1213

1314

1415
export default async function Home() {
1516
return (
1617
<div className="flex flex-col items-center overflow-hidden">
17-
{/* TopBar */}
1818
<NavigationMenu />
19+
<UpgradeToast />
1920

2021
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 max-w-[90%]">
2122
<div className="max-h-44 w-auto">

packages/web/src/app/upgradeToast.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use client';
2+
3+
import { useToast } from "@/components/hooks/use-toast";
4+
import { ToastAction } from "@/components/ui/toast";
5+
import { NEXT_PUBLIC_SOURCEBOT_VERSION } from "@/lib/environment.client";
6+
import { useEffect } from "react";
7+
import { useLocalStorage } from "usehooks-ts";
8+
9+
const GITHUB_TAGS_URL = "https://api.github.com/repos/sourcebot-dev/sourcebot/tags";
10+
const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/;
11+
const TOAST_TIMEOUT_MS = 1000 * 60 * 60 * 24;
12+
13+
type Version = {
14+
major: number;
15+
minor: number;
16+
patch: number;
17+
};
18+
19+
export const UpgradeToast = () => {
20+
const { toast } = useToast();
21+
const [ upgradeToastLastShownDate, setUpgradeToastLastShownDate ] = useLocalStorage<string>(
22+
"upgradeToastLastShownDate",
23+
new Date(0).toUTCString()
24+
);
25+
26+
useEffect(() => {
27+
const currentVersion = getVersionFromString(NEXT_PUBLIC_SOURCEBOT_VERSION);
28+
if (!currentVersion) {
29+
return;
30+
}
31+
32+
if (Date.now() - new Date(upgradeToastLastShownDate).getTime() < TOAST_TIMEOUT_MS) {
33+
return;
34+
}
35+
36+
fetch(GITHUB_TAGS_URL)
37+
.then((response) => response.json())
38+
.then((data: { name: string }[]) => {
39+
const versions = data
40+
.map(({ name }) => getVersionFromString(name))
41+
.filter((version) => version !== null)
42+
.sort((a, b) => compareVersions(a, b))
43+
.reverse();
44+
45+
if (versions.length === 0) {
46+
return;
47+
}
48+
49+
const latestVersion = versions[0];
50+
if (compareVersions(currentVersion, latestVersion) >= 0) {
51+
return;
52+
}
53+
54+
toast({
55+
title: "New version available 📣 ",
56+
description: `Upgrade from ${getVersionString(currentVersion)} to ${getVersionString(latestVersion)}`,
57+
duration: 10 * 1000,
58+
action: (
59+
<div className="flex flex-col gap-1">
60+
<ToastAction
61+
altText="Upgrade"
62+
onClick={() => {
63+
window.open("https://github.com/sourcebot-dev/sourcebot/releases/latest", "_blank");
64+
}}
65+
>
66+
Upgrade
67+
</ToastAction>
68+
</div>
69+
)
70+
});
71+
72+
setUpgradeToastLastShownDate(new Date().toUTCString());
73+
});
74+
}, [setUpgradeToastLastShownDate, toast, upgradeToastLastShownDate]);
75+
76+
return null;
77+
}
78+
79+
const getVersionFromString = (version: string): Version | null => {
80+
const match = version.match(SEMVER_REGEX);
81+
if (!match) {
82+
return null;
83+
}
84+
return {
85+
major: parseInt(match[1]),
86+
minor: parseInt(match[2]),
87+
patch: parseInt(match[3]),
88+
} satisfies Version;
89+
}
90+
91+
const getVersionString = (version: Version) => {
92+
return `v${version.major}.${version.minor}.${version.patch}`;
93+
}
94+
95+
const compareVersions = (a: Version, b: Version) => {
96+
if (a.major !== b.major) {
97+
return a.major - b.major;
98+
}
99+
if (a.minor !== b.minor) {
100+
return a.minor - b.minor;
101+
}
102+
return a.patch - b.patch;
103+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"use client"
2+
3+
// Inspired by react-hot-toast library
4+
import * as React from "react"
5+
6+
import type {
7+
ToastActionElement,
8+
ToastProps,
9+
} from "@/components/ui/toast"
10+
11+
const TOAST_LIMIT = 1
12+
const TOAST_REMOVE_DELAY = 1000000
13+
14+
type ToasterToast = ToastProps & {
15+
id: string
16+
title?: React.ReactNode
17+
description?: React.ReactNode
18+
action?: ToastActionElement
19+
}
20+
21+
const actionTypes = {
22+
ADD_TOAST: "ADD_TOAST",
23+
UPDATE_TOAST: "UPDATE_TOAST",
24+
DISMISS_TOAST: "DISMISS_TOAST",
25+
REMOVE_TOAST: "REMOVE_TOAST",
26+
} as const
27+
28+
let count = 0
29+
30+
function genId() {
31+
count = (count + 1) % Number.MAX_SAFE_INTEGER
32+
return count.toString()
33+
}
34+
35+
type ActionType = typeof actionTypes
36+
37+
type Action =
38+
| {
39+
type: ActionType["ADD_TOAST"]
40+
toast: ToasterToast
41+
}
42+
| {
43+
type: ActionType["UPDATE_TOAST"]
44+
toast: Partial<ToasterToast>
45+
}
46+
| {
47+
type: ActionType["DISMISS_TOAST"]
48+
toastId?: ToasterToast["id"]
49+
}
50+
| {
51+
type: ActionType["REMOVE_TOAST"]
52+
toastId?: ToasterToast["id"]
53+
}
54+
55+
interface State {
56+
toasts: ToasterToast[]
57+
}
58+
59+
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
60+
61+
const addToRemoveQueue = (toastId: string) => {
62+
if (toastTimeouts.has(toastId)) {
63+
return
64+
}
65+
66+
const timeout = setTimeout(() => {
67+
toastTimeouts.delete(toastId)
68+
dispatch({
69+
type: "REMOVE_TOAST",
70+
toastId: toastId,
71+
})
72+
}, TOAST_REMOVE_DELAY)
73+
74+
toastTimeouts.set(toastId, timeout)
75+
}
76+
77+
export const reducer = (state: State, action: Action): State => {
78+
switch (action.type) {
79+
case "ADD_TOAST":
80+
return {
81+
...state,
82+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83+
}
84+
85+
case "UPDATE_TOAST":
86+
return {
87+
...state,
88+
toasts: state.toasts.map((t) =>
89+
t.id === action.toast.id ? { ...t, ...action.toast } : t
90+
),
91+
}
92+
93+
case "DISMISS_TOAST": {
94+
const { toastId } = action
95+
96+
// ! Side effects ! - This could be extracted into a dismissToast() action,
97+
// but I'll keep it here for simplicity
98+
if (toastId) {
99+
addToRemoveQueue(toastId)
100+
} else {
101+
state.toasts.forEach((toast) => {
102+
addToRemoveQueue(toast.id)
103+
})
104+
}
105+
106+
return {
107+
...state,
108+
toasts: state.toasts.map((t) =>
109+
t.id === toastId || toastId === undefined
110+
? {
111+
...t,
112+
open: false,
113+
}
114+
: t
115+
),
116+
}
117+
}
118+
case "REMOVE_TOAST":
119+
if (action.toastId === undefined) {
120+
return {
121+
...state,
122+
toasts: [],
123+
}
124+
}
125+
return {
126+
...state,
127+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
128+
}
129+
}
130+
}
131+
132+
const listeners: Array<(state: State) => void> = []
133+
134+
let memoryState: State = { toasts: [] }
135+
136+
function dispatch(action: Action) {
137+
memoryState = reducer(memoryState, action)
138+
listeners.forEach((listener) => {
139+
listener(memoryState)
140+
})
141+
}
142+
143+
type Toast = Omit<ToasterToast, "id">
144+
145+
function toast({ ...props }: Toast) {
146+
const id = genId()
147+
148+
const update = (props: ToasterToast) =>
149+
dispatch({
150+
type: "UPDATE_TOAST",
151+
toast: { ...props, id },
152+
})
153+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154+
155+
dispatch({
156+
type: "ADD_TOAST",
157+
toast: {
158+
...props,
159+
id,
160+
open: true,
161+
onOpenChange: (open) => {
162+
if (!open) dismiss()
163+
},
164+
},
165+
})
166+
167+
return {
168+
id: id,
169+
dismiss,
170+
update,
171+
}
172+
}
173+
174+
function useToast() {
175+
const [state, setState] = React.useState<State>(memoryState)
176+
177+
React.useEffect(() => {
178+
listeners.push(setState)
179+
return () => {
180+
const index = listeners.indexOf(setState)
181+
if (index > -1) {
182+
listeners.splice(index, 1)
183+
}
184+
}
185+
}, [state])
186+
187+
return {
188+
...state,
189+
toast,
190+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191+
}
192+
}
193+
194+
export { useToast, toast }

0 commit comments

Comments
 (0)