diff --git a/background/constants/networks.ts b/background/constants/networks.ts index d28343311d..469f952049 100644 --- a/background/constants/networks.ts +++ b/background/constants/networks.ts @@ -104,7 +104,7 @@ export const ZK_SYNC: EVMNetwork = { } export const MEZO_TESTNET: EVMNetwork = { - name: "Matsnet", + name: "Mezo matsnet", baseAsset: MEZO_BTC, chainID: "31611", family: "EVM", @@ -169,7 +169,7 @@ export const NETWORK_BY_CHAIN_ID = { } export const TEST_NETWORK_BY_CHAIN_ID = new Set( - [SEPOLIA, ARBITRUM_SEPOLIA].map((network) => network.chainID), + [MEZO_TESTNET, SEPOLIA, ARBITRUM_SEPOLIA].map((network) => network.chainID), ) // Networks that are not added to this struct will diff --git a/background/lib/mezo.ts b/background/lib/mezo.ts new file mode 100644 index 0000000000..b0285189a9 --- /dev/null +++ b/background/lib/mezo.ts @@ -0,0 +1,28 @@ +import { Interface } from "ethers/lib/utils" +import { AnyEVMTransaction, sameNetwork } from "../networks" +import { MEZO_TESTNET } from "../constants" +import { sameEVMAddress } from "./utils" + +const BORROWER_CONTRACT_ADDRESS = "0x20fAeA18B6a1D0FCDBCcFfFe3d164314744baF30" + +const BorrowerABI = new Interface([ + "function openTrove(uint256 _maxFeePercentage, uint256 debtAmount, uint256 _assetAmount, address _upperHint, address _lowerHint)", +]) + +// eslint-disable-next-line import/prefer-default-export +export const checkIsBorrowingTx = (tx: AnyEVMTransaction) => { + if ( + !sameNetwork(tx.network, MEZO_TESTNET) || + !tx.blockHash || + !sameEVMAddress(tx.to, BORROWER_CONTRACT_ADDRESS) + ) { + return false + } + + try { + const data = BorrowerABI.decodeFunctionData("openTrove", tx.input ?? "") + return data.debtAmount > 0n + } catch (error) { + return false + } +} diff --git a/background/redux-slices/selectors/networks.ts b/background/redux-slices/selectors/networks.ts index 21324aa2f9..6645960b23 100644 --- a/background/redux-slices/selectors/networks.ts +++ b/background/redux-slices/selectors/networks.ts @@ -28,3 +28,11 @@ export const selectCustomNetworks = createSelector( (network) => !DEFAULT_NETWORKS_BY_CHAIN_ID.has(network.chainID), ), ) + +export const selectTestnetNetworks = createSelector( + selectEVMNetworks, + (evmNetworks) => + evmNetworks.filter((network) => + TEST_NETWORK_BY_CHAIN_ID.has(network.chainID), + ), +) diff --git a/background/redux-slices/selectors/uiSelectors.ts b/background/redux-slices/selectors/uiSelectors.ts index 736c3a5ead..79fd0b2f77 100644 --- a/background/redux-slices/selectors/uiSelectors.ts +++ b/background/redux-slices/selectors/uiSelectors.ts @@ -36,6 +36,11 @@ export const selectShowingActivityDetail = createSelector( }, ) +export const selectActiveCampaigns = createSelector( + (state: RootState) => state.ui.activeCampaigns, + (campaigns) => campaigns, +) + export const selectCurrentAddressNetwork = createSelector( (state: RootState) => state.ui.selectedAccount, (selectedAccount) => selectedAccount, diff --git a/background/redux-slices/ui.ts b/background/redux-slices/ui.ts index ec488ef3a6..f0737a9a38 100644 --- a/background/redux-slices/ui.ts +++ b/background/redux-slices/ui.ts @@ -1,7 +1,7 @@ import { createSlice, createSelector } from "@reduxjs/toolkit" import Emittery from "emittery" import { AddressOnNetwork } from "../accounts" -import { ETHEREUM } from "../constants" +import { ETHEREUM, TEST_NETWORK_BY_CHAIN_ID } from "../constants" import { AnalyticsEvent, OneTimeAnalyticsEvent } from "../lib/posthog" import { EVMNetwork } from "../networks" import { AnalyticsPreferences, DismissableItem } from "../services/preferences" @@ -11,11 +11,12 @@ import { AccountState, addAddressNetwork } from "./accounts" import { createBackgroundAsyncThunk } from "./utils" import { UNIXTime } from "../types" import { DEFAULT_AUTOLOCK_INTERVAL } from "../services/preferences/defaults" +import type { RootState } from "." export const defaultSettings = { hideDust: false, defaultWallet: false, - showTestNetworks: false, + showTestNetworks: true, showNotifications: undefined, collectAnalytics: false, showAnalyticsNotification: false, @@ -25,6 +26,13 @@ export const defaultSettings = { autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL, } +export type MezoClaimStatus = + | "not-eligible" + | "eligible" + | "claimed-sats" + | "borrowed" + | "campaign-complete" + export type UIState = { selectedAccount: AddressOnNetwork showingActivityDetailID: string | null @@ -47,6 +55,13 @@ export type UIState = { routeHistoryEntries?: Partial[] slippageTolerance: number accountSignerSettings: AccountSignerSettings[] + activeCampaigns: { + "mezo-claim"?: { + dateFrom: string + dateTo: string + state: MezoClaimStatus + } + } } export type Events = { @@ -63,6 +78,7 @@ export type Events = { updateAnalyticsPreferences: Partial addCustomNetworkResponse: [string, boolean] updateAutoLockInterval: number + toggleShowTestNetworks: boolean } export const emitter = new Emittery() @@ -78,6 +94,7 @@ export const initialState: UIState = { snackbarMessage: "", slippageTolerance: 0.01, accountSignerSettings: [], + activeCampaigns: {}, } const uiSlice = createSlice({ @@ -222,6 +239,22 @@ const uiSlice = createSlice({ ...state, settings: { ...state.settings, autoLockInterval: payload }, }), + updateCampaignState: ( + immerState: UIState, + { + payload, + }: { + payload: [T, Partial] + }, + ) => { + const [campaignId, update] = payload + + immerState.activeCampaigns ??= {} + immerState.activeCampaigns[campaignId] = { + ...immerState.activeCampaigns[campaignId], + ...update, + } + }, }, }) @@ -246,6 +279,7 @@ export const { setSlippageTolerance, setAccountsSignerSettings, setAutoLockInterval, + updateCampaignState, } = uiSlice.actions export default uiSlice.reducer @@ -360,6 +394,22 @@ export const setSelectedNetwork = createBackgroundAsyncThunk( }, ) +export const toggleShowTestNetworks = createBackgroundAsyncThunk( + "ui/toggleShowTestNetworks", + async (value: boolean, { dispatch, getState }) => { + const state = getState() as RootState + + const currentNetwork = state.ui.selectedAccount.network + + // If user is on one of the built-in test networks, don't leave them stranded + if (TEST_NETWORK_BY_CHAIN_ID.has(currentNetwork.chainID)) { + dispatch(setSelectedNetwork(ETHEREUM)) + } + + await emitter.emit("toggleShowTestNetworks", value) + }, +) + export const refreshBackgroundPage = createBackgroundAsyncThunk( "ui/refreshBackgroundPage", async () => { diff --git a/background/services/analytics/index.ts b/background/services/analytics/index.ts index 2660799695..cef7b99e73 100644 --- a/background/services/analytics/index.ts +++ b/background/services/analytics/index.ts @@ -31,6 +31,8 @@ interface Events extends ServiceLifecycleEvents { * handling sending and persistance concerns. */ export default class AnalyticsService extends BaseService { + #analyticsUUID: string | undefined = undefined + /* * Create a new AnalyticsService. The service isn't initialized until * startService() is called and resolved. @@ -93,6 +95,7 @@ export default class AnalyticsService extends BaseService { protected override async internalStartService(): Promise { await super.internalStartService() + const { uuid, isNew } = await this.getOrCreateAnalyticsUUID() let { isEnabled, hasDefaultOnBeenTurnedOn } = await this.preferenceService.getAnalyticsPreferences() @@ -114,8 +117,6 @@ export default class AnalyticsService extends BaseService { } if (isEnabled) { - const { uuid, isNew } = await this.getOrCreateAnalyticsUUID() - browser.runtime.setUninstallURL( process.env.NODE_ENV === "development" ? "about:blank" @@ -126,6 +127,8 @@ export default class AnalyticsService extends BaseService { await this.sendAnalyticsEvent(AnalyticsEvent.NEW_INSTALL) } } + + this.#analyticsUUID = uuid } protected override async internalStopService(): Promise { @@ -134,6 +137,15 @@ export default class AnalyticsService extends BaseService { await super.internalStopService() } + get analyticsUUID() { + if (!this.#analyticsUUID) { + throw new Error( + "Attempted to access analytics UUID before service started", + ) + } + return this.#analyticsUUID + } + async sendAnalyticsEvent( eventName: AnalyticsEvent, payload?: Record, diff --git a/background/services/notifications/index.ts b/background/services/notifications/index.ts index 4543b015bf..249c1f43db 100644 --- a/background/services/notifications/index.ts +++ b/background/services/notifications/index.ts @@ -68,12 +68,8 @@ export default class NotificationsService extends BaseService { // does guard this, but if that ends up not being true, browser.notifications // will be undefined and all of this will explode. - this.preferenceService.emitter.on( - "initializeNotificationsPreferences", - async (isPermissionGranted) => { - this.isPermissionGranted = isPermissionGranted - }, - ) + this.isPermissionGranted = + await this.preferenceService.getShouldShowNotificationsPreferences() this.preferenceService.emitter.on( "setNotificationsPermission", @@ -130,6 +126,7 @@ export default class NotificationsService extends BaseService { message: string contextMessage?: string type?: browser.Notifications.TemplateType + onDismiss?: () => void } callback?: () => void }) { @@ -138,10 +135,12 @@ export default class NotificationsService extends BaseService { } const notificationId = uniqueId("notification-") + const { onDismiss = () => {}, ...createNotificationOptions } = options + const notificationOptions = { type: "basic" as browser.Notifications.TemplateType, iconUrl: TAHO_ICON_URL, - ...options, + ...createNotificationOptions, } if (typeof callback === "function") { @@ -149,6 +148,12 @@ export default class NotificationsService extends BaseService { } browser.notifications.create(notificationId, notificationOptions) + + browser.notifications.onClosed.addListener((id, byUser) => { + if (id === notificationId && byUser) { + onDismiss() + } + }) } public notifyXPDrop(callback?: () => void): void { diff --git a/background/services/preferences/db.ts b/background/services/preferences/db.ts index 831b1d8a74..424c948c11 100644 --- a/background/services/preferences/db.ts +++ b/background/services/preferences/db.ts @@ -65,6 +65,7 @@ export type Preferences = { analytics: AnalyticsPreferences autoLockInterval: UNIXTime shouldShowNotifications: boolean + showTestNetworks: boolean } /** @@ -76,12 +77,17 @@ export type ManuallyDismissableItem = | "analytics-enabled-banner" | "copy-sensitive-material-warning" | "testnet-portal-is-open-banner" + /** * Items that the user will see once and will not be auto-displayed again. Can * be used for tours, or for popups that can be retriggered but will not * auto-display more than once. */ -export type SingleShotItem = "default-connection-popover" +export type SingleShotItem = + | "default-connection-popover" + | "mezo-eligible-notification" + | "mezo-borrow-notification" + | "mezo-nft-notification" /** * Items that the user will view one time and either manually dismiss or that @@ -434,6 +440,19 @@ export class PreferenceDatabase extends Dexie { }), ) + this.version(22).upgrade((tx) => + tx + .table("preferences") + .toCollection() + .modify((storedPreferences: Omit) => { + const update: Partial = { + showTestNetworks: true, + } + + Object.assign(storedPreferences, update) + }), + ) + // This is the old version for populate // https://dexie.org/docs/Dexie/Dexie.on.populate-(old-version) // The this does not behave according the new docs, but works @@ -463,6 +482,16 @@ export class PreferenceDatabase extends Dexie { }) } + async setShowTestNetworks(newValue: boolean): Promise { + await this.preferences + .toCollection() + .modify((storedPreferences: Preferences) => { + const update: Partial = { showTestNetworks: newValue } + + Object.assign(storedPreferences, update) + }) + } + async setShouldShowNotifications(newValue: boolean): Promise { await this.preferences .toCollection() diff --git a/background/services/preferences/defaults.ts b/background/services/preferences/defaults.ts index 2c5f5b4257..f57b1e7885 100644 --- a/background/services/preferences/defaults.ts +++ b/background/services/preferences/defaults.ts @@ -35,6 +35,7 @@ const defaultPreferences = { }, autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL, shouldShowNotifications: false, + showTestNetworks: true, } export default defaultPreferences diff --git a/background/services/preferences/index.ts b/background/services/preferences/index.ts index a60a1171cf..1d34cafb03 100644 --- a/background/services/preferences/index.ts +++ b/background/services/preferences/index.ts @@ -1,4 +1,3 @@ -import browser from "webextension-polyfill" import { FiatCurrency } from "../../assets" import { AddressOnNetwork, NameOnNetwork } from "../../accounts" import { ServiceLifecycleEvents, ServiceCreatorFunction } from "../types" @@ -110,6 +109,7 @@ interface Events extends ServiceLifecycleEvents { initializeSelectedAccount: AddressOnNetwork initializeShownDismissableItems: DismissableItem[] initializeNotificationsPreferences: boolean + initializeShowTestNetworks: boolean updateAnalyticsPreferences: AnalyticsPreferences addressBookEntryModified: AddressBookEntry updatedSignerSettings: AccountSignerSettings[] @@ -163,6 +163,11 @@ export default class PreferenceService extends BaseService { "initializeNotificationsPreferences", await this.getShouldShowNotificationsPreferences(), ) + + this.emitter.emit( + "initializeShowTestNetworks", + await this.getShowTestNetworks(), + ) } protected override async internalStopService(): Promise { @@ -277,18 +282,10 @@ export default class PreferenceService extends BaseService { } async setShouldShowNotifications(shouldShowNotifications: boolean) { - if (shouldShowNotifications) { - const granted = await browser.permissions.request({ - permissions: ["notifications"], - }) - - await this.db.setShouldShowNotifications(granted) - this.emitter.emit("setNotificationsPermission", granted) + await this.db.setShouldShowNotifications(shouldShowNotifications) + this.emitter.emit("setNotificationsPermission", shouldShowNotifications) - return granted - } - - return false + return shouldShowNotifications } async getAccountSignerSettings(): Promise { @@ -299,6 +296,14 @@ export default class PreferenceService extends BaseService { return (await this.db.getPreferences())?.currency } + async setShowTestNetworks(value: boolean): Promise { + await this.db.setShowTestNetworks(value) + } + + async getShowTestNetworks(): Promise { + return (await this.db.getPreferences()).showTestNetworks + } + async getTokenListPreferences(): Promise { return (await this.db.getPreferences())?.tokenLists } diff --git a/background/services/redux/index.ts b/background/services/redux/index.ts index 2eb12f6974..3c2d73cb81 100644 --- a/background/services/redux/index.ts +++ b/background/services/redux/index.ts @@ -1,7 +1,10 @@ -import { Runtime } from "webextension-polyfill" +import browser, { Runtime } from "webextension-polyfill" import { PermissionRequest } from "@tallyho/provider-bridge-shared" import { utils } from "ethers" +import isBetween from "dayjs/plugin/isBetween" +import dayjs from "dayjs" + import { isProbablyEVMAddress, normalizeEVMAddress, @@ -77,6 +80,9 @@ import { toggleNotifications, setShownDismissableItems, dismissableItemMarkedAsShown, + MezoClaimStatus, + updateCampaignState, + toggleTestNetworks, } from "../../redux-slices/ui" import { estimatedFeesPerGas, @@ -197,6 +203,10 @@ import { } from "../../redux-slices/prices" import NotificationsService from "../notifications" import { ReduxStoreType, initializeStore, readAndMigrateState } from "./store" +import { checkIsBorrowingTx } from "../../lib/mezo" +import { isDisabled } from "../../features" + +dayjs.extend(isBetween) export default class ReduxService extends BaseService { /** @@ -379,6 +389,10 @@ export default class ReduxService extends BaseService { schedule: { delayInMinutes: 1.5 }, handler: () => this.store.dispatch(initializationLoadingTimeHitLimit()), }, + checkMezoEligibility: { + schedule: { delayInMinutes: 1, periodInMinutes: 60 }, + handler: () => this.checkMezoCampaignState(), + }, }) // Start up the redux store and set it up for proxying. @@ -451,6 +465,7 @@ export default class ReduxService extends BaseService { this.connectAbilitiesService() this.connectNFTsService() this.connectNotificationsService() + this.connectMezoCampaignListeners() await this.connectChainService() @@ -1340,16 +1355,25 @@ export default class ReduxService extends BaseService { }, ) + this.preferenceService.emitter.on( + "initializeShowTestNetworks", + async (showTestNetworks: boolean) => { + await this.store.dispatch(toggleTestNetworks(showTestNetworks)) + }, + ) + + uiSliceEmitter.on("toggleShowTestNetworks", async (value) => { + await this.preferenceService.setShowTestNetworks(value) + await this.store.dispatch(toggleTestNetworks(value)) + }) + this.preferenceService.emitter.on( "initializeSelectedAccount", async (dbAddressNetwork: AddressOnNetwork) => { if (dbAddressNetwork) { // Wait until chain service starts and populates supported networks await this.chainService.started() - // TBD: naming the normal reducer and async thunks - // Initialize redux from the db - // !!! Important: this action belongs to a regular reducer. - // NOT to be confused with the setNewCurrentAddress asyncThunk + const { address, network } = dbAddressNetwork let supportedNetwork = this.chainService.supportedNetworks.find( (net) => sameNetwork(network, net), @@ -1676,11 +1700,10 @@ export default class ReduxService extends BaseService { uiSliceEmitter.on( "shouldShowNotifications", async (shouldShowNotifications: boolean) => { - const isPermissionGranted = - await this.preferenceService.setShouldShowNotifications( - shouldShowNotifications, - ) - this.store.dispatch(toggleNotifications(isPermissionGranted)) + await this.preferenceService.setShouldShowNotifications( + shouldShowNotifications, + ) + this.store.dispatch(toggleNotifications(shouldShowNotifications)) }, ) @@ -1710,6 +1733,138 @@ export default class ReduxService extends BaseService { }) } + async connectMezoCampaignListeners() { + if (isDisabled("SUPPORT_MEZO_NETWORK")) { + return + } + + this.enrichmentService.emitter.on( + "enrichedEVMTransaction", + ({ transaction }) => { + if (checkIsBorrowingTx(transaction)) { + this.store.dispatch( + updateCampaignState(["mezo-claim", { state: "campaign-complete" }]), + ) + } + }, + ) + } + + async checkMezoCampaignState() { + if (isDisabled("SUPPORT_MEZO_NETWORK")) { + return + } + + await this.started() + const accounts = await this.chainService.getAccountsToTrack() + const lastKnownState = + this.store.getState().ui.activeCampaigns?.["mezo-claim"]?.state + + if ( + !accounts.length || + lastKnownState === "campaign-complete" || + lastKnownState === "not-eligible" + ) { + return + } + + const shownItems = new Set( + await this.preferenceService.getShownDismissableItems(), + ) + + // const uuid = this.analyticsService.analyticsUUID + const hasSeenEligibilityPush = shownItems.has("mezo-eligible-notification") + const hasSeenBorrowPush = shownItems.has("mezo-borrow-notification") + const hasSeenNFTNotification = shownItems.has("mezo-nft-notification") + + // fetch with uuid + const campaignData = { + dateFrom: "2025-02-21", + dateTo: "2025-03-28", + state: "eligible" as MezoClaimStatus, + } + + const isActiveCampaign = dayjs().isBetween( + campaignData.dateFrom, + campaignData.dateTo, + "day", + "[]", + ) + + if ( + isActiveCampaign && + campaignData.state === "eligible" && + !hasSeenEligibilityPush + ) { + this.notificationsService.notify({ + options: { + title: + "Enjoy 20,000 sats on Mezo testnet. Try borrow for an exclusive Mezo NFT!", + message: "Login to Mezo to claim", + onDismiss: () => + this.preferenceService.markDismissableItemAsShown( + "mezo-eligible-notification", + ), + }, + callback: () => { + browser.tabs.create({ url: "https://mezo.org/matsnet" }) + this.preferenceService.markDismissableItemAsShown( + "mezo-eligible-notification", + ) + }, + }) + } + + if ( + isActiveCampaign && + campaignData.state === "claimed-sats" && + !hasSeenBorrowPush + ) { + this.notificationsService.notify({ + options: { + title: "Borrow mUSD with testnet sats for an exclusive Mezo NFT!", + message: "Click to borrow mUSD ", + onDismiss: () => + this.preferenceService.markDismissableItemAsShown( + "mezo-borrow-notification", + ), + }, + callback: () => { + browser.tabs.create({ url: "https://mezo.org/matsnet/borrow" }) + this.preferenceService.markDismissableItemAsShown( + "mezo-borrow-notification", + ) + }, + }) + } + + if ( + isActiveCampaign && + campaignData.state === "borrowed" && + !hasSeenNFTNotification + ) { + this.notificationsService.notify({ + options: { + title: + "Spend testnet mUSD in the Mezo store for an exclusive Mezo NFT!", + message: "Click to visit the Mezo Store", + onDismiss: () => + this.preferenceService.markDismissableItemAsShown( + "mezo-borrow-notification", + ), + }, + callback: () => { + browser.tabs.create({ url: "https://mezo.org/matsnet/borrow" }) + this.preferenceService.markDismissableItemAsShown( + "mezo-borrow-notification", + ) + }, + }) + } + + this.store.dispatch(updateCampaignState(["mezo-claim", campaignData])) + } + async updateAssetMetadata( asset: SmartContractFungibleAsset, metadata: AnyAssetMetadata, diff --git a/e2e-tests/regular/onboarding.spec.ts b/e2e-tests/regular/onboarding.spec.ts index 4ef5565b74..9b607f43f2 100644 --- a/e2e-tests/regular/onboarding.spec.ts +++ b/e2e-tests/regular/onboarding.spec.ts @@ -20,6 +20,8 @@ test.describe("Onboarding", () => { ).toHaveText(readOnlyAddress) }).toPass() + await expect(popup.getByTestId("wallet_balance")).toBeVisible() + expect(popup.getByTestId("wallet_balance").innerText()).not.toContain("$0") }) diff --git a/ui/_locales/en/messages.json b/ui/_locales/en/messages.json index 986729087a..fabf4eab24 100644 --- a/ui/_locales/en/messages.json +++ b/ui/_locales/en/messages.json @@ -550,7 +550,8 @@ "l2": "L2 scaling solution", "compatibleChain": "EVM-compatible blockchain", "avalanche": "Mainnet C-Chain", - "connected": "Connected" + "connected": "Connected", + "featuredNetwork": "new" }, "readOnly": "Read-only", "readOnlyNotice": "Read-only mode", diff --git a/ui/components/TopMenu/TopMenuProtocolList.tsx b/ui/components/TopMenu/TopMenuProtocolList.tsx index 93ef7e92bb..4afa174f96 100644 --- a/ui/components/TopMenu/TopMenuProtocolList.tsx +++ b/ui/components/TopMenu/TopMenuProtocolList.tsx @@ -16,7 +16,10 @@ import { import { EVMNetwork, sameNetwork } from "@tallyho/tally-background/networks" import { selectCurrentNetwork } from "@tallyho/tally-background/redux-slices/selectors" import { selectShowTestNetworks } from "@tallyho/tally-background/redux-slices/ui" -import { selectProductionEVMNetworks } from "@tallyho/tally-background/redux-slices/selectors/networks" +import { + selectProductionEVMNetworks, + selectTestnetNetworks, +} from "@tallyho/tally-background/redux-slices/selectors/networks" import { useTranslation } from "react-i18next" import { useBackgroundSelector } from "../../hooks" import TopMenuProtocolListItem from "./TopMenuProtocolListItem" @@ -25,7 +28,6 @@ import { i18n } from "../../_locales/i18n" export const productionNetworkInfo = { [ETHEREUM.chainID]: i18n.t("protocol.mainnet"), - [MEZO_TESTNET.chainID]: i18n.t("protocol.mezoTestnet"), [POLYGON.chainID]: i18n.t("protocol.l2"), [OPTIMISM.chainID]: i18n.t("protocol.l2"), [ARBITRUM_ONE.chainID]: i18n.t("protocol.l2"), @@ -37,35 +39,26 @@ export const productionNetworkInfo = { const disabledChainIDs = [ARBITRUM_NOVA.chainID] -const testNetworks = [ - { - network: SEPOLIA, - info: i18n.t("protocol.testnet"), - isDisabled: false, - }, - { - network: ARBITRUM_SEPOLIA, - info: i18n.t("protocol.testnet"), - isDisabled: false, - }, -] +const testNetworkInfo = { + [MEZO_TESTNET.chainID]: i18n.t("protocol.mezoTestnet"), + [SEPOLIA.chainID]: i18n.t("protocol.testnet"), + [ARBITRUM_SEPOLIA.chainID]: i18n.t("protocol.testnet"), +} type TopMenuProtocolListProps = { onProtocolChange: (network: EVMNetwork) => void } /** - * Places Ethereum and Mezo network above other networks + * Places Mezo network above other networks */ const sortByNetworkPriority = (a: EVMNetwork, b: EVMNetwork) => { const getPriority = (network: EVMNetwork) => { switch (true) { - case sameNetwork(ETHEREUM, network): - return 0 case sameNetwork(MEZO_TESTNET, network): - return 1 + return 0 default: - return 2 + return 1 } } return getPriority(a) - getPriority(b) @@ -78,15 +71,16 @@ export default function TopMenuProtocolList({ const currentNetwork = useBackgroundSelector(selectCurrentNetwork) const showTestNetworks = useBackgroundSelector(selectShowTestNetworks) const productionNetworks = useBackgroundSelector(selectProductionEVMNetworks) + const testnetNetworks = useBackgroundSelector(selectTestnetNetworks) - const builtinNetworks = productionNetworks - .filter(isBuiltInNetwork) - .sort(sortByNetworkPriority) + const builtinNetworks = productionNetworks.filter(isBuiltInNetwork) const customNetworks = productionNetworks.filter( (network) => !isBuiltInNetwork(network), ) + const testNetworks = testnetNetworks.sort(sortByNetworkPriority) + return (
@@ -131,14 +125,13 @@ export default function TopMenuProtocolList({
- {testNetworks.map((info) => ( + {testNetworks.map((network) => ( ))} diff --git a/ui/components/TopMenu/TopMenuProtocolListItem.tsx b/ui/components/TopMenu/TopMenuProtocolListItem.tsx index ca56775541..f69af24c13 100644 --- a/ui/components/TopMenu/TopMenuProtocolListItem.tsx +++ b/ui/components/TopMenu/TopMenuProtocolListItem.tsx @@ -1,7 +1,8 @@ import React, { ReactElement } from "react" import { useTranslation } from "react-i18next" import classNames from "classnames" -import { EVMNetwork } from "@tallyho/tally-background/networks" +import { EVMNetwork, sameNetwork } from "@tallyho/tally-background/networks" +import { MEZO_TESTNET } from "@tallyho/tally-background/constants" import SharedNetworkIcon from "../Shared/SharedNetworkIcon" type Props = { @@ -13,6 +14,13 @@ type Props = { showSelectedText?: boolean } +const isFeaturedNetwork = (network: EVMNetwork) => { + if (sameNetwork(network, MEZO_TESTNET)) { + return Date.now() < new Date("2025-04-10").getTime() + } + return false +} + export default function TopMenuProtocolListItem(props: Props): ReactElement { const { t } = useTranslation() const { @@ -39,7 +47,12 @@ export default function TopMenuProtocolListItem(props: Props): ReactElement {
-
{network.name}
+
+ {network.name} + {isFeaturedNetwork(network) && ( + {t("protocol.featuredNetwork")} + )} +
{info} {isSelected && showSelectedText && ( @@ -49,6 +62,25 @@ export default function TopMenuProtocolListItem(props: Props): ReactElement {
+ + ) +} diff --git a/ui/pages/Settings.tsx b/ui/pages/Settings.tsx index 3b3299b947..b3dfcf73bd 100644 --- a/ui/pages/Settings.tsx +++ b/ui/pages/Settings.tsx @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill" import React, { ReactElement, useEffect, useState } from "react" import { useDispatch, useSelector } from "react-redux" import { Trans, useTranslation } from "react-i18next" @@ -7,8 +8,8 @@ import { selectShowNotifications, setShouldShowNotifications, selectShowTestNetworks, - toggleTestNetworks, toggleHideBanners, + toggleShowTestNetworks, selectHideBanners, selectShowUnverifiedAssets, toggleShowUnverifiedAssets, @@ -176,11 +177,17 @@ export default function Settings(): ReactElement { } const toggleNotifications = (toggleValue: boolean) => { - dispatch(setShouldShowNotifications(toggleValue)) + browser.permissions + .request({ + permissions: ["notifications"], + }) + .then((hasPermission) => + dispatch(setShouldShowNotifications(hasPermission && toggleValue)), + ) } - const toggleShowTestNetworks = (defaultWalletValue: boolean) => { - dispatch(toggleTestNetworks(defaultWalletValue)) + const toggleShowTestnets = (defaultWalletValue: boolean) => { + dispatch(toggleShowTestNetworks(defaultWalletValue)) } const toggleShowUnverified = (toggleValue: boolean) => { @@ -236,7 +243,7 @@ export default function Settings(): ReactElement { title: t("settings.enableTestNetworks"), component: () => ( toggleShowTestNetworks(toggleValue)} + onChange={(toggleValue) => toggleShowTestnets(toggleValue)} value={showTestNetworks} /> ), diff --git a/ui/pages/Wallet.tsx b/ui/pages/Wallet.tsx index f4eea1055d..ce110997ec 100644 --- a/ui/pages/Wallet.tsx +++ b/ui/pages/Wallet.tsx @@ -1,5 +1,9 @@ import React, { ReactElement, useEffect, useMemo, useState } from "react" +import dayjs from "dayjs" +import isBetween from "dayjs/plugin/isBetween" + import { + selectActiveCampaigns, selectCurrentAccountActivities, selectCurrentAccountBalances, selectCurrentNetwork, @@ -28,6 +32,9 @@ import SharedButton from "../components/Shared/SharedButton" import SharedIcon from "../components/Shared/SharedIcon" import PortalBanner from "../components/Wallet/Banner/PortalBanner" import WalletSubspaceLink from "../components/Wallet/WalletSubscapeLink" +import MezoWalletCampaignBanner from "../components/Wallet/Banner/WalletCampaignBanner" + +dayjs.extend(isBetween) export default function Wallet(): ReactElement { const { t } = useTranslation() @@ -41,6 +48,7 @@ export default function Wallet(): ReactElement { const claimState = useBackgroundSelector((state) => state.claim) const selectedNetwork = useBackgroundSelector(selectCurrentNetwork) const showUnverifiedAssets = useBackgroundSelector(selectShowUnverifiedAssets) + const activeCampaigns = useBackgroundSelector(selectActiveCampaigns) useEffect(() => { dispatch( @@ -99,6 +107,20 @@ export default function Wallet(): ReactElement { panelNames.push(t("wallet.pages.activity")) + const renderMezoCampaignBanner = () => { + const campaign = activeCampaigns?.["mezo-claim"] + if ( + !campaign || + campaign.state === "not-eligible" || + campaign.state === "campaign-complete" || + !dayjs().isBetween(campaign.dateFrom, campaign.dateTo, "day", "[]") + ) { + return null + } + + return + } + return ( <>
@@ -116,6 +138,8 @@ export default function Wallet(): ReactElement { {isEnabled(FeatureFlags.SHOW_TOKEN_FEATURES) && ( )} + {isEnabled(FeatureFlags.SUPPORT_MEZO_NETWORK) && + renderMezoCampaignBanner()} {isEnabled(FeatureFlags.SHOW_ISLAND_UI) && }
+ + + + + + + + + + + + + + + diff --git a/ui/public/images/mezo-1.png b/ui/public/images/mezo-1.png new file mode 100644 index 0000000000..3db1e96101 Binary files /dev/null and b/ui/public/images/mezo-1.png differ diff --git a/ui/public/images/mezo-2.png b/ui/public/images/mezo-2.png new file mode 100644 index 0000000000..e961fca227 Binary files /dev/null and b/ui/public/images/mezo-2.png differ diff --git a/ui/public/images/mezo-3.png b/ui/public/images/mezo-3.png new file mode 100644 index 0000000000..69c8199c5e Binary files /dev/null and b/ui/public/images/mezo-3.png differ diff --git a/ui/public/images/networks/matsnet-square@2x.png b/ui/public/images/networks/mezomatsnet-square@2x.png similarity index 100% rename from ui/public/images/networks/matsnet-square@2x.png rename to ui/public/images/networks/mezomatsnet-square@2x.png diff --git a/ui/public/images/networks/matsnet@2x.png b/ui/public/images/networks/mezomatsnet@2x.png similarity index 100% rename from ui/public/images/networks/matsnet@2x.png rename to ui/public/images/networks/mezomatsnet@2x.png