diff --git a/.gitignore b/.gitignore index 661c1f4628..14991f3b79 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /tmp /out-tsc .env +/public/version.json # Sentry Config File .env.sentry-build-plugin diff --git a/package.json b/package.json index bcfece03bd..2c90261970 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "generate-subgraph-types": "npm-run-all --parallel generate-subgraph-types:mainnet generate-subgraph-types:testnet", "generate-contracts": "rimraf ./src/packages/contracts/generated/getters && rimraf ./src/packages/contracts/generated/infos && ./src/packages/contracts/scripts/generateContractRecords/index.ts && yarn prettier --write \"src/packages/contracts/generated/getters/**/*.{ts,json}\" && yarn prettier --write \"src/packages/contracts/generated/infos/**/*.{ts,json}\"", "generate-pancake-swap-tokens": "./src/packages/tokens/scripts/generatePancakeSwapTokenRecords.ts && yarn prettier --write \"src/packages/tokens/infos/pancakeSwapTokens/bscMainnet.ts\"", - "generate-version-file": "genversion --es6 --semi src/constants/version.ts", + "generate-version-files": "genversion --es6 --semi src/constants/version.ts && src/scripts/generatePublicVersionFile.ts", "husky:install": "husky install", - "postinstall": "npm-run-all --parallel husky:install generate-version-file generate-contracts generate-subgraph-types && yarn generate-pancake-swap-tokens", + "postinstall": "npm-run-all --parallel husky:install generate-version-files generate-contracts generate-subgraph-types && yarn generate-pancake-swap-tokens", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "regression": "reg-suit run" @@ -50,6 +50,7 @@ "bignumber.js": "^9.1.1", "buffer": "^6.0.3", "clsx": "^2.0.0", + "compare-versions": "^6.1.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^2.30.0", "ethers": "^5.7.2", diff --git a/src/App/index.tsx b/src/App/index.tsx index 76d23eab88..c5a3624927 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -7,6 +7,7 @@ import { QueryClientProvider } from 'react-query'; import { HashRouter } from 'react-router-dom'; import { queryClient } from 'clients/api'; +import { AppVersionChecker } from 'containers/AppVersionChecker'; import { Layout } from 'containers/Layout'; import { AuthProvider } from 'context/AuthContext'; import { SentryErrorInfo } from 'packages/errors/SentryErrorInfo'; @@ -30,6 +31,8 @@ const App = () => ( + + diff --git a/src/clients/api/__mocks__/index.ts b/src/clients/api/__mocks__/index.ts index 8679a3f680..55ffe4d22e 100644 --- a/src/clients/api/__mocks__/index.ts +++ b/src/clients/api/__mocks__/index.ts @@ -269,6 +269,10 @@ export const useGetHypotheticalPrimeApys = vi.fn(() => useQuery(FunctionKey.GET_HYPOTHETICAL_PRIME_APYS, getHypotheticalPrimeApys), ); +export const getLatestAppVersion = vi.fn(); +export const useGetLatestAppVersion = () => + useQuery(FunctionKey.GET_LATEST_APP_VERSION, getLatestAppVersion); + // Mutations export const approveToken = vi.fn(); export const useApproveToken = (_variables: never, options?: MutationObserverOptions) => diff --git a/src/clients/api/index.ts b/src/clients/api/index.ts index 1983b254c0..20e1fb4e54 100644 --- a/src/clients/api/index.ts +++ b/src/clients/api/index.ts @@ -345,3 +345,6 @@ export { default as useGetHypotheticalPrimeApys } from './queries/getHypothetica export { default as getPrimeStatus } from './queries/getPrimeStatus'; export * from './queries/getPrimeStatus'; export { default as useGetPrimeStatus } from './queries/getPrimeStatus/useGetPrimeStatus'; + +export * from './queries/getLatestAppVersion'; +export { default as useGetLatestAppVersion } from './queries/getLatestAppVersion/useGetLatestAppVersion'; diff --git a/src/clients/api/queries/getLatestAppVersion/__tests__/index.spec.tsx b/src/clients/api/queries/getLatestAppVersion/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9d38071138 --- /dev/null +++ b/src/clients/api/queries/getLatestAppVersion/__tests__/index.spec.tsx @@ -0,0 +1,23 @@ +import Vi from 'vitest'; + +import { PUBLIC_VERSION_FILE_URL, getLatestAppVersion } from '..'; + +describe('getLatestAppVersion', () => { + test('returns the latest app version on success', async () => { + const fakeVersion = '9.9.9'; + + (fetch as Vi.Mock).mockImplementationOnce(() => ({ + json: () => ({ + version: fakeVersion, + }), + })); + + const response = await getLatestAppVersion(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(PUBLIC_VERSION_FILE_URL); + expect(response).toEqual({ + version: fakeVersion, + }); + }); +}); diff --git a/src/clients/api/queries/getLatestAppVersion/index.tsx b/src/clients/api/queries/getLatestAppVersion/index.tsx new file mode 100644 index 0000000000..bfa3cff208 --- /dev/null +++ b/src/clients/api/queries/getLatestAppVersion/index.tsx @@ -0,0 +1,14 @@ +export type GetLatestAppVersionOutput = { + version?: string; +}; + +export const PUBLIC_VERSION_FILE_URL = `${window.location.origin}/version.json`; + +export const getLatestAppVersion = async (): Promise => { + const data = await fetch(PUBLIC_VERSION_FILE_URL); + const { version } = await data.json(); + + return { + version, + }; +}; diff --git a/src/clients/api/queries/getLatestAppVersion/useGetLatestAppVersion.ts b/src/clients/api/queries/getLatestAppVersion/useGetLatestAppVersion.ts new file mode 100644 index 0000000000..9bec8129ff --- /dev/null +++ b/src/clients/api/queries/getLatestAppVersion/useGetLatestAppVersion.ts @@ -0,0 +1,27 @@ +import { QueryObserverOptions, useQuery } from 'react-query'; + +import { + GetLatestAppVersionOutput, + getLatestAppVersion, +} from 'clients/api/queries/getLatestAppVersion'; +import FunctionKey from 'constants/functionKey'; + +const REFETCH_INTERVAL_MS = 1000 * 60 * 60; // One hour in milliseconds + +type Options = QueryObserverOptions< + GetLatestAppVersionOutput, + Error, + GetLatestAppVersionOutput, + GetLatestAppVersionOutput, + FunctionKey.GET_LATEST_APP_VERSION +>; + +const useGetLatestAppVersion = (options?: Options) => + useQuery(FunctionKey.GET_LATEST_APP_VERSION, getLatestAppVersion, { + staleTime: 0, + cacheTime: 0, + refetchInterval: REFETCH_INTERVAL_MS, + ...options, + }); + +export default useGetLatestAppVersion; diff --git a/src/constants/functionKey.ts b/src/constants/functionKey.ts index 30f110ac91..432580695e 100644 --- a/src/constants/functionKey.ts +++ b/src/constants/functionKey.ts @@ -56,6 +56,7 @@ enum FunctionKey { GET_HYPOTHETICAL_PRIME_APYS = 'GET_HYPOTHETICAL_PRIME_APYS', GET_PRIME_TOKEN = 'GET_PRIME_TOKEN', GET_PRIME_STATUS = 'GET_PRIME_STATUS', + GET_LATEST_APP_VERSION = 'GET_LATEST_APP_VERSION', // Mutations MINT_VAI = 'MINT_VAI', diff --git a/src/containers/AppVersionChecker/index.tsx b/src/containers/AppVersionChecker/index.tsx new file mode 100644 index 0000000000..7fc3737375 --- /dev/null +++ b/src/containers/AppVersionChecker/index.tsx @@ -0,0 +1,24 @@ +import { compareVersions } from 'compare-versions'; +import { displayNotification } from 'packages/notifications'; +import { useTranslation } from 'packages/translations'; +import React, { useEffect } from 'react'; + +import { useGetLatestAppVersion } from 'clients/api'; +import { version as APP_VERSION } from 'constants/version'; + +export const AppVersionChecker: React.FC = () => { + const { t } = useTranslation(); + const { data } = useGetLatestAppVersion(); + const latestAppVersion = data?.version; + + useEffect(() => { + if (latestAppVersion && compareVersions(latestAppVersion, APP_VERSION)) { + displayNotification({ + description: t('appVersionChecker.newVersionMessage'), + autoClose: false, + }); + } + }, [latestAppVersion, t]); + + return null; +}; diff --git a/src/hooks/useSendTransaction/useTrackTransaction/__tests__/index.spec.tsx b/src/hooks/useSendTransaction/useTrackTransaction/__tests__/index.spec.tsx index 0d37d432f7..4225480950 100644 --- a/src/hooks/useSendTransaction/useTrackTransaction/__tests__/index.spec.tsx +++ b/src/hooks/useSendTransaction/useTrackTransaction/__tests__/index.spec.tsx @@ -60,6 +60,7 @@ describe('useTrackTransaction', () => { expect(displayNotification).toHaveBeenCalledWith({ id: fakeContractTransaction.hash, variant: 'loading', + autoClose: false, title: en.transactionNotification.pending.title, description: ( { expect(displayNotification).toHaveBeenCalledWith({ id: fakeContractTransaction.hash, variant: 'loading', + autoClose: false, title: en.transactionNotification.pending.title, description: ( { expect(displayNotification).toHaveBeenCalledWith({ id: fakeContractTransaction.hash, variant: 'loading', + autoClose: false, title: en.transactionNotification.pending.title, description: ( { expect(displayNotification).toHaveBeenCalledWith({ id: fakeContractTransaction.hash, variant: 'loading', + autoClose: false, title: en.transactionNotification.pending.title, description: ( { const notificationId = displayNotification({ id: transaction.hash, variant: 'loading', + autoClose: false, title: t('transactionNotification.pending.title'), description: , }); diff --git a/src/packages/notifications/utilities/index.ts b/src/packages/notifications/utilities/index.ts index 475b711343..9b56c2aaea 100644 --- a/src/packages/notifications/utilities/index.ts +++ b/src/packages/notifications/utilities/index.ts @@ -13,19 +13,11 @@ const timeoutsMapping: { [notificationId: Notification['id']]: NodeJS.Timer; } = {}; -const setHideTimeout = ({ - id, - variant, -}: { - id: Notification['id']; - variant: Notification['variant']; -}) => { +const setHideTimeout = ({ id }: { id: Notification['id'] }) => { const { removeNotification } = store.getState(); - // Automatically hide notification after a certain time if it's not of the variant "loading" - if (variant !== 'loading') { - timeoutsMapping[id] = setTimeout(() => removeNotification({ id }), DISPLAY_TIME_MS); - } + // Automatically hide notification after a certain time + timeoutsMapping[id] = setTimeout(() => removeNotification({ id }), DISPLAY_TIME_MS); }; export const hideNotification = ({ id }: RemoveNotificationInput) => { @@ -36,7 +28,14 @@ export const hideNotification = ({ id }: RemoveNotificationInput) => { removeNotification({ id }); }; -export const displayNotification = (input: AddNotificationInput) => { +type DisplayNotificationInput = AddNotificationInput & { + autoClose?: boolean; +}; + +export const displayNotification = ({ + autoClose = true, + ...addNotificationInput +}: DisplayNotificationInput) => { const { addNotification, notifications } = store.getState(); // Remove last notification if we've reached the maximum allowed @@ -45,21 +44,32 @@ export const displayNotification = (input: AddNotificationInput) => { } // Add notification to store - const newNotificationId = addNotification(input); + const newNotificationId = addNotification(addNotificationInput); - setHideTimeout({ id: newNotificationId, variant: input.variant }); + if (autoClose) { + setHideTimeout({ id: newNotificationId }); + } return newNotificationId; }; -export const updateNotification = (input: UpdateNotificationInput) => { +type UpdateNotificationUtilInput = UpdateNotificationInput & { + autoClose?: boolean; +}; + +export const updateNotification = ({ + autoClose = true, + ...updateNotificationInput +}: UpdateNotificationUtilInput) => { // Clear hide timeout if one was set - clearTimeout(timeoutsMapping[input.id]); + clearTimeout(timeoutsMapping[updateNotificationInput.id]); const { updateNotification: updateStoreNotification } = store.getState(); // Update notification - updateStoreNotification(input); + updateStoreNotification(updateNotificationInput); - setHideTimeout({ id: input.id, variant: input.variant }); + if (autoClose) { + setHideTimeout({ id: updateNotificationInput.id }); + } }; diff --git a/src/packages/translations/translations/en.json b/src/packages/translations/translations/en.json index c056c9ebd1..b9d5f177e2 100644 --- a/src/packages/translations/translations/en.json +++ b/src/packages/translations/translations/en.json @@ -79,6 +79,9 @@ "step1": "Step 1", "step2": "Step 2" }, + "appVersionChecker": { + "newVersionMessage": "A new version of Venus is available. Reload the page to use it now." + }, "apyChart": { "tooltipItemLabels": { "borrowApy": "Borrow APY", @@ -309,15 +312,15 @@ "xvs": "XVS" } }, + "legacyPool": { + "description": "Venus’ primary pool is composed of large-cap tokens that meet minimum liquidity requirements.", + "name": "Venus Core Pool" + }, "lunaUstWarningModal": { "closeButtonLabel": "Got it", "content": "LUNA and UST have been deprecated, please disable them as collateral to get access to the Venus protocol again.", "title": "Please disable LUNA and UST as collateral" }, - "legacyPool": { - "description": "Venus’ primary pool is composed of large-cap tokens that meet minimum liquidity requirements.", - "name": "Venus Core Pool" - }, "markdownEditor": { "markdownTabLabel": "Write", "placeholder": "Nothing to preview", diff --git a/src/scripts/generatePublicVersionFile.ts b/src/scripts/generatePublicVersionFile.ts new file mode 100755 index 0000000000..61dbef01ee --- /dev/null +++ b/src/scripts/generatePublicVersionFile.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env tsx +import * as path from 'path'; +import { searchForWorkspaceRoot } from 'vite'; + +import { version } from 'constants/version'; +import writeFile from 'utilities/writeFile'; + +const generatePublicVersionFile = async () => { + const content = JSON.stringify({ + version, + }); + + console.log(searchForWorkspaceRoot(__dirname)); + + // Generate file + const outputPath = path.join(searchForWorkspaceRoot(__dirname), './public/version.json'); + + writeFile({ + outputPath, + content, + }); + + return outputPath; +}; + +console.log('Generating public version file...'); + +generatePublicVersionFile() + .then(outputPath => console.log(`Finished generating public version file at: ${outputPath}`)) + .catch(console.error); diff --git a/yarn.lock b/yarn.lock index d9a482bcd9..1a736dd26a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9710,6 +9710,11 @@ compare-versions@^5.0.0: resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-5.0.3.tgz#a9b34fea217472650ef4a2651d905f42c28ebfd7" integrity sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A== +compare-versions@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.0.tgz#3f2131e3ae93577df111dba133e6db876ffe127a" + integrity sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg== + compressible@~2.0.16: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"