diff --git a/packages/extension/package.json b/packages/extension/package.json index b3af75bf9..b7629b969 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@enkryptcom/extension", - "version": "2.0.2", + "version": "2.0.3", "private": true, "type": "module", "scripts": { diff --git a/packages/extension/src/libs/metrics/index.ts b/packages/extension/src/libs/metrics/index.ts index 1163b7af0..071bb2305 100644 --- a/packages/extension/src/libs/metrics/index.ts +++ b/packages/extension/src/libs/metrics/index.ts @@ -11,6 +11,8 @@ import { SendEventType, SettingEventType, SwapEventType, + UpdatesEventType, + UpdatesOpenLocation } from './types'; const metrics = new Metrics(); @@ -91,6 +93,14 @@ const trackDAppsEvents = ( metrics.track('dapps', { event, ...options }); }; +const trackUpdatesEvents = (event: UpdatesEventType, options: { + network: NetworkNames; + location?: UpdatesOpenLocation; + duration?: number; +}): void => { + metrics.track('updatesClick', { event, ...options }); + +} const optOutofMetrics = (optOut: boolean) => { if (!__IS_FIREFOX__) { metrics.setOptOut(false); @@ -111,4 +121,5 @@ export { trackDAppsEvents, optOutofMetrics, trackGenericEvents, + trackUpdatesEvents }; diff --git a/packages/extension/src/libs/metrics/types.ts b/packages/extension/src/libs/metrics/types.ts index 8c5c5bc7c..23f132584 100644 --- a/packages/extension/src/libs/metrics/types.ts +++ b/packages/extension/src/libs/metrics/types.ts @@ -61,3 +61,13 @@ export enum DAppsEventType { export enum SettingEventType { OptOut = 'opt_out', } + +export enum UpdatesEventType { + UpdatesOpen = 'updates_open', + UpdatesClosed = 'updates_closed', +} + +export enum UpdatesOpenLocation { + settings = 'settings', + logo = "logo", +} diff --git a/packages/extension/src/libs/updates-state/index.ts b/packages/extension/src/libs/updates-state/index.ts new file mode 100644 index 000000000..83a6c97f0 --- /dev/null +++ b/packages/extension/src/libs/updates-state/index.ts @@ -0,0 +1,62 @@ +import BrowserStorage from '../common/browser-storage'; +import { InternalStorageNamespace } from '@/types/provider'; +import { IState, StorageKeys } from './types'; + +class UpdatesState { + private storage: BrowserStorage; + + constructor() { + this.storage = new BrowserStorage(InternalStorageNamespace.updatesState); + } + + async setState(state: IState): Promise { + return this.storage.set(StorageKeys.updatesInfo, state); + } + + async getState(): Promise { + const state = this.storage.get(StorageKeys.updatesInfo); + if (!state) { + const newState: IState = { + lastVersionViewed: '', + currentRelease: '', + currentReleaseTimestamp: 0, + } + return newState + } + return state; + } + + async getLastVersionViewed(): Promise { + const state: IState = await this.getState(); + return state?.lastVersionViewed ?? ''; + } + async setLastVersionViewed(lastVersionViewed: string): Promise { + const state: IState = await this.getState(); + const newState: IState = { ...state, lastVersionViewed } + await this.setState(newState); + } + + async getCurrentRelease(): Promise { + const state: IState = await this.getState(); + return state?.currentRelease ?? ''; + } + + async setCurrentRelease(currentRelease: string): Promise { + const state: IState = await this.getState(); + const newState: IState = { ...state, currentRelease } + await this.setState(newState); + } + + async getCurrentReleaseTimestamp(): Promise { + const state: IState = await this.getState(); + return state?.currentReleaseTimestamp ?? 0; + } + + async setCurrentReleaseTimestamp(currentReleaseTimestamp: number): Promise { + const state: IState = await this.getState(); + const newState: IState = { ...state, currentReleaseTimestamp } + await this.setState(newState); + } +} + +export default UpdatesState; diff --git a/packages/extension/src/libs/updates-state/types.ts b/packages/extension/src/libs/updates-state/types.ts new file mode 100644 index 000000000..dae0fb32a --- /dev/null +++ b/packages/extension/src/libs/updates-state/types.ts @@ -0,0 +1,9 @@ +export enum StorageKeys { + updatesInfo = 'updates-info', +} + +export interface IState { + lastVersionViewed: string; + currentRelease: string; + currentReleaseTimestamp: number; +} diff --git a/packages/extension/src/types/provider.ts b/packages/extension/src/types/provider.ts index 4d9adafb4..601e67b47 100644 --- a/packages/extension/src/types/provider.ts +++ b/packages/extension/src/types/provider.ts @@ -52,6 +52,7 @@ export enum InternalStorageNamespace { customNetworksState = 'CustomNetworksState', rateState = 'RateState', recentlySentAddresses = 'RecentlySentAddresses', + updatesState = 'UpdatesState', } export enum EnkryptProviderEventMethods { persistentEvents = 'PersistentEvents', diff --git a/packages/extension/src/ui/action/App.vue b/packages/extension/src/ui/action/App.vue index 4078fb490..fabe95aeb 100644 --- a/packages/extension/src/ui/action/App.vue +++ b/packages/extension/src/ui/action/App.vue @@ -6,7 +6,14 @@ @@ -97,6 +113,13 @@ :latest-version="latestVersion" @close:popup="updateShow = !updateShow" /> + @@ -143,21 +166,37 @@ import { AccountsHeaderData } from './types/account'; import AddNetwork from './views/add-network/index.vue'; import ModalRate from './views/modal-rate/index.vue'; import Settings from './views/settings/index.vue'; +import ModalUpdates from './views/updates/index.vue'; import { KadenaNetwork } from '@/providers/kadena/types/kadena-network'; import { EnkryptProviderEventMethods, ProviderName } from '@/types/provider'; import { onClickOutside } from '@vueuse/core'; import RateState from '@/libs/rate-state'; import SwapLookingAnimation from '@action/icons/swap/swap-looking-animation.vue'; -import { trackBuyEvents, trackNetwork } from '@/libs/metrics'; +import { + trackBuyEvents, + trackNetwork, + trackUpdatesEvents, +} from '@/libs/metrics'; import { getLatestEnkryptVersion } from '@action/utils/browser'; import { gt as semverGT } from 'semver'; -import { BuyEventType, NetworkChangeEvents } from '@/libs/metrics/types'; +import { + BuyEventType, + NetworkChangeEvents, + UpdatesEventType, + UpdatesOpenLocation, +} from '@/libs/metrics/types'; import { NetworksCategory } from '@action/types/network-category'; import { newNetworks } from '@/providers/common/libs/new-features'; +import UpdatesState from '@/libs/updates-state'; +import UpdatedIcon from '@/ui/action/icons/updates/updated.vue'; +import HeartIcon from '@/ui/action/icons/updates/heart.vue'; +import { getLatestEnkryptUpdates } from '@action/utils/browser'; +import { Updates } from '@/ui/action/types/updates'; const domainState = new DomainState(); const networksState = new NetworksState(); const rateState = new RateState(); +const updatesState = new UpdatesState(); const appMenuRef = ref(null); const showDepositWindow = ref(false); const accountHeaderData = ref({ @@ -191,7 +230,107 @@ const isLoading = ref(true); const currentVersion = __PACKAGE_VERSION__; const latestVersion = ref(''); const enabledTestnetworks = ref([]); +/** ------------------- + * Updates + -------------------*/ +const releases = ref(null); +const loadedUpdates = ref(false); +const showUpdatesBtn = ref(false); +const showUpdatesDialog = ref(false); +const stateCurrentReleaseTimestamp = ref(0); + +/** + * Initializes the update state by performing the following actions: + * 1. Retrieves the current release from the state. + * 2. Updates the current release timestamp. + * 3. If the current release is empty or different from the current version in the app state, + * sets the current release and updates the release timestamp. + * 4. Fetches the latest Enkrypt updates and sets the releases state. + * 5. Displays the updates button if there are new releases. + * 6. Sets the loadedUpdates state to true if successful, otherwise false. + * + * @async + * @function initUpdateState + * @returns {Promise} A promise that resolves when the update state is initialized. + * @throws Will log an error message if the initialization fails. + */ +const initUpdateState = async () => { + try { + const currentReleaseInState = await updatesState.getCurrentRelease(); + stateCurrentReleaseTimestamp.value = + await updatesState.getCurrentReleaseTimestamp(); + if ( + currentReleaseInState === '' || + currentReleaseInState !== currentVersion + ) { + await updatesState.setCurrentRelease(currentVersion); + const newReleaseTimestamp = Date.now(); + await updatesState.setCurrentReleaseTimestamp(newReleaseTimestamp); + stateCurrentReleaseTimestamp.value = newReleaseTimestamp; + } + releases.value = await getLatestEnkryptUpdates(); + if (releases.value) { + await getShowUpdatesBtn(); + } + loadedUpdates.value = true; + } catch (error) { + console.error('Failed to init update state:', error); + loadedUpdates.value = false; + } +}; +/** + * Asynchronously determines whether to show the updates button based on the last version viewed and the current version. + * + * The function performs the following steps: + * 1. Retrieves the last version viewed from the updates state. + * 2. Checks if the last version viewed is empty or if the current version is greater than the last version viewed. + * 3. If the above condition is true, calculates an expiration timestamp (2 weeks from the current release timestamp). + * 4. Sets the `showUpdatesBtn` value to true if the current release timestamp is less than the expiration timestamp. + * 5. Otherwise, sets the `showUpdatesBtn` value to false. + * + * If an error occurs during the process, it logs an error message to the console. + * + * @returns {Promise} A promise that resolves when the function completes. + */ +const getShowUpdatesBtn = async () => { + try { + const lastVersionViewed = await updatesState.getLastVersionViewed(); + if ( + lastVersionViewed === '' || + (currentVersion && semverGT(currentVersion, lastVersionViewed)) + ) { + const expireTimestamp = stateCurrentReleaseTimestamp.value + 12096e5; //2 weeks; + showUpdatesBtn.value = + stateCurrentReleaseTimestamp.value < expireTimestamp; + } else { + showUpdatesBtn.value = false; + } + } catch (error) { + console.error('Failed to get show updates button:', error); + } +}; + +const openUpdatesDialog = (_location: UpdatesOpenLocation) => { + showUpdatesDialog.value = true; + updatesState.setLastVersionViewed(currentVersion); + showUpdatesBtn.value = false; + if (isOpenMore.value) { + closeMoreMenu(); + } + trackUpdatesEvents(UpdatesEventType.UpdatesOpen, { + network: currentNetwork.value.name, + location: _location, + }); +}; + +const closeUpdatesDialog = () => { + showUpdatesDialog.value = false; +}; + +/** ------------------- + * Core + -------------------*/ const setActiveNetworks = async () => { const pinnedNetworkNames = await networksState.getPinnedNetworkNames(); const allNetworks = await getAllNetworks(); @@ -246,6 +385,7 @@ const isKeyRingLocked = async (): Promise => { tabId: await domainState.getCurrentTabId(), }).then(res => JSON.parse(res.result || 'true')); }; + const init = async () => { const curNetwork = await domainState.getSelectedNetWork(); if (curNetwork) { @@ -258,6 +398,7 @@ const init = async () => { await setActiveNetworks(); isLoading.value = false; }; + onMounted(async () => { const isInitialized = await kr.isInitialized(); if (isInitialized) { @@ -287,6 +428,7 @@ onMounted(async () => { }); }, 2000); } + initUpdateState(); } else { openOnboard(); } @@ -621,6 +763,16 @@ body { &-logo { margin-left: 8px; } + &-updated { + height: 24px; + width: 90px; + cursor: pointer; + transition: 0.3s; + filter: brightness(1); + &:hover { + filter: brightness(0.9); + } + } &-row { height: 40px; @@ -689,6 +841,13 @@ body { top: 48px; z-index: 3; + &-divider { + height: 1px; + width: 90%; + margin: 8px; + background: @gray02; + } + &-link { width: 100%; height: 48px; @@ -700,6 +859,13 @@ body { transition: background 300ms ease-in-out; border-radius: 8px; + &-heart { + width: 18px !important; + height: 18px !important; + margin-right: 16px !important; + margin-left: 16px !important; + } + &:hover, &.active { background: rgba(0, 0, 0, 0.04); diff --git a/packages/extension/src/ui/action/components/accounts-header/components/header-accounts.vue b/packages/extension/src/ui/action/components/accounts-header/components/header-accounts.vue index 31071aaa6..6ab5b29cd 100644 --- a/packages/extension/src/ui/action/components/accounts-header/components/header-accounts.vue +++ b/packages/extension/src/ui/action/components/accounts-header/components/header-accounts.vue @@ -237,6 +237,7 @@ const disconnectFromDapp = async () => { height: 32px; margin-right: 12px; border-radius: 50%; + margin-top: 2px; } &__active-network { position: absolute; diff --git a/packages/extension/src/ui/action/icons/updates/heart.vue b/packages/extension/src/ui/action/icons/updates/heart.vue new file mode 100644 index 000000000..b3be9e6ac --- /dev/null +++ b/packages/extension/src/ui/action/icons/updates/heart.vue @@ -0,0 +1,5 @@ + diff --git a/packages/extension/src/ui/action/icons/updates/updated.vue b/packages/extension/src/ui/action/icons/updates/updated.vue new file mode 100644 index 000000000..fa3e2d2d4 --- /dev/null +++ b/packages/extension/src/ui/action/icons/updates/updated.vue @@ -0,0 +1,61 @@ + diff --git a/packages/extension/src/ui/action/types/updates.ts b/packages/extension/src/ui/action/types/updates.ts new file mode 100644 index 000000000..df9bb8e74 --- /dev/null +++ b/packages/extension/src/ui/action/types/updates.ts @@ -0,0 +1,13 @@ +import { NetworkNames } from "@enkryptcom/types"; + +export interface Version { + version: string; + description?: string; + chains_added?: NetworkNames[]; + swap_added?: NetworkNames[]; + release_date: string; + release_link: string; +} +export interface Updates { + versions: Version[]; +} diff --git a/packages/extension/src/ui/action/utils/browser.ts b/packages/extension/src/ui/action/utils/browser.ts index 57cff0f89..d86bb520c 100644 --- a/packages/extension/src/ui/action/utils/browser.ts +++ b/packages/extension/src/ui/action/utils/browser.ts @@ -1,5 +1,5 @@ import Browser from 'webextension-polyfill'; - +import { Updates } from '@/ui/action/types/updates'; export const BROWSER_NAMES = { chrome: 'chrome', firefox: 'firefox', @@ -60,3 +60,19 @@ export const getLatestEnkryptVersion = (): Promise => { }) .catch(() => null); }; + +export const getLatestEnkryptUpdates = (): Promise => { + + const browser = detectBrowser(); + const url = 'https://raw.githubusercontent.com/enkryptcom/dynamic-data/main/configs/release-versions' + const fetchUrl = browser === BROWSER_NAMES.safari ? `${url}-safari.json` : `${url}.json` + return fetch( + fetchUrl + ) + .then(res => res.json()) + .catch((error) => { + console.error('Failed to fetch updates:', error); + return null + } + ); +}; diff --git a/packages/extension/src/ui/action/views/updates/components/updates-header.vue b/packages/extension/src/ui/action/views/updates/components/updates-header.vue new file mode 100644 index 000000000..6ce506dfd --- /dev/null +++ b/packages/extension/src/ui/action/views/updates/components/updates-header.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/extension/src/ui/action/views/updates/components/updates-network.vue b/packages/extension/src/ui/action/views/updates/components/updates-network.vue new file mode 100644 index 000000000..2071de211 --- /dev/null +++ b/packages/extension/src/ui/action/views/updates/components/updates-network.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/packages/extension/src/ui/action/views/updates/index.vue b/packages/extension/src/ui/action/views/updates/index.vue new file mode 100644 index 000000000..872dd7205 --- /dev/null +++ b/packages/extension/src/ui/action/views/updates/index.vue @@ -0,0 +1,250 @@ + + + + +