diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 9ac546bf10..33d0b7c611 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2587,10 +2587,10 @@ export const getTokenIds = async ({ return tokenIdList; }; -export const getUsdt0LaunchBannerDismissed = async (): Promise => { +export const getEthenaLaunchBannerDismissed = async (): Promise => { const { isDismissed, error } = await sendMessageToBackground({ activePublicKey: null, - type: SERVICE_TYPES.GET_USDT0_LAUNCH_BANNER_DISMISSED, + type: SERVICE_TYPES.GET_ETHENA_LAUNCH_BANNER_DISMISSED, }); if (error) { @@ -2600,12 +2600,12 @@ export const getUsdt0LaunchBannerDismissed = async (): Promise => { return !!isDismissed; }; -export const dismissUsdt0LaunchBanner = async (): Promise<{ +export const dismissEthenaLaunchBanner = async (): Promise<{ isDismissed: boolean; }> => { const { isDismissed, error } = await sendMessageToBackground({ activePublicKey: null, - type: SERVICE_TYPES.DISMISS_USDT0_LAUNCH_BANNER, + type: SERVICE_TYPES.DISMISS_ETHENA_LAUNCH_BANNER, }); if (error) { @@ -3186,6 +3186,21 @@ export const claimRebrandAnnouncement = async (): Promise => { return Boolean(shouldShowRebrandAnnouncement); }; +// True once per eligible wallet, like the rebrand announcement above: the +// background flips pending to seen as it answers. +export const claimEthenaLaunchPromo = async (): Promise => { + const { shouldShowEthenaLaunchPromo, error } = await sendMessageToBackground({ + activePublicKey: null, + type: SERVICE_TYPES.CLAIM_ETHENA_LAUNCH_PROMO, + }); + + if (error) { + throw new Error(error); + } + + return Boolean(shouldShowEthenaLaunchPromo); +}; + export const getCachedSwapTopTokens = async ( network: string, ): Promise<{ tokens: TrendingAsset[]; updatedAt: number } | null> => { diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 206b06d1bb..6f68399a7b 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -436,12 +436,12 @@ export interface GetHiddenAssetsMessage extends BaseMessage { network: string; } -export interface GetUsdt0LaunchBannerDismissedMessage extends BaseMessage { - type: SERVICE_TYPES.GET_USDT0_LAUNCH_BANNER_DISMISSED; +export interface GetEthenaLaunchBannerDismissedMessage extends BaseMessage { + type: SERVICE_TYPES.GET_ETHENA_LAUNCH_BANNER_DISMISSED; } -export interface DismissUsdt0LaunchBannerMessage extends BaseMessage { - type: SERVICE_TYPES.DISMISS_USDT0_LAUNCH_BANNER; +export interface DismissEthenaLaunchBannerMessage extends BaseMessage { + type: SERVICE_TYPES.DISMISS_ETHENA_LAUNCH_BANNER; } export interface GetRecentProtocolsMessage extends BaseMessage { @@ -469,6 +469,10 @@ export interface ClaimRebrandAnnouncementMessage extends BaseMessage { type: SERVICE_TYPES.CLAIM_REBRAND_ANNOUNCEMENT; } +export interface ClaimEthenaLaunchPromoMessage extends BaseMessage { + type: SERVICE_TYPES.CLAIM_ETHENA_LAUNCH_PROMO; +} + export interface GetBlockaidDebugOverrideMessage extends BaseMessage { type: SERVICE_TYPES.GET_BLOCKAID_DEBUG_OVERRIDE; } @@ -590,14 +594,15 @@ export type ServiceMessageRequest = | GetIsAccountMismatchMessage | ChangeAssetVisibilityMessage | GetHiddenAssetsMessage - | GetUsdt0LaunchBannerDismissedMessage - | DismissUsdt0LaunchBannerMessage + | GetEthenaLaunchBannerDismissedMessage + | DismissEthenaLaunchBannerMessage | GetRecentProtocolsMessage | AddRecentProtocolMessage | ClearRecentProtocolsMessage | GetDiscoverWelcomeSeenMessage | DismissDiscoverWelcomeMessage | ClaimRebrandAnnouncementMessage + | ClaimEthenaLaunchPromoMessage | GetBlockaidDebugOverrideMessage | AddCollectibleMessage | GetCollectiblesMessage diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index ad2d12343c..d8eebd26c9 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -143,6 +143,7 @@ export interface Response { recentProtocols: RecentProtocolEntry[]; hasSeenDiscoverWelcome: boolean; shouldShowRebrandAnnouncement: boolean; + shouldShowEthenaLaunchPromo: boolean; cachedSwapTopTokens: { tokens: TrendingAsset[]; updatedAt: number } | null; } diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 07edb2c45c..4b39836da9 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -55,8 +55,8 @@ export enum SERVICE_TYPES { CHANGE_ASSET_VISIBILITY = "CHANGE_ASSET_VISIBILITY", GET_HIDDEN_ASSETS = "GET_HIDDEN_ASSETS", GET_IS_ACCOUNT_MISMATCH = "GET_IS_ACCOUNT_MISMATCH", - GET_USDT0_LAUNCH_BANNER_DISMISSED = "GET_USDT0_LAUNCH_BANNER_DISMISSED", - DISMISS_USDT0_LAUNCH_BANNER = "DISMISS_USDT0_LAUNCH_BANNER", + GET_ETHENA_LAUNCH_BANNER_DISMISSED = "GET_ETHENA_LAUNCH_BANNER_DISMISSED", + DISMISS_ETHENA_LAUNCH_BANNER = "DISMISS_ETHENA_LAUNCH_BANNER", GET_BLOCKAID_DEBUG_OVERRIDE = "GET_BLOCKAID_DEBUG_OVERRIDE", SAVE_BLOCKAID_DEBUG_OVERRIDE = "SAVE_BLOCKAID_DEBUG_OVERRIDE", ADD_COLLECTIBLE = "ADD_COLLECTIBLE", @@ -72,6 +72,7 @@ export enum SERVICE_TYPES { GET_DISCOVER_WELCOME_SEEN = "GET_DISCOVER_WELCOME_SEEN", DISMISS_DISCOVER_WELCOME = "DISMISS_DISCOVER_WELCOME", CLAIM_REBRAND_ANNOUNCEMENT = "CLAIM_REBRAND_ANNOUNCEMENT", + CLAIM_ETHENA_LAUNCH_PROMO = "CLAIM_ETHENA_LAUNCH_PROMO", GET_CACHED_SWAP_TOP_TOKENS = "GET_CACHED_SWAP_TOP_TOKENS", CACHE_SWAP_TOP_TOKENS = "CACHE_SWAP_TOP_TOKENS", USER_ACTIVITY = "USER_ACTIVITY", diff --git a/extension/e2e-tests/ethenaLaunch.test.ts b/extension/e2e-tests/ethenaLaunch.test.ts new file mode 100644 index 0000000000..b080cb64cd --- /dev/null +++ b/extension/e2e-tests/ethenaLaunch.test.ts @@ -0,0 +1,281 @@ +import type { BrowserContext, Page } from "@playwright/test"; +import { test, expect } from "./test-fixtures"; +import { loginToTestAccount, PASSWORD } from "./helpers/login"; +import { goToSettings } from "./helpers/network"; + +const lockAndUnlock = async (page: Page) => { + await goToSettings(page); + await page.getByText("Log Out").click(); + await expect(page.getByText("Welcome back")).toBeVisible(); + await page.locator("#password-input").fill(PASSWORD); + await page.getByRole("button", { name: "Unlock" }).click(); +}; + +test("a wallet set up on this install skips the Ethena launch promo, which Home's banner still opens", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + // Straight Home: only wallets that predate the launch get it by themselves. + await lockAndUnlock(page); + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByTestId("ethena-launch")).toHaveCount(0); + + await page.getByTestId("ethena-launch-banner-open").click(); + await expect(page.getByTestId("ethena-launch")).toBeVisible(); +}); + +test("a wallet that predates the launch sees the Ethena launch promo once, on its next unlock to Home", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + // What installing the launch build over an existing wallet records. + await page.evaluate(() => + chrome.storage.local.set({ ethenaLaunchPromoStatus: "pending" }), + ); + + await lockAndUnlock(page); + await expect(page.getByTestId("ethena-launch")).toBeVisible({ + timeout: 30000, + }); + await page.getByTestId("ethena-launch-close").click(); + await expect(page.getByTestId("account-view")).toBeVisible(); + + // Once only: the next unlock goes straight Home. + await lockAndUnlock(page); + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByTestId("ethena-launch")).toHaveCount(0); +}); + +test("Ethena launch promo sits in Home's frame in a wide window, filling it", async ({ + page, + extensionId, + context, +}) => { + // The fixture's window is wide, like fullscreen mode's tab. + await loginToTestAccount({ page, extensionId, context }); + const frameOf = (selector: string) => + page.locator(selector).evaluate((el) => { + const { x, width, height } = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return { + x, + width, + height, + borders: `${style.borderLeft} | ${style.borderRight}`, + }; + }); + const home = await frameOf(".AccountHeader__hero"); + expect(home.width).toBeLessThan(page.viewportSize()!.width); + + await page.getByTestId("ethena-launch-banner-open").click(); + await expect(page.getByTestId("ethena-launch")).toBeVisible(); + const promo = await frameOf('[data-testid="ethena-launch-frame"]'); + + // Same place, width and borders as Home's, all the way down. + expect({ ...promo, height: undefined }).toEqual({ + ...home, + height: undefined, + }); + expect(promo.height).toBe(page.viewportSize()!.height); + // The content stretches across it: Swap spans the frame less its borders + // and 24px gutters, as other fullscreen screens' buttons do. + const swap = await page.getByTestId("ethena-launch-swap-usde").boundingBox(); + expect(swap!.width).toBe(promo.width - 2 - 2 * 24); +}); + +test("Ethena launch promo's token actions are all the same size", async ({ + page, + extensionId, + context, +}) => { + await loginToTestAccount({ page, extensionId, context }); + await page.getByTestId("ethena-launch-banner-open").click(); + await expect(page.getByTestId("ethena-launch")).toBeVisible(); + + const size = (testId: string) => + page.getByTestId(testId).evaluate((el) => { + const icon = el.querySelector("svg")?.getBoundingClientRect(); + return { + height: el.getBoundingClientRect().height, + icon: icon ? icon.width : null, + }; + }); + for (const token of ["usde", "susde"]) { + const swap = await size(`ethena-launch-swap-${token}`); + const receive = await size(`ethena-launch-receive-${token}`); + const transfer = await size(`ethena-launch-transfer-${token}`); + // SDS's lg Button, which Swap and Receive are. + expect(swap.height).toBe(40); + expect(receive.height).toBe(swap.height); + expect(transfer).toEqual(swap); + } +}); + +test("Ethena launch promo's scroll cue scrolls the tokens into view", async ({ + page, + extensionId, + context, +}) => { + await loginToTestAccount({ page, extensionId, context }); + await page.getByTestId("ethena-launch-banner-open").click(); + const bar = page.getByTestId("ethena-launch-header"); + const cue = page.getByRole("button", { name: "Scroll to discover tokens" }); + await expect(cue).toBeVisible(); + + await cue.click(); + + // It stops with the cue 20px below the close button's bar, the tokens + // under it. + const gap = async () => { + const [barBox, cueBox] = await Promise.all([ + bar.boundingBox(), + cue.boundingBox(), + ]); + return Math.round(cueBox!.y - (barBox!.y + barBox!.height)); + }; + await expect.poll(gap).toBe(20); + await expect(page.getByTestId("ethena-launch-swap-usde")).toBeInViewport(); +}); + +test("Ethena launch promo comes back from Receive as it was left", async ({ + page, + extensionId, + context, +}) => { + await loginToTestAccount({ page, extensionId, context }); + await page.getByTestId("ethena-launch-banner-open").click(); + const promo = page.getByTestId("ethena-launch"); + await expect(promo).toBeVisible(); + await promo.evaluate((el) => el.scrollTo(0, 400)); + const left = await promo.evaluate((el) => el.scrollTop); + expect(left).toBeGreaterThan(0); + + await page.getByTestId("ethena-launch-receive-usde").click(); + await expect(page.getByTestId("view-public-key-account-name")).toBeVisible(); + await page.getByTestId("BackButton").click(); + + // Where it was, with nothing replaying. + await expect(promo).toBeVisible(); + expect(await promo.evaluate((el) => el.scrollTop)).toBe(left); + expect( + await promo.evaluate((el) => el.getAnimations({ subtree: true }).length), + ).toBe(0); +}); + +test("Esc closes the address screen, then the Ethena launch promo", async ({ + page, + extensionId, + context, +}) => { + await loginToTestAccount({ page, extensionId, context }); + await page.getByTestId("ethena-launch-banner-open").click(); + await expect(page.getByTestId("ethena-launch")).toBeVisible(); + + // The address screen: Esc goes back to the promo, like its X. + await page.getByTestId("ethena-launch-receive-usde").click(); + await expect(page.getByTestId("view-public-key-account-name")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("ethena-launch")).toBeVisible(); + + // The promo: Esc goes Home, like its X. + await page.keyboard.press("Escape"); + await expect(page.getByTestId("account-view")).toBeVisible(); +}); + +test("Ethena launch promo keeps its close button on a bar as the page scrolls", async ({ + page, + extensionId, + context, +}) => { + await loginToTestAccount({ page, extensionId, context }); + await page.getByTestId("ethena-launch-banner-open").click(); + const promo = page.getByTestId("ethena-launch"); + const close = page.getByTestId("ethena-launch-close"); + await expect(promo).toBeVisible(); + const atTop = await close.boundingBox(); + const barOpacity = () => + page + .getByTestId("ethena-launch-header") + .evaluate((el) => getComputedStyle(el, "::before").opacity); + expect(await barOpacity()).toBe("0"); + + // Down to the tokens: the X stays put, now on the bar, which the tokens + // fade out under. + await promo.evaluate((el) => el.scrollTo(0, el.scrollHeight)); + await expect(page.getByTestId("ethena-launch-learn-more")).toBeInViewport(); + expect(await close.boundingBox()).toEqual(atTop); + await expect.poll(barOpacity).toBe("1"); + + // Back at the top, the bar clears so the hero shows as designed. + await promo.evaluate((el) => el.scrollTo(0, 0)); + await expect.poll(barOpacity).toBe("0"); +}); + +// How much of a screenshot is drawn in Chrome's focus ring blue for a dark +// color scheme (#99c8ff), which the banner pins in both themes. Decoded on a +// blank page, outside the extension's CSP. +const focusRingPixels = async (context: BrowserContext, png: Buffer) => { + const blank = await context.newPage(); + const result = await blank.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = new OffscreenCanvas(image.width, image.height); + const context2d = canvas.getContext("2d")!; + context2d.drawImage(image, 0, 0); + const { data } = context2d.getImageData(0, 0, image.width, image.height); + let pixels = 0; + for (let i = 0; i < data.length; i += 4) { + const distance = + Math.abs(data[i] - 0x99) + + Math.abs(data[i + 1] - 0xc8) + + Math.abs(data[i + 2] - 0xff); + if (distance < 48) { + pixels += 1; + } + } + return { pixels, perimeter: 2 * (image.width + image.height) }; + }, png.toString("base64")); + await blank.close(); + return result; +}; + +for (const colorScheme of ["dark", "light"] as const) { + test(`Ethena launch banner shows keyboard focus on its main button in ${colorScheme} mode`, async ({ + page, + extensionId, + context, + }) => { + await page.emulateMedia({ colorScheme }); + await loginToTestAccount({ page, extensionId, context }); + const banner = page.getByTestId("ethena-launch-banner"); + await expect(banner).toBeVisible(); + // Off the banner, so the shot isn't of its hover state. + await page.mouse.move(0, 0); + + // Back from the dismiss button, as a keyboard user reaches it. + await page.getByTestId("ethena-launch-banner-dismiss").focus(); + await page.keyboard.press("Shift+Tab"); + await expect(page.getByTestId("ethena-launch-banner-open")).toBeFocused(); + + // The button fills the card, which clips anything drawn outside it, and + // dark mode's outline covers the card's edge. The ring only shows all the + // way round if it's drawn inside both. + const { pixels, perimeter } = await focusRingPixels( + context, + await banner.screenshot(), + ); + expect(pixels).toBeGreaterThan(perimeter); + }); +} diff --git a/extension/e2e-tests/theme.test.ts b/extension/e2e-tests/theme.test.ts index 1c4d617930..5e45381dc7 100644 --- a/extension/e2e-tests/theme.test.ts +++ b/extension/e2e-tests/theme.test.ts @@ -48,6 +48,11 @@ const backdrop = (locator: Locator) => return ""; }); +const computed = ( + locator: Locator, + property: "color" | "backgroundColor" | "fill" | "fontWeight", +) => locator.evaluate((el, prop) => getComputedStyle(el)[prop], property); + // Varial pages sit on base-00; sheets floating over them stay on gray-01. const BASE_00 = { light: "rgb(255, 255, 255)", dark: "rgb(0, 0, 0)" }; const GRAY_01 = { light: "rgb(252, 252, 252)", dark: "rgb(22, 22, 22)" }; @@ -81,39 +86,145 @@ test("welcome screen follows the color scheme", async ({ expect(await backdrop(body)).toBe(BASE_00.dark); }); -test("USDT0 sheet stays dark in light theme", async ({ +// The Ethena launch is one design in both themes, but for the banner's outline +// in dark mode. These compare whole renders across a live theme change rather +// than spot-checking a few colors, so any property that follows the theme +// shows up, named. + +// Waits out entrance animations and transitions, so a render is of the +// settled UI rather than a frame of its motion. +const settle = (locator: Locator) => + locator.evaluate(async (root) => { + await Promise.all( + root.getAnimations({ subtree: true }).map(({ finished }) => finished), + ); + }); + +// Every computed property of every element under the locator, in document +// order. Custom properties are left out: they're the theme's inputs, and one +// changes what's drawn only through the properties that use it, which are +// compared themselves. +const renderedStyles = (locator: Locator) => + locator.evaluate((root) => + [root, ...Array.from(root.querySelectorAll("*"))].map((el) => { + const style = getComputedStyle(el); + const properties: Record = {}; + for (let i = 0; i < style.length; i += 1) { + const name = style.item(i); + if (!name.startsWith("--")) { + properties[name] = style.getPropertyValue(name); + } + } + return { + element: `${el.tagName.toLowerCase()}.${el.getAttribute("class") ?? ""}`, + properties, + }; + }), + ); + +type RenderedStyles = Awaited>; + +// What changed between two renders of the same elements, one line per +// property, so a failure says exactly what the theme flipped. +const styleChanges = (before: RenderedStyles, after: RenderedStyles) => [ + ...(after.length === before.length + ? [] + : [`${before.length} elements → ${after.length}`]), + ...before.flatMap(({ element, properties }, index) => + Object.entries(properties) + .filter(([name, value]) => after[index]?.properties[name] !== value) + .map( + ([name, value]) => + `${element} ${name}: ${value} → ${after[index]?.properties[name]}`, + ), + ), +]; + +test("Ethena launch banner only adds an outline in dark mode", async ({ + page, + extensionId, + context, +}) => { + await page.emulateMedia({ colorScheme: "light" }); + await loginToTestAccount({ page, extensionId, context }); + const banner = page.getByTestId("ethena-launch-banner"); + await expect(banner).toBeVisible(); + // Off the banner, so neither render is of its hover state. + await page.mouse.move(0, 0); + await settle(banner); + const light = await renderedStyles(banner); + + await page.emulateMedia({ colorScheme: "dark" }); + await expect(page.locator("body")).toHaveClass(/sds-theme-dark/); + await settle(banner); + // Dark mode outlines the card in gray-04, so it stands out on the black + // page (Figma 4414:59535). Drawn inside the card, it moves nothing, and + // nothing else follows the theme. (Light mode's 3px is the initial width, + // which Chrome reports while the style is none; nothing is drawn.) + expect(styleChanges(light, await renderedStyles(banner))).toEqual([ + "div.EthenaLaunchBanner outline-color: rgb(255, 255, 255) → rgb(40, 40, 40)", + "div.EthenaLaunchBanner outline-offset: 0px → -1px", + "div.EthenaLaunchBanner outline-style: none → solid", + "div.EthenaLaunchBanner outline-width: 3px → 1px", + ]); + + // Otherwise it's Figma's banner (4414:42195): the near-black card, white + // title, light-gray subtitle and gray X. + expect({ + card: await computed(banner, "backgroundColor"), + title: await computed(banner.getByText("Meet Ethena on Stellar"), "color"), + subtitle: await computed( + banner.getByText("USDe and sUSDe are now available."), + "color", + ), + dismiss: await computed( + page.getByTestId("ethena-launch-banner-dismiss"), + "color", + ), + }).toEqual({ + card: "rgb(7, 9, 8)", + title: "rgb(255, 255, 255)", + subtitle: "rgb(199, 199, 199)", + dismiss: "rgb(133, 133, 133)", + }); +}); + +test("Ethena launch promo looks the same in both themes", async ({ page, extensionId, context, }) => { await page.emulateMedia({ colorScheme: "light" }); await loginToTestAccount({ page, extensionId, context }); - await page.getByTestId("usdt0-launch-banner-open").click(); - await expect(page.getByTestId("usdt0-launch-sheet")).toBeVisible(); + await page.getByTestId("ethena-launch-banner-open").click(); + const promo = page.getByTestId("ethena-launch"); + await expect(promo).toBeVisible(); + await settle(promo); - // Its hero art is fixed-dark, so the text over it must resolve dark-theme - // tokens even though the page is light. - const colorOf = (el: Element) => getComputedStyle(el).color; + // Its hero art is drawn for a black page, so the page and the text over it + // must resolve dark-theme tokens even though the app is light. const darkGray12 = "rgb(237, 237, 237)"; + expect(await backdrop(promo)).toBe(BASE_00.dark); expect( - await page - .locator(".Usdt0LaunchSheet__feature__title") - .first() - .evaluate(colorOf), - ).toBe(darkGray12); - expect( - await page.getByTestId("usdt0-launch-sheet-close").evaluate(colorOf), + await computed( + page.getByRole("heading", { name: "Meet Ethena on Stellar" }), + "color", + ), ).toBe(darkGray12); + expect(await computed(page.getByTestId("ethena-launch-close"), "color")).toBe( + darkGray12, + ); + const light = await renderedStyles(promo); + + await page.emulateMedia({ colorScheme: "dark" }); + await expect(page.locator("body")).toHaveClass(/sds-theme-dark/); + await settle(promo); + expect(styleChanges(light, await renderedStyles(promo))).toEqual([]); }); // The lock screen and its Forgot password sheet resolve their colors in both // themes. Expected values are the resolved tokens: gray, red and base from // SDS's .sds-theme-* blocks, lilac from varialPalette.scss. -const computed = ( - locator: Locator, - property: "color" | "backgroundColor" | "fill" | "fontWeight", -) => locator.evaluate((el, prop) => getComputedStyle(el)[prop], property); - const lockScreenColors = async (page: Page) => ({ heading: await computed( page.getByRole("heading", { name: "Welcome back" }), diff --git a/extension/src/background/helpers/__tests__/migrations.test.ts b/extension/src/background/helpers/__tests__/migrations.test.ts index b251acf527..55c9cf6949 100644 --- a/extension/src/background/helpers/__tests__/migrations.test.ts +++ b/extension/src/background/helpers/__tests__/migrations.test.ts @@ -14,10 +14,12 @@ import { STORAGE_VERSION, KEY_ID_LIST, REBRAND_ANNOUNCEMENT_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS_ID, TOKEN_ID_LIST, } from "constants/localStorageTypes"; import * as DataStorage from "../dataStorage"; import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; import * as DataStorageAccess from "../dataStorageAccess"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; @@ -261,4 +263,39 @@ describe("Storage migrations", () => { ); }); }); + describe("markEthenaLaunchPromoEligibility", () => { + it("marks a wallet that exists when the launch build arrives as pending", async () => { + await mockStorage.set({ [KEY_ID_LIST]: ["key-1"] }); + + await DataStorage.markEthenaLaunchPromoEligibility(); + + const stored = await mockStorage.get(ETHENA_LAUNCH_PROMO_STATUS_ID); + expect(stored[ETHENA_LAUNCH_PROMO_STATUS_ID]).toEqual( + ETHENA_LAUNCH_PROMO_STATUS.PENDING, + ); + }); + + it("marks a fresh install as seen", async () => { + await DataStorage.markEthenaLaunchPromoEligibility(); + + const stored = await mockStorage.get(ETHENA_LAUNCH_PROMO_STATUS_ID); + expect(stored[ETHENA_LAUNCH_PROMO_STATUS_ID]).toEqual( + ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); + }); + + it("leaves a recorded status alone on later updates", async () => { + await mockStorage.set({ + [KEY_ID_LIST]: ["key-1"], + [ETHENA_LAUNCH_PROMO_STATUS_ID]: ETHENA_LAUNCH_PROMO_STATUS.SEEN, + }); + + await DataStorage.markEthenaLaunchPromoEligibility(); + + const stored = await mockStorage.get(ETHENA_LAUNCH_PROMO_STATUS_ID); + expect(stored[ETHENA_LAUNCH_PROMO_STATUS_ID]).toEqual( + ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); + }); + }); }); diff --git a/extension/src/background/helpers/dataStorage.ts b/extension/src/background/helpers/dataStorage.ts index 242c585891..07db6c354f 100644 --- a/extension/src/background/helpers/dataStorage.ts +++ b/extension/src/background/helpers/dataStorage.ts @@ -16,8 +16,10 @@ import { LAST_USED_ACCOUNT, KEY_ID_LIST, REBRAND_ANNOUNCEMENT_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS_ID, } from "constants/localStorageTypes"; import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; import { DEFAULT_NETWORKS, NetworkDetails, @@ -403,6 +405,28 @@ export const markRebrandAnnouncementEligibility = async () => { ); }; +/** + * Same once-per-install decision as the rebrand announcement, for the + * one-time Ethena launch promo: a wallet that already exists when the launch + * build first runs is marked pending; a fresh install is marked seen. + */ +export const markEthenaLaunchPromoEligibility = async () => { + const localStore = dataStorageAccess(browserLocalStorage); + + if (await localStore.getItem(ETHENA_LAUNCH_PROMO_STATUS_ID)) { + return; + } + + const keyIdList = ((await localStore.getItem(KEY_ID_LIST)) || []) as string[]; + + await localStore.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + keyIdList.length + ? ETHENA_LAUNCH_PROMO_STATUS.PENDING + : ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); +}; + export const versionedMigration = async () => { // sequentially call migrations in order to enforce smooth schema upgrades diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 9e20f856fe..c7db73b16e 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -41,6 +41,7 @@ import { migrateSorobanRpcUrlNetworkDetails, versionedMigration, markRebrandAnnouncementEligibility, + markEthenaLaunchPromoEligibility, } from "./helpers/dataStorage"; import { dataStorageAccess, @@ -211,6 +212,7 @@ export const initInstalledListener = () => { browser?.runtime?.onInstalled.addListener(migrateSorobanRpcUrlNetworkDetails); browser?.runtime?.onInstalled.addListener(versionedMigration); browser?.runtime?.onInstalled.addListener(markRebrandAnnouncementEligibility); + browser?.runtime?.onInstalled.addListener(markEthenaLaunchPromoEligibility); }; export const initSidebarBehavior = async () => { diff --git a/extension/src/background/messageListener/__tests__/claimEthenaLaunchPromo.test.ts b/extension/src/background/messageListener/__tests__/claimEthenaLaunchPromo.test.ts new file mode 100644 index 0000000000..a45ba1c374 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/claimEthenaLaunchPromo.test.ts @@ -0,0 +1,55 @@ +import { mockDataStorage } from "background/messageListener/helpers/test-helpers"; +import { ETHENA_LAUNCH_PROMO_STATUS_ID } from "constants/localStorageTypes"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; + +import { claimEthenaLaunchPromo } from "../handlers/claimEthenaLaunchPromo"; + +describe("claimEthenaLaunchPromo", () => { + beforeEach(async () => { + await mockDataStorage.remove(ETHENA_LAUNCH_PROMO_STATUS_ID); + }); + + it("says no when nothing has been recorded (fresh install)", async () => { + const result = await claimEthenaLaunchPromo({ + localStore: mockDataStorage, + }); + expect(result).toEqual({ shouldShowEthenaLaunchPromo: false }); + }); + + it("says yes once for a pending wallet and marks it seen", async () => { + await mockDataStorage.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS.PENDING, + ); + + const first = await claimEthenaLaunchPromo({ + localStore: mockDataStorage, + }); + const second = await claimEthenaLaunchPromo({ + localStore: mockDataStorage, + }); + + expect(first).toEqual({ shouldShowEthenaLaunchPromo: true }); + expect(second).toEqual({ shouldShowEthenaLaunchPromo: false }); + expect( + await mockDataStorage.getItem(ETHENA_LAUNCH_PROMO_STATUS_ID), + ).toEqual(ETHENA_LAUNCH_PROMO_STATUS.SEEN); + }); + + it("lets only one of several concurrent surfaces show it", async () => { + await mockDataStorage.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS.PENDING, + ); + + const results = await Promise.all([ + claimEthenaLaunchPromo({ localStore: mockDataStorage }), + claimEthenaLaunchPromo({ localStore: mockDataStorage }), + claimEthenaLaunchPromo({ localStore: mockDataStorage }), + ]); + + expect(results.filter((r) => r.shouldShowEthenaLaunchPromo)).toHaveLength( + 1, + ); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/createAccount.test.ts b/extension/src/background/messageListener/__tests__/createAccount.test.ts index 37adb37024..12c3a62ecd 100644 --- a/extension/src/background/messageListener/__tests__/createAccount.test.ts +++ b/extension/src/background/messageListener/__tests__/createAccount.test.ts @@ -14,7 +14,9 @@ import { KEY_ID_LIST, APPLICATION_ID, TEMPORARY_STORE_ID, + ETHENA_LAUNCH_PROMO_STATUS_ID, } from "constants/localStorageTypes"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; import { createAccount } from "background/messageListener/handlers/createAccount"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; @@ -64,6 +66,30 @@ describe("Create account message listener", () => { expect(applicationState).toBe(APPLICATION_STATE.PASSWORD_CREATED); expect(tempStoreId[keyId]).toBeDefined(); }); + it("doesn't owe a new wallet the Ethena launch promo", async () => { + // Pending from a wallet that existed before the reset/onboarding. + await mockDataStorage.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS.PENDING, + ); + + await popupMessageListener( + { + type: SERVICE_TYPES.CREATE_ACCOUNT, + password: "test", + isOverwritingAccount: false, + } as CreateAccountMessage, + mockSessionStore, + mockDataStorage, + mockKeyManager, + testAlarm as any, + { id: "fake-extension-id" }, + ); + + expect(await mockDataStorage.getItem(ETHENA_LAUNCH_PROMO_STATUS_ID)).toBe( + ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); + }); it("can overwrite account", async () => { const response = (await popupMessageListener( { diff --git a/extension/src/background/messageListener/__tests__/ethenaLaunchBanner.test.ts b/extension/src/background/messageListener/__tests__/ethenaLaunchBanner.test.ts new file mode 100644 index 0000000000..e4c724c80f --- /dev/null +++ b/extension/src/background/messageListener/__tests__/ethenaLaunchBanner.test.ts @@ -0,0 +1,33 @@ +import { mockDataStorage } from "background/messageListener/helpers/test-helpers"; +import { ETHENA_LAUNCH_BANNER_DISMISSED } from "constants/localStorageTypes"; + +import { getEthenaLaunchBannerDismissed } from "../handlers/getEthenaLaunchBannerDismissed"; +import { dismissEthenaLaunchBanner } from "../handlers/dismissEthenaLaunchBanner"; + +describe("Ethena launch banner dismissal", () => { + beforeEach(async () => { + await mockDataStorage.remove(ETHENA_LAUNCH_BANNER_DISMISSED); + }); + + it("reports the banner as not dismissed until it is", async () => { + const result = await getEthenaLaunchBannerDismissed({ + localStore: mockDataStorage, + }); + expect(result).toEqual({ isDismissed: false }); + }); + + it("persists a dismissal and reports it from then on", async () => { + const dismissed = await dismissEthenaLaunchBanner({ + localStore: mockDataStorage, + }); + const lookup = await getEthenaLaunchBannerDismissed({ + localStore: mockDataStorage, + }); + + expect(dismissed).toEqual({ isDismissed: true }); + expect(lookup).toEqual({ isDismissed: true }); + expect(await mockDataStorage.getItem(ETHENA_LAUNCH_BANNER_DISMISSED)).toBe( + true, + ); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/recoverAccount.test.ts b/extension/src/background/messageListener/__tests__/recoverAccount.test.ts index f726f78b5e..57bc391a12 100644 --- a/extension/src/background/messageListener/__tests__/recoverAccount.test.ts +++ b/extension/src/background/messageListener/__tests__/recoverAccount.test.ts @@ -9,6 +9,8 @@ import { mockStorageApi, MockBrowserAlarm, } from "background/messageListener/helpers/test-helpers"; +import { ETHENA_LAUNCH_PROMO_STATUS_ID } from "constants/localStorageTypes"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; // Distinct keys per derivation index, so the recovery preload loop stores a // separate account for each one rather than collapsing into a single entry. @@ -71,4 +73,19 @@ describe("Recover account message listener", () => { true, ); }); + + // Recovery is onboarding (also the "forgot password" path), so it isn't + // followed by the one-time launch promo either. + it("doesn't owe a recovered wallet the Ethena launch promo", async () => { + await mockDataStorage.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS.PENDING, + ); + + await recover(); + + expect(await mockDataStorage.getItem(ETHENA_LAUNCH_PROMO_STATUS_ID)).toBe( + ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); + }); }); diff --git a/extension/src/background/messageListener/__tests__/resetWallet.test.ts b/extension/src/background/messageListener/__tests__/resetWallet.test.ts index 709ce68693..f9d3cbc7d9 100644 --- a/extension/src/background/messageListener/__tests__/resetWallet.test.ts +++ b/extension/src/background/messageListener/__tests__/resetWallet.test.ts @@ -23,6 +23,8 @@ import { CACHED_SWAP_TOP_TOKENS_ID, COLLECTIBLES_ID, DATA_SHARING_ID, + ETHENA_LAUNCH_BANNER_DISMISSED, + ETHENA_LAUNCH_PROMO_STATUS_ID, HAS_ACCOUNT_SUBSCRIPTION, HAS_SEEN_DISCOVER_WELCOME, HIDDEN_ASSETS, @@ -52,7 +54,6 @@ import { TEMPORARY_STORE_EXTRA_ID, TEMPORARY_STORE_ID, TOKEN_ID_LIST, - USDT0_LAUNCH_BANNER_DISMISSED, } from "constants/localStorageTypes"; jest.mock("stellar-hd-wallet", () => ({ @@ -118,10 +119,11 @@ const OLD_WALLET_DATA: Record = { [CACHED_SWAP_TOP_TOKENS_ID]: [], [CACHED_MEMO_REQUIRED_ACCOUNTS_ID]: [], [`${CACHED_MEMO_REQUIRED_ACCOUNTS_ID}_date`]: 1, - [USDT0_LAUNCH_BANNER_DISMISSED]: true, + [ETHENA_LAUNCH_BANNER_DISMISSED]: true, [HAS_SEEN_DISCOVER_WELCOME]: true, [IS_BLOCKAID_ANNOUNCED_ID]: true, [REBRAND_ANNOUNCEMENT_STATUS_ID]: "pending", + [ETHENA_LAUNCH_PROMO_STATUS_ID]: "pending", isTestnet: true, }; @@ -290,6 +292,10 @@ describe("RESET_WALLET", () => { CACHED_MEMO_REQUIRED_ACCOUNTS_ID, CACHED_SWAP_TOP_TOKENS_ID, COLLECTIBLES_ID, + ETHENA_LAUNCH_BANNER_DISMISSED, + // Whether this wallet is still owed the launch promo. Like the rebrand + // status below, create and import mark it seen for the next wallet. + ETHENA_LAUNCH_PROMO_STATUS_ID, HAS_ACCOUNT_SUBSCRIPTION, HAS_SEEN_DISCOVER_WELCOME, HIDDEN_ASSETS, @@ -308,7 +314,6 @@ describe("RESET_WALLET", () => { RECENT_PROTOCOLS, TEMPORARY_STORE_ID, TOKEN_ID_LIST, - USDT0_LAUNCH_BANNER_DISMISSED, ]; // Not top-level browser.storage.local keys: the metrics keys live in page // localStorage, and TEMPORARY_STORE_EXTRA_ID is nested in temporaryStore. diff --git a/extension/src/background/messageListener/handlers/claimEthenaLaunchPromo.ts b/extension/src/background/messageListener/handlers/claimEthenaLaunchPromo.ts new file mode 100644 index 0000000000..df6c45d7d2 --- /dev/null +++ b/extension/src/background/messageListener/handlers/claimEthenaLaunchPromo.ts @@ -0,0 +1,32 @@ +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { ETHENA_LAUNCH_PROMO_STATUS_ID } from "constants/localStorageTypes"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; + +// Every surface unlocked by the same broadcast asks at once. Serialising the +// read-and-write means only the first one is told to show the promo. +let claimQueue: Promise = Promise.resolve(); + +export const claimEthenaLaunchPromo = ({ + localStore, +}: { + localStore: DataStorageAccess; +}): Promise<{ shouldShowEthenaLaunchPromo: boolean }> => { + const claim = claimQueue.then(async () => { + const status = await localStore.getItem(ETHENA_LAUNCH_PROMO_STATUS_ID); + + if (status !== ETHENA_LAUNCH_PROMO_STATUS.PENDING) { + return { shouldShowEthenaLaunchPromo: false }; + } + + await localStore.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); + + return { shouldShowEthenaLaunchPromo: true }; + }); + + claimQueue = claim.catch(() => undefined); + + return claim; +}; diff --git a/extension/src/background/messageListener/handlers/createAccount.ts b/extension/src/background/messageListener/handlers/createAccount.ts index ad56571fea..18c964a5e1 100644 --- a/extension/src/background/messageListener/handlers/createAccount.ts +++ b/extension/src/background/messageListener/handlers/createAccount.ts @@ -9,9 +9,11 @@ import { DataStorageAccess } from "background/helpers/dataStorageAccess"; import { APPLICATION_ID, REBRAND_ANNOUNCEMENT_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS_ID, KEY_DERIVATION_NUMBER_ID, } from "constants/localStorageTypes"; import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; import { clearSession, SessionTimer } from "background/helpers/session"; import { storeAccount } from "../helpers/store-account"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; @@ -77,11 +79,15 @@ export const createAccount = async ({ await localStore.setItem(APPLICATION_ID, APPLICATION_STATE.PASSWORD_CREATED); // A new wallet goes through onboarding, which must not be followed by the - // one-time rebrand announcement. + // one-time rebrand announcement or launch promo. await localStore.setItem( REBRAND_ANNOUNCEMENT_STATUS_ID, REBRAND_ANNOUNCEMENT_STATUS.SEEN, ); + await localStore.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); const currentState = sessionStore.getState(); diff --git a/extension/src/background/messageListener/handlers/dismissUsdt0LaunchBanner.ts b/extension/src/background/messageListener/handlers/dismissEthenaLaunchBanner.ts similarity index 51% rename from extension/src/background/messageListener/handlers/dismissUsdt0LaunchBanner.ts rename to extension/src/background/messageListener/handlers/dismissEthenaLaunchBanner.ts index fd163574fa..2c952f8a8d 100644 --- a/extension/src/background/messageListener/handlers/dismissUsdt0LaunchBanner.ts +++ b/extension/src/background/messageListener/handlers/dismissEthenaLaunchBanner.ts @@ -1,11 +1,11 @@ import { DataStorageAccess } from "background/helpers/dataStorageAccess"; -import { USDT0_LAUNCH_BANNER_DISMISSED } from "constants/localStorageTypes"; +import { ETHENA_LAUNCH_BANNER_DISMISSED } from "constants/localStorageTypes"; -export const dismissUsdt0LaunchBanner = async ({ +export const dismissEthenaLaunchBanner = async ({ localStore, }: { localStore: DataStorageAccess; }): Promise<{ isDismissed: boolean }> => { - await localStore.setItem(USDT0_LAUNCH_BANNER_DISMISSED, true); + await localStore.setItem(ETHENA_LAUNCH_BANNER_DISMISSED, true); return { isDismissed: true }; }; diff --git a/extension/src/background/messageListener/handlers/getUsdt0LaunchBannerDismissed.ts b/extension/src/background/messageListener/handlers/getEthenaLaunchBannerDismissed.ts similarity index 50% rename from extension/src/background/messageListener/handlers/getUsdt0LaunchBannerDismissed.ts rename to extension/src/background/messageListener/handlers/getEthenaLaunchBannerDismissed.ts index d0e9d6f350..7836c09fc9 100644 --- a/extension/src/background/messageListener/handlers/getUsdt0LaunchBannerDismissed.ts +++ b/extension/src/background/messageListener/handlers/getEthenaLaunchBannerDismissed.ts @@ -1,11 +1,11 @@ import { DataStorageAccess } from "background/helpers/dataStorageAccess"; -import { USDT0_LAUNCH_BANNER_DISMISSED } from "constants/localStorageTypes"; +import { ETHENA_LAUNCH_BANNER_DISMISSED } from "constants/localStorageTypes"; -export const getUsdt0LaunchBannerDismissed = async ({ +export const getEthenaLaunchBannerDismissed = async ({ localStore, }: { localStore: DataStorageAccess; }): Promise<{ isDismissed: boolean }> => { - const dismissed = await localStore.getItem(USDT0_LAUNCH_BANNER_DISMISSED); + const dismissed = await localStore.getItem(ETHENA_LAUNCH_BANNER_DISMISSED); return { isDismissed: !!dismissed }; }; diff --git a/extension/src/background/messageListener/handlers/recoverAccount.ts b/extension/src/background/messageListener/handlers/recoverAccount.ts index 55843f2516..db9321a589 100644 --- a/extension/src/background/messageListener/handlers/recoverAccount.ts +++ b/extension/src/background/messageListener/handlers/recoverAccount.ts @@ -15,10 +15,12 @@ import { ACCOUNT_NAME_LIST_ID, APPLICATION_ID, REBRAND_ANNOUNCEMENT_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS_ID, KEY_DERIVATION_NUMBER_ID, KEY_ID_LIST, } from "constants/localStorageTypes"; import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; +import { ETHENA_LAUNCH_PROMO_STATUS } from "constants/ethenaLaunchPromo"; import { clearSession, SessionTimer } from "background/helpers/session"; import { storeAccount } from "../helpers/store-account"; import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; @@ -84,11 +86,15 @@ export const recoverAccount = async ({ await localStore.setItem(KEY_DERIVATION_NUMBER_ID, "0"); // Recovery is onboarding (also the "forgot password" path), which must - // not be followed by the one-time rebrand announcement. + // not be followed by the one-time rebrand announcement or launch promo. await localStore.setItem( REBRAND_ANNOUNCEMENT_STATUS_ID, REBRAND_ANNOUNCEMENT_STATUS.SEEN, ); + await localStore.setItem( + ETHENA_LAUNCH_PROMO_STATUS_ID, + ETHENA_LAUNCH_PROMO_STATUS.SEEN, + ); await clearSession({ localStore, sessionStore }); diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 6eedf562d2..fc9a2669ef 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -91,8 +91,8 @@ import { modifyAssetsList } from "./handlers/modifyAssetsList"; import { getIsAccountMismatch } from "./handlers/getIsAccountMismatch"; import { changeAssetVisibility } from "./handlers/changeAssetVisibility"; import { getHiddenAssets } from "./handlers/getHiddenAssets"; -import { getUsdt0LaunchBannerDismissed } from "./handlers/getUsdt0LaunchBannerDismissed"; -import { dismissUsdt0LaunchBanner } from "./handlers/dismissUsdt0LaunchBanner"; +import { getEthenaLaunchBannerDismissed } from "./handlers/getEthenaLaunchBannerDismissed"; +import { dismissEthenaLaunchBanner } from "./handlers/dismissEthenaLaunchBanner"; import { loadBackendSettings } from "./handlers/loadBackendSettings"; import { saveBlockaidOverrideState } from "./handlers/saveDebugOverride"; import { getBlockaidOverrideState } from "./handlers/getDebugOverride"; @@ -106,6 +106,7 @@ import { clearRecentProtocols } from "./handlers/clearRecentProtocols"; import { getDiscoverWelcomeSeen } from "./handlers/getDiscoverWelcomeSeen"; import { dismissDiscoverWelcome } from "./handlers/dismissDiscoverWelcome"; import { claimRebrandAnnouncement } from "./handlers/claimRebrandAnnouncement"; +import { claimEthenaLaunchPromo } from "./handlers/claimEthenaLaunchPromo"; import { callBackendV2 } from "background/helpers/callBackendV2"; import { getCachedSwapTopTokens } from "./handlers/getCachedSwapTopTokens"; import { cacheSwapTopTokens } from "./handlers/cacheSwapTopTokens"; @@ -595,13 +596,13 @@ export const popupMessageListener = ( sessionStore, }); } - case SERVICE_TYPES.GET_USDT0_LAUNCH_BANNER_DISMISSED: { - return getUsdt0LaunchBannerDismissed({ + case SERVICE_TYPES.GET_ETHENA_LAUNCH_BANNER_DISMISSED: { + return getEthenaLaunchBannerDismissed({ localStore, }); } - case SERVICE_TYPES.DISMISS_USDT0_LAUNCH_BANNER: { - return dismissUsdt0LaunchBanner({ + case SERVICE_TYPES.DISMISS_ETHENA_LAUNCH_BANNER: { + return dismissEthenaLaunchBanner({ localStore, }); } @@ -654,6 +655,9 @@ export const popupMessageListener = ( case SERVICE_TYPES.CLAIM_REBRAND_ANNOUNCEMENT: { return claimRebrandAnnouncement({ localStore }); } + case SERVICE_TYPES.CLAIM_ETHENA_LAUNCH_PROMO: { + return claimEthenaLaunchPromo({ localStore }); + } case SERVICE_TYPES.GET_CACHED_SWAP_TOP_TOKENS: { return getCachedSwapTopTokens({ request, localStore }); } diff --git a/extension/src/constants/ethenaLaunchPromo.ts b/extension/src/constants/ethenaLaunchPromo.ts new file mode 100644 index 0000000000..a1575cc8ba --- /dev/null +++ b/extension/src/constants/ethenaLaunchPromo.ts @@ -0,0 +1,9 @@ +/** + * One-time full-screen "Meet Ethena on Stellar" promo, shown to wallets that + * existed when the launch build was installed: `PENDING` until shown, `SEEN` + * once shown or never owed. Fresh installs are marked `SEEN` on first run. + */ +export enum ETHENA_LAUNCH_PROMO_STATUS { + PENDING = "pending", + SEEN = "seen", +} diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index cbcfb1556d..feada41eb8 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -32,7 +32,7 @@ export const HIDDEN_COLLECTIBLES = "hiddenCollectibles"; export const HIDDEN_COLLECTIBLE_LIST = "hiddenCollectibleList"; export const TEMPORARY_STORE_ID = "temporaryStore"; export const TEMPORARY_STORE_EXTRA_ID = "temporaryStoreExtra"; -export const USDT0_LAUNCH_BANNER_DISMISSED = "usdt0LaunchBannerDismissed"; +export const ETHENA_LAUNCH_BANNER_DISMISSED = "ethenaLaunchBannerDismissed"; export const OVERRIDDEN_BLOCKAID_RESPONSE_ID = "overriddenBlockaidResponse"; export const COLLECTIBLES_ID = "collectibles"; export const IS_OPEN_SIDEBAR_BY_DEFAULT_ID = "isOpenSidebarByDefault"; @@ -40,4 +40,5 @@ export const METRICS_USER_ID = "metrics_user_id"; export const RECENT_PROTOCOLS = "recentProtocols"; export const HAS_SEEN_DISCOVER_WELCOME = "hasSeenDiscoverWelcome"; export const REBRAND_ANNOUNCEMENT_STATUS_ID = "rebrandAnnouncementStatus"; +export const ETHENA_LAUNCH_PROMO_STATUS_ID = "ethenaLaunchPromoStatus"; export const AUTO_LOCK_TIMEOUT_MINUTES_ID = "autoLockTimeoutMinutes"; diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index d534d5781e..7a31609ea4 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -41,6 +41,7 @@ import { SignTransaction } from "popup/views/SignTransaction"; import { SignAuthEntry } from "popup/views/SignAuthEntry"; import { UnlockAccount } from "popup/views/UnlockAccount"; import { RebrandAnnouncement } from "popup/views/RebrandAnnouncement"; +import { EthenaLaunch } from "popup/views/EthenaLaunch"; import { Welcome } from "popup/views/Welcome"; import { DisplayBackupPhrase } from "popup/views/DisplayBackupPhrase"; import { Debug } from "popup/views/Debug"; @@ -260,6 +261,14 @@ export const Router = () => ( } > + + + + } + > } diff --git a/extension/src/popup/assets/ethena-launch-banner-glow.svg b/extension/src/popup/assets/ethena-launch-banner-glow.svg new file mode 100644 index 0000000000..8df48f6fcb --- /dev/null +++ b/extension/src/popup/assets/ethena-launch-banner-glow.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/extension/src/popup/assets/ethena-launch-coin-dark.png b/extension/src/popup/assets/ethena-launch-coin-dark.png new file mode 100644 index 0000000000..8c218cb546 Binary files /dev/null and b/extension/src/popup/assets/ethena-launch-coin-dark.png differ diff --git a/extension/src/popup/assets/ethena-launch-coin-light.png b/extension/src/popup/assets/ethena-launch-coin-light.png new file mode 100644 index 0000000000..8a4e4d8845 Binary files /dev/null and b/extension/src/popup/assets/ethena-launch-coin-light.png differ diff --git a/extension/src/popup/assets/ethena-launch-wave.svg b/extension/src/popup/assets/ethena-launch-wave.svg new file mode 100644 index 0000000000..3654d2ea64 --- /dev/null +++ b/extension/src/popup/assets/ethena-launch-wave.svg @@ -0,0 +1,1611 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extension/src/popup/assets/logo-susde.svg b/extension/src/popup/assets/logo-susde.svg new file mode 100644 index 0000000000..d4fc91b838 --- /dev/null +++ b/extension/src/popup/assets/logo-susde.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/extension/src/popup/assets/logo-usde.svg b/extension/src/popup/assets/logo-usde.svg new file mode 100644 index 0000000000..e294c9fa3d --- /dev/null +++ b/extension/src/popup/assets/logo-usde.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/extension/src/popup/assets/logo-usdt0.png b/extension/src/popup/assets/logo-usdt0.png deleted file mode 100644 index b193faf698..0000000000 Binary files a/extension/src/popup/assets/logo-usdt0.png and /dev/null differ diff --git a/extension/src/popup/assets/usdt0-arcs.svg b/extension/src/popup/assets/usdt0-arcs.svg deleted file mode 100644 index 4ed670073f..0000000000 --- a/extension/src/popup/assets/usdt0-arcs.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/extension/src/popup/assets/usdt0-lockup.svg b/extension/src/popup/assets/usdt0-lockup.svg deleted file mode 100644 index cc9591d197..0000000000 --- a/extension/src/popup/assets/usdt0-lockup.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/extension/src/popup/basics/layout/View/styles.scss b/extension/src/popup/basics/layout/View/styles.scss index 9d1ba57e10..4d5bc41dd5 100644 --- a/extension/src/popup/basics/layout/View/styles.scss +++ b/extension/src/popup/basics/layout/View/styles.scss @@ -30,16 +30,11 @@ flex-shrink: 0; flex-grow: 0; - // Figma's Top (3705-77862; mobile's HeaderFade): the page color runs solid - // to 80.714% of the header and is clear by 93.214%, so content scrolling up - // under it fades out. + // Figma's Top (3705-77862; mobile's HeaderFade): content scrolling up + // under the header fades out. @mixin header-fade { z-index: var(--z-index--app-header); - background: linear-gradient( - to bottom, - var(--sds-clr-base-00) 80.714%, - transparent 93.214% - ); + @include header-fade-background; } // Home's top row stays pinned while the balance, the tiles and the lists diff --git a/extension/src/popup/components/account/AccountHeader/index.tsx b/extension/src/popup/components/account/AccountHeader/index.tsx index 0c8ebe77ef..4ab15241f3 100644 --- a/extension/src/popup/components/account/AccountHeader/index.tsx +++ b/extension/src/popup/components/account/AccountHeader/index.tsx @@ -12,7 +12,7 @@ import { navigateTo } from "popup/helpers/navigate"; import { AccountChip } from "popup/components/account/AccountChip"; import { AccountSheet } from "popup/components/account/AccountSheet"; import { ConnectedAppsSheet } from "popup/components/account/ConnectedAppsSheet"; -import { Usdt0LaunchBanner } from "popup/components/account/Usdt0LaunchBanner"; +import { EthenaLaunchBanner } from "popup/components/account/EthenaLaunchBanner"; import { AccountTabs } from "popup/components/account/AccountTabs"; import { MaintenanceBanner } from "popup/components/MaintenanceBanner"; @@ -128,7 +128,7 @@ export const AccountHeader = ({ ))} - + diff --git a/extension/src/popup/components/account/EthenaLaunchBanner/__tests__/EthenaLaunchBanner.test.tsx b/extension/src/popup/components/account/EthenaLaunchBanner/__tests__/EthenaLaunchBanner.test.tsx new file mode 100644 index 0000000000..01d374916f --- /dev/null +++ b/extension/src/popup/components/account/EthenaLaunchBanner/__tests__/EthenaLaunchBanner.test.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route, Routes } from "react-router-dom"; +import { captureException } from "@sentry/browser"; + +import * as ApiInternal from "@shared/api/internal"; +import { ROUTES } from "popup/constants/routes"; +import { Wrapper } from "popup/__testHelpers__"; +import { EthenaLaunchBanner } from "popup/components/account/EthenaLaunchBanner"; + +jest.mock("@sentry/browser", () => ({ + ...jest.requireActual("@sentry/browser"), + captureException: jest.fn(), +})); + +const renderBanner = () => + render( + + + } /> + } + /> + + , + ); + +describe("EthenaLaunchBanner", () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it("shows the launch until it's dismissed", async () => { + jest + .spyOn(ApiInternal, "getEthenaLaunchBannerDismissed") + .mockResolvedValue(false); + renderBanner(); + + expect( + await screen.findByTestId("ethena-launch-banner"), + ).toBeInTheDocument(); + expect(screen.getByText("Meet Ethena on Stellar")).toBeInTheDocument(); + expect( + screen.getByText("USDe and sUSDe are now available."), + ).toBeInTheDocument(); + }); + + it("stays hidden once dismissed", async () => { + const lookup = jest + .spyOn(ApiInternal, "getEthenaLaunchBannerDismissed") + .mockResolvedValue(true); + renderBanner(); + + await waitFor(() => expect(lookup).toHaveBeenCalled()); + expect(screen.queryByTestId("ethena-launch-banner")).toBeNull(); + }); + + it("stays hidden when the dismissal can't be looked up", async () => { + const error = new Error("no background"); + jest + .spyOn(ApiInternal, "getEthenaLaunchBannerDismissed") + .mockRejectedValue(error); + renderBanner(); + + await waitFor(() => expect(captureException).toHaveBeenCalledWith(error)); + expect(screen.queryByTestId("ethena-launch-banner")).toBeNull(); + }); + + it("hides and remembers the dismissal", async () => { + jest + .spyOn(ApiInternal, "getEthenaLaunchBannerDismissed") + .mockResolvedValue(false); + const dismiss = jest + .spyOn(ApiInternal, "dismissEthenaLaunchBanner") + .mockResolvedValue({ isDismissed: true }); + renderBanner(); + + await userEvent.click( + await screen.findByTestId("ethena-launch-banner-dismiss"), + ); + + expect(dismiss).toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByTestId("ethena-launch-banner")).toBeNull(), + ); + }); + + it("opens the full-screen promo", async () => { + jest + .spyOn(ApiInternal, "getEthenaLaunchBannerDismissed") + .mockResolvedValue(false); + renderBanner(); + + await userEvent.click( + await screen.findByTestId("ethena-launch-banner-open"), + ); + + expect(await screen.findByTestId("ethena-launch-stub")).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/account/EthenaLaunchBanner/index.tsx b/extension/src/popup/components/account/EthenaLaunchBanner/index.tsx new file mode 100644 index 0000000000..9143e8a03e --- /dev/null +++ b/extension/src/popup/components/account/EthenaLaunchBanner/index.tsx @@ -0,0 +1,91 @@ +import React, { useState, useEffect } from "react"; +import { Icon } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { captureException } from "@sentry/browser"; + +import { + getEthenaLaunchBannerDismissed, + dismissEthenaLaunchBanner, +} from "@shared/api/internal"; + +import { ROUTES } from "popup/constants/routes"; +import { navigateTo } from "popup/helpers/navigate"; +import EthenaLaunchBannerGlow from "popup/assets/ethena-launch-banner-glow.svg"; + +import "./styles.scss"; + +export const EthenaLaunchBanner = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isDismissed, setIsDismissed] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const checkDismissedStatus = async () => { + try { + const dismissed = await getEthenaLaunchBannerDismissed(); + setIsDismissed(dismissed); + } catch (error) { + // Default to hiding the banner on messaging failure so we never + // re-show it to a user who has already dismissed it. + captureException(error); + setIsDismissed(true); + } finally { + setIsLoading(false); + } + }; + + checkDismissedStatus(); + }, []); + + const handleDismiss = async () => { + try { + const { isDismissed } = await dismissEthenaLaunchBanner(); + setIsDismissed(isDismissed); + } catch (error) { + captureException(error); + } + }; + + const handleBannerClick = () => { + navigateTo(ROUTES.ethenaLaunch, navigate); + }; + + // Don't show banner if loading or if dismissed + if (isLoading || isDismissed) { + return null; + } + + return ( +
+ + + +
+ ); +}; diff --git a/extension/src/popup/components/account/EthenaLaunchBanner/styles.scss b/extension/src/popup/components/account/EthenaLaunchBanner/styles.scss new file mode 100644 index 0000000000..5e280080a6 --- /dev/null +++ b/extension/src/popup/components/account/EthenaLaunchBanner/styles.scss @@ -0,0 +1,115 @@ +@use "../../../styles/utils.scss" as *; + +// Figma's banner (4414:42195): the campaign's fixed near-black card, with +// fixed colors on it rather than theme tokens, so it's the same in both themes +// apart from dark mode's outline (below). +.EthenaLaunchBanner { + position: relative; + // Its own stacking context, so the dismiss button's z-index stays inside + // the banner instead of lifting it over Home's pinned header as the banner + // scrolls under it, and the glow can sit behind the copy at z-index -1. + isolation: isolate; + overflow: hidden; + background: #070908; + border-radius: pxToRem(16px); + // The copy is white unless it says otherwise. + color: var(--sds-clr-white); + // What the browser draws itself, like the keyboard focus ring, follows the + // color scheme. Fixed to the dark card's so it doesn't flip either. + color-scheme: dark; + // No margins: this sits in `AccountHeader__account-info`, which spans the + // content column and spaces its children 16px apart, so the banner + // stretches to match the action tiles. + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.9; + } + + // The blurred blue disc bleeding off the card's top-right corner, at + // Figma's offsets. + &__glow { + position: absolute; + z-index: -1; + top: pxToRem(-80px); + right: pxToRem(-79px); + width: pxToRem(263px); + height: pxToRem(263px); + max-width: none; + pointer-events: none; + } + + // The launch surface is a real - } - /> - -
-
-
- {t("USDT0 is now on Stellar")} -
- {/* global.scss forces `p` color to inherit (!important), which - would defeat the muted gray — render as div like the rows */} - - {t("Access USDT liquidity on Stellar with USDT0.")} - -
-
-
-
- -
-
- - {t("Move across networks")} - - - {t("Transfer USDT0 between Stellar and supported networks.")} - -
-
-
-
- -
-
- - {t("1:1 backed, unified liquidity")} - - - {t("USDT0 is backed 1:1 by USDT.")} - -
-
-
-
-
- - - -
- - - ); -}; diff --git a/extension/src/popup/components/account/Usdt0LaunchBanner/Usdt0LaunchSheet/styles.scss b/extension/src/popup/components/account/Usdt0LaunchBanner/Usdt0LaunchSheet/styles.scss deleted file mode 100644 index 82a98c0217..0000000000 --- a/extension/src/popup/components/account/Usdt0LaunchBanner/Usdt0LaunchSheet/styles.scss +++ /dev/null @@ -1,244 +0,0 @@ -@use "../../../../styles/utils.scss" as *; - -.Usdt0LaunchSheet { - position: relative; - height: 100%; - width: 100%; - background: var(--sds-clr-gray-01); - // Re-resolves inherited text against this subtree's theme class; body's - // color arrives already computed in the page theme. - color: var(--sds-clr-gray-12); - // Clip the oversized hero art horizontally, but keep a vertical scroll - // path so the footer CTAs stay reachable in short viewports (squat - // sidebar windows, high zoom) - overflow-x: hidden; - overflow-y: auto; - - &__background { - position: absolute; - top: 0; - left: 0; - right: 0; - height: pxToRem(429px); - pointer-events: none; - - &__gradient { - position: absolute; - top: 0; - left: 0; - right: 0; - height: pxToRem(377px); - // The hero (gradient + arcs image + fade + overlay) is one composite - // dark artwork — all its layers stay fixed-dark together; a tokenized - // stop here would split from the still-dark fade when the theme flips. - background: linear-gradient(180deg, #002d19 0%, #161616 100%); - } - - &__arcs { - position: absolute; - top: pxToRem(77px); - left: 50%; - transform: translateX(-50%); - width: pxToRem(395px); - height: pxToRem(180px); - max-width: none; - } - - &__fade { - position: absolute; - top: pxToRem(73px); - left: 0; - right: 0; - height: pxToRem(356px); - background: linear-gradient( - 180deg, - rgba(22, 23, 23, 0) 4.7%, - #151616 44.67% - ); - } - - &__overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - height: pxToRem(356px); - background: linear-gradient( - 180deg, - rgba(22, 23, 23, 0) 4.7%, - rgba(21, 22, 22, 0.78) 44.67% - ); - } - - &__lockup { - position: absolute; - top: pxToRem(135px); - left: 50%; - transform: translateX(-50%); - width: pxToRem(165px); - height: auto; - } - } - - &__content { - position: relative; - display: flex; - flex-direction: column; - // Fills the sheet but may grow past it, scrolling the root, when the - // viewport is shorter than the content - min-height: 100%; - padding-bottom: pxToRem(24px); - gap: pxToRem(32px); - } - - // Mirrors the View app header metrics (80px tall, 24px inset) so the X - // sits exactly where the QR code screen's does - &__header { - display: flex; - align-items: center; - min-height: pxToRem(80px); - padding: 0 pxToRem(24px); - } - - // The BackButton class supplies the geometry; a - - - !open && setIsSheetOpen(false)} - ariaLabel={t("USDT0 is now on Stellar")} - > -
- setIsSheetOpen(false)} /> -
-
- - ); -}; diff --git a/extension/src/popup/components/account/Usdt0LaunchBanner/styles.scss b/extension/src/popup/components/account/Usdt0LaunchBanner/styles.scss deleted file mode 100644 index 7a029c7f2b..0000000000 --- a/extension/src/popup/components/account/Usdt0LaunchBanner/styles.scss +++ /dev/null @@ -1,110 +0,0 @@ -@use "../../../styles/utils.scss" as *; - -.Usdt0LaunchBanner { - position: relative; - // Its own stacking context, so the dismiss button's z-index stays inside - // the banner instead of lifting it over Home's pinned header as the banner - // scrolls under it. - isolation: isolate; - background: #002d19; - border-radius: pxToRem(12px); - // No margins: this sits in `AccountHeader__account-info`, which spans the - // content column and spaces its children 16px apart, so the banner - // stretches to match the action tiles and takes the same gap above and - // below them as Figma's balance, tiles and tabs. - display: flex; - align-items: center; - justify-content: space-between; - gap: pxToRem(12px); - transition: opacity 0.2s ease; - - &:hover { - opacity: 0.9; - } - - // The launch surface is a real + + + + + ); +}; + +interface EthenaLaunchLocationState { + // Where closing goes: UnlockAccount's original destination, or Home. + destination?: string; + // How far the page was scrolled when it left for Receive. + scrollTop?: number; +} + +/** + * Full-screen "Meet Ethena on Stellar" launch promo. UnlockAccount routes here + * once per eligible wallet (the background marks it seen as it hands it out), + * and the Home banner opens it on demand. Closing returns to the destination + * in location state, or Home. + */ +export const EthenaLaunch = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const locationState = (location.state ?? {}) as EthenaLaunchLocationState; + const destination = locationState.destination ?? ROUTES.account; + // Back from Receive's address screen: the page comes back as it was left, + // scrolled where it was and without replaying the intro. + const returnScrollTop = locationState.scrollTop; + + const pageRef = useRef(null); + const headerRef = useRef(null); + + // The close button's bar fills in once the page scrolls (see styles). + const [isScrolled, setIsScrolled] = useState(Boolean(returnScrollTop)); + + // Before the first paint, so the page never shows at the top first. + useLayoutEffect(() => { + if (returnScrollTop !== undefined && pageRef.current) { + pageRef.current.scrollTop = returnScrollTop; + } + }, [returnScrollTop]); + + const handleScroll = (event: React.UIEvent) => { + setIsScrolled(event.currentTarget.scrollTop > 0); + }; + + const handleScrollToTokens = (event: React.MouseEvent) => { + const page = pageRef.current; + if (!page) { + return; + } + const cueTop = + event.currentTarget.getBoundingClientRect().top - + page.getBoundingClientRect().top; + const barHeight = headerRef.current?.offsetHeight ?? 0; + page.scrollTo({ + top: page.scrollTop + cueTop - barHeight - SCROLL_CUE_GAP, + behavior: window.matchMedia?.(REDUCED_MOTION_QUERY)?.matches + ? "auto" + : "smooth", + }); + }; + + const handleClose = () => { + navigate(destination, { replace: true }); + }; + + // Esc closes the promo like the X does, rather than the popup. + useEscapeToClose(handleClose); + + const handleReceive = () => { + // The address screen closes by going back, to this history entry, so + // note in it where the page is. + navigate(`${location.pathname}${location.search}`, { + replace: true, + state: { + ...locationState, + scrollTop: pageRef.current?.scrollTop ?? 0, + }, + }); + navigateTo(ROUTES.viewPublicKey, navigate); + }; + + const handleLearnMore = () => { + openTab(ETHENA_DOCS_URL); + }; + + return ( + // Home's frame: in fullscreen mode a centred column with vertical + // borders, which the promo fills instead of the whole window. + + {/* Pinned dark: the hero art is drawn for a black page and there is no + light design. */} +
+
+ +
+ +
+ + +
+ + {t("Meet Ethena on Stellar")} + +
+ {t("Discover USDe and sUSDe, now available on Stellar.")} +
+
+ +
+ +
+ + + +
+
+
+ ); +}; diff --git a/extension/src/popup/views/EthenaLaunch/styles.scss b/extension/src/popup/views/EthenaLaunch/styles.scss new file mode 100644 index 0000000000..a7b08a055c --- /dev/null +++ b/extension/src/popup/views/EthenaLaunch/styles.scss @@ -0,0 +1,390 @@ +@use "../../styles/utils.scss" as *; + +// Figma's launch timeline (1s, plays once): the wave and the coins slide in +// over 800ms, the title and subtitle fade in from 200ms, the scroll cue from +// 400ms. +$enter-duration: 800ms; +$fade-duration: 500ms; +$copy-delay: 200ms; +$scroll-hint-delay: 400ms; +$slide-easing: cubic-bezier(0.22, 1, 0.36, 1); +$fade-easing: cubic-bezier(0.4, 0, 0.2, 1); + +// The close button's bar is Home's pinned header: as tall, with the same fade. +$header-height: 80px; +$header-fade-in: 200ms; + +.EthenaLaunch { + position: relative; + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + background: var(--sds-clr-base-00); + // Re-resolves inherited text against this subtree's theme class; body's + // color arrives already computed in the page theme. + color: var(--sds-clr-gray-12); + // No scrollbar, as in the design: the scroll cue says there's more below. + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + // Home's frame (a View inset): the whole window in the popup and sidebar, + // a centred column with vertical borders in fullscreen mode. The promo + // fills it, top to bottom. + &__frame { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + width: 100%; + } + + // Sticky, so the X stays put while the page scrolls under it, and pulled + // back over the hero by its own height, so it pushes nothing down. + &__header { + position: sticky; + top: 0; + z-index: 1; + height: pxToRem($header-height); + margin-bottom: pxToRem(-$header-height); + display: flex; + align-items: flex-start; + justify-content: flex-end; + + // The bar behind the X. Clear at the top, where the wave runs up behind + // the X; once the page scrolls it fills in like Home's pinned header, and + // the tokens fade out as they scroll up under it. + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + @include header-fade-background; + opacity: 0; + transition: opacity $header-fade-in $fade-easing; + } + + &--scrolled::before { + opacity: 1; + } + } + + &__close { + flex-shrink: 0; + width: pxToRem(24px); + height: pxToRem(24px); + margin: pxToRem(24px) pxToRem(16px) 0 0; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--sds-clr-gray-12); + + svg { + display: block; + width: pxToRem(24px); + height: pxToRem(24px); + @include icon-stroke(24, 1.5); + } + } + + // The first screen: at least as tall as the window, with the scroll cue on + // its bottom edge and the tokens starting below the fold. + &__hero { + position: relative; + width: 100%; + min-height: 100%; + padding: pxToRem(130px) 0 pxToRem(16px); + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(48px); + } + + // Figma clips the wave art to a 534x301 frame centred 50px left of the + // column; the image sits inside it at the frame's own offsets. + &__wave { + position: absolute; + top: pxToRem(48px); + left: calc(50% - #{pxToRem(317px)}); + width: pxToRem(534px); + height: pxToRem(301px); + overflow: hidden; + pointer-events: none; + animation: + EthenaLaunch-slide-in-x $enter-duration $slide-easing both, + EthenaLaunch-fade-in $enter-duration $fade-easing both; + + // The frame's side edges, and the bottom edge where the strokes run down + // to it on the left, fall outside the 360px popup. A wider sidebar or tab + // would show the strokes cut off there, so fade them out. + @media (min-width: 435px) { + mask-image: + linear-gradient( + to right, + transparent, + black 20%, + black 80%, + transparent + ), + linear-gradient(to bottom, black 60%, transparent); + mask-composite: intersect; + } + + img { + position: absolute; + top: pxToRem(-82.254px); + left: pxToRem(-163.603px); + width: pxToRem(786.059px); + height: pxToRem(476.965px); + max-width: none; + } + } + + // The two coins overlap as in Figma's stack: the dark one on top. + &__coins { + position: relative; + flex-shrink: 0; + width: pxToRem(240px); + height: pxToRem(195.5px); + } + + &__coin { + position: absolute; + max-width: none; + object-fit: cover; + animation: + EthenaLaunch-slide-in-y $enter-duration $slide-easing both, + EthenaLaunch-fade-in $enter-duration $fade-easing both; + + &--light { + top: pxToRem(53.55px); + left: pxToRem(84.71px); + width: pxToRem(155.294px); + height: pxToRem(141.961px); + } + + // The artwork carries empty space above the coin, which Figma crops. + &--dark { + top: 0; + left: 0; + width: pxToRem(187px); + height: pxToRem(161px); + object-position: bottom; + } + } + + &__intro { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + width: 100%; + padding: 0 pxToRem(24px); + text-align: center; + animation: EthenaLaunch-fade-in $fade-duration $fade-easing $copy-delay both; + } + + &__subtitle { + font-size: pxToRem(14px); + line-height: pxToRem(20px); + color: var(--sds-clr-gray-11); + // Breaks after "sUSDe," as the design does, without hardcoding the + // break into the translated copy. + text-wrap: balance; + } + + // A button: it scrolls the tokens into view (see handleScrollToTokens). + &__scroll-hint { + // Pushed to the bottom of the first screen however tall the window is. + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(4px); + padding: 0; + background: none; + border: none; + cursor: pointer; + font-family: inherit; + font-size: pxToRem(12px); + line-height: pxToRem(18px); + font-weight: var(--sds-fw-medium); + color: var(--sds-clr-gray-11); + text-align: center; + animation: EthenaLaunch-fade-in $fade-duration $fade-easing + $scroll-hint-delay both; + + &:hover { + color: var(--sds-clr-gray-12); + } + + svg { + width: pxToRem(18px); + height: pxToRem(18px); + color: var(--sds-clr-gray-09); + @include icon-stroke(18); + } + } + + &__tokens { + width: 100%; + padding-bottom: pxToRem(24px); + display: flex; + flex-direction: column; + align-items: center; + } + + &__token { + width: 100%; + padding: pxToRem(16px) pxToRem(24px); + display: flex; + flex-direction: column; + gap: pxToRem(24px); + + &__info { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + } + + &__heading { + display: flex; + align-items: center; + gap: pxToRem(12px); + } + + &__logo { + flex-shrink: 0; + width: pxToRem(40px); + height: pxToRem(40px); + } + + &__code { + font-size: pxToRem(16px); + line-height: pxToRem(24px); + font-weight: var(--sds-fw-semi-bold); + color: var(--sds-clr-gray-12); + } + + &__name, + &__description { + font-size: pxToRem(12px); + line-height: pxToRem(18px); + font-weight: var(--sds-fw-medium); + color: var(--sds-clr-gray-11); + } + + &__actions { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + } + } + + // Borderless text button per design. No SDS Button variant is transparent, + // so it's hand-rolled at the metrics of SDS's lg Button, which Swap and + // Receive above it are: 40px tall, 14/22 text and a 14px icon. + &__transfer { + display: flex; + align-items: center; + justify-content: center; + gap: pxToRem(4px); + width: 100%; + height: pxToRem(40px); + padding: pxToRem(8px) pxToRem(12px); + background: none; + border: none; + border-radius: pxToRem(100px); + cursor: pointer; + color: var(--sds-clr-gray-12); + font-family: inherit; + font-size: pxToRem(14px); + font-weight: var(--sds-fw-semi-bold); + line-height: pxToRem(22px); + + &:hover { + // Opaque fallback first: manifest v2 still allows Firefox < 113, no + // color-mix + background: var(--sds-clr-gray-04); + background: color-mix(in srgb, var(--sds-clr-gray-12) 10%, transparent); + } + + // Sized like the icon in an SDS Button, so its stroke matches Swap's. + svg { + width: pxToRem(14px); + height: pxToRem(14px); + } + } + + &__learn-more { + display: flex; + align-items: center; + gap: pxToRem(8px); + margin-top: pxToRem(24px); + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--sds-clr-gray-09); + font-family: inherit; + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(18px); + + &:hover { + color: var(--sds-clr-gray-11); + } + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + color: var(--sds-clr-gray-10); + @include icon-stroke(14, 1.5); + } + } + + // Back from Receive's address screen, as it was left: the intro doesn't + // replay. + &--returning { + .EthenaLaunch__wave, + .EthenaLaunch__coin, + .EthenaLaunch__intro, + .EthenaLaunch__scroll-hint { + animation: none; + } + } + + @media (prefers-reduced-motion: reduce) { + &__wave, + &__coin, + &__intro, + &__scroll-hint { + animation: none; + } + + &__header::before { + transition: none; + } + } +} + +@keyframes EthenaLaunch-fade-in { + from { + opacity: 0; + } +} + +@keyframes EthenaLaunch-slide-in-x { + from { + transform: translateX(-20px); + } +} + +@keyframes EthenaLaunch-slide-in-y { + from { + transform: translateY(40px); + } +} diff --git a/extension/src/popup/views/UnlockAccount/__tests__/UnlockAccount.test.tsx b/extension/src/popup/views/UnlockAccount/__tests__/UnlockAccount.test.tsx index c123dd1cb2..5cee4ed6fb 100644 --- a/extension/src/popup/views/UnlockAccount/__tests__/UnlockAccount.test.tsx +++ b/extension/src/popup/views/UnlockAccount/__tests__/UnlockAccount.test.tsx @@ -50,6 +50,10 @@ const renderLockScreen = ( path={ROUTES.rebrandAnnouncement} element={
} /> + } + /> , ); @@ -61,10 +65,12 @@ const typePasswordAndUnlock = async (password: string) => { describe("UnlockAccount", () => { beforeEach(() => { - // Only wallets that pre-date the rebrand are owed the announcement. + // Only wallets that pre-date the rebrand are owed the announcement, and + // only wallets that pre-date the launch build are owed its promo. jest .spyOn(ApiInternal, "claimRebrandAnnouncement") .mockResolvedValue(false); + jest.spyOn(ApiInternal, "claimEthenaLaunchPromo").mockResolvedValue(false); }); afterEach(() => { @@ -140,6 +146,70 @@ describe("UnlockAccount", () => { expect(screen.queryByTestId("account-view-stub")).toBeNull(); }); + it("shows the one-time Ethena launch promo on the way Home to a wallet that's owed it", async () => { + jest + .spyOn(ApiInternal, "confirmPassword") + .mockResolvedValue(unlockedResponse as any); + jest.spyOn(ApiInternal, "claimEthenaLaunchPromo").mockResolvedValue(true); + renderLockScreen(); + + await typePasswordAndUnlock("my password"); + + expect(await screen.findByTestId("ethena-launch-stub")).toBeInTheDocument(); + expect(screen.queryByTestId("account-view-stub")).toBeNull(); + }); + + it("goes Home when the launch promo can't be claimed", async () => { + jest + .spyOn(ApiInternal, "confirmPassword") + .mockResolvedValue(unlockedResponse as any); + jest + .spyOn(ApiInternal, "claimEthenaLaunchPromo") + .mockRejectedValue(new Error("no background")); + renderLockScreen(); + + await typePasswordAndUnlock("my password"); + + expect(await screen.findByTestId("account-view-stub")).toBeInTheDocument(); + }); + + it("keeps a dApp request ahead of the launch promo, which stays owed", async () => { + jest + .spyOn(ApiInternal, "confirmPassword") + .mockResolvedValue(unlockedResponse as any); + const claimPromo = jest + .spyOn(ApiInternal, "claimEthenaLaunchPromo") + .mockResolvedValue(true); + renderLockScreen({ + pathname: ROUTES.unlockAccount, + search: "?request=abc", + state: { from: { pathname: ROUTES.grantAccess } }, + }); + + await typePasswordAndUnlock("my password"); + + expect(await screen.findByTestId("grant-access-stub")).toBeInTheDocument(); + expect(claimPromo).not.toHaveBeenCalled(); + }); + + it("shows the rebrand announcement first and leaves the launch promo owed", async () => { + jest + .spyOn(ApiInternal, "confirmPassword") + .mockResolvedValue(unlockedResponse as any); + jest.spyOn(ApiInternal, "claimRebrandAnnouncement").mockResolvedValue(true); + const claimPromo = jest + .spyOn(ApiInternal, "claimEthenaLaunchPromo") + .mockResolvedValue(true); + renderLockScreen(); + + await typePasswordAndUnlock("my password"); + + expect( + await screen.findByTestId("rebrand-announcement-stub"), + ).toBeInTheDocument(); + expect(claimPromo).not.toHaveBeenCalled(); + }); + it("shows Incorrect Password and stays locked on a wrong password", async () => { jest .spyOn(ApiInternal, "confirmPassword") diff --git a/extension/src/popup/views/UnlockAccount/index.tsx b/extension/src/popup/views/UnlockAccount/index.tsx index aa7e2f3963..22f58b3b82 100644 --- a/extension/src/popup/views/UnlockAccount/index.tsx +++ b/extension/src/popup/views/UnlockAccount/index.tsx @@ -17,7 +17,10 @@ import { confirmPassword, hasPrivateKeySelector, } from "popup/ducks/accountServices"; -import { claimRebrandAnnouncement } from "@shared/api/internal"; +import { + claimEthenaLaunchPromo, + claimRebrandAnnouncement, +} from "@shared/api/internal"; import { ForgotPasswordSheet } from "./ForgotPasswordSheet"; @@ -71,6 +74,11 @@ export const UnlockAccount = () => { // Wallets that pre-date the rebrand get the one-time announcement before // their destination. The background hands it out once, so of several // surfaces unlocked by the same broadcast only the first shows it. + // + // Wallets that pre-date the Ethena launch build get its one-time promo + // the same way, but only on the way Home: a dApp request isn't held up + // for it, and at most one takeover plays per unlock. Either way the + // promo stays owed until a later unlock claims it. const continueToDestination = async () => { let shouldShowAnnouncement = false; try { @@ -87,6 +95,23 @@ export const UnlockAccount = () => { return; } + let shouldShowLaunchPromo = false; + if (destination === ROUTES.account) { + try { + shouldShowLaunchPromo = await claimEthenaLaunchPromo(); + } catch { + // Fail closed: a messaging failure skips the promo. + } + } + + if (shouldShowLaunchPromo) { + navigate(ROUTES.ethenaLaunch, { + replace: true, + state: { destination: target }, + }); + return; + } + navigate(target, { replace: true }); }; diff --git a/extension/src/popup/views/ViewPublicKey/index.tsx b/extension/src/popup/views/ViewPublicKey/index.tsx index 37524954b9..915cbad91d 100644 --- a/extension/src/popup/views/ViewPublicKey/index.tsx +++ b/extension/src/popup/views/ViewPublicKey/index.tsx @@ -3,12 +3,13 @@ import { useSelector } from "react-redux"; import { QRCodeSVG } from "qrcode.react"; import { Icon, Button, Notification } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { Navigate, useLocation } from "react-router-dom"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { emitMetric } from "helpers/metrics"; import { truncatedPublicKey } from "helpers/stellar"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { closeWindow, openTab } from "popup/helpers/navigate"; +import { useEscapeToClose } from "popup/helpers/hooks/useEscapeToClose"; import { View } from "popup/basics/layout/View"; import { showToast } from "popup/basics/shadcn/Toast"; import { accountNameSelector } from "popup/ducks/accountServices"; @@ -26,9 +27,13 @@ import "./styles.scss"; export const ViewPublicKey = () => { const { t } = useTranslation(); const location = useLocation(); + const navigate = useNavigate(); const accountName = useSelector(accountNameSelector); const { state, fetchData } = useGetAppData(); + // Esc goes back like the X does, rather than closing the popup. + useEscapeToClose(() => navigate(-1)); + useEffect(() => { const getData = async () => { await fetchData(); diff --git a/extension/src/popup/views/__tests__/EthenaLaunch.test.tsx b/extension/src/popup/views/__tests__/EthenaLaunch.test.tsx new file mode 100644 index 0000000000..516ac6dcab --- /dev/null +++ b/extension/src/popup/views/__tests__/EthenaLaunch.test.tsx @@ -0,0 +1,291 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route, Routes, useNavigate } from "react-router-dom"; + +import { ROUTES } from "popup/constants/routes"; +import { Wrapper } from "popup/__testHelpers__"; +import { EthenaLaunch } from "popup/views/EthenaLaunch"; + +jest.mock("popup/helpers/navigate", () => ({ + ...jest.requireActual("popup/helpers/navigate"), + openTab: jest.fn(), +})); + +// The global mock returns keys verbatim; the per-token labels need their +// {{code}} filled in to be told apart. +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, values: Record = {}) => + Object.entries(values).reduce( + (text, [name, value]) => text.split(`{{${name}}}`).join(value), + key, + ), + }), +})); + +const { openTab } = jest.requireMock( + "popup/helpers/navigate", +); + +// From XLM (token0) to each token (token1), by their Mainnet token contracts. +const SUSHI_USDE_URL = + "https://www.sushi.com/stellar/swap?token0=CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA&token1=CCNOV6XVI2O5BSTA3232GIRUQULDEBV7IWGKM6QKI6II5OIIQTU24JJN"; +const SUSHI_SUSDE_URL = + "https://www.sushi.com/stellar/swap?token0=CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA&token1=CACSKWC753QVGC4J6YPDBIR2BEHFVWIAE7CBSS3HOVYMN623OAIQYE2M"; +const STARGATE_USDE_URL = + "https://stargate.finance/?srcChain=ethereum&srcToken=0x4c9EDD5852cd905f086C759E8383e09bff1E68B3&dstChain=stellar&dstToken=CCNOV6XVI2O5BSTA3232GIRUQULDEBV7IWGKM6QKI6II5OIIQTU24JJN"; +const STARGATE_SUSDE_URL = + "https://stargate.finance/?srcChain=ethereum&srcToken=0x9D39A5DE30e57443BfF2A8307A4256c8797A3497&dstChain=stellar&dstToken=CACSKWC753QVGC4J6YPDBIR2BEHFVWIAE7CBSS3HOVYMN623OAIQYE2M"; + +// The address screen, closed the way the real one is: back in history. +const ViewPublicKeyStub = () => { + const navigate = useNavigate(); + return ( + + ); +}; + +const renderView = (destination?: string) => + render( + + + } /> + account-home
} /> + settings-home} /> + } /> + + , + ); + +describe("EthenaLaunch", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("introduces Ethena and both of its tokens", () => { + renderView(); + + expect( + screen.getByRole("heading", { name: "Meet Ethena on Stellar" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Discover USDe and sUSDe, now available on Stellar."), + ).toBeInTheDocument(); + expect(screen.getByText("Scroll to discover tokens")).toBeInTheDocument(); + expect(screen.getByText("Synthetic Dollar")).toBeInTheDocument(); + expect( + screen.getByText( + "A synthetic dollar designed to maintain a value of $1.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Staked USDe")).toBeInTheDocument(); + expect( + screen.getByText( + "A staked version of USDe that accrues variable protocol rewards.", + ), + ).toBeInTheDocument(); + }); + + it("stays on the dark theme whatever the app theme is", () => { + renderView(); + + expect(screen.getByTestId("ethena-launch")).toHaveClass("sds-theme-dark"); + }); + + it("puts the close button on a bar once the page scrolls", () => { + renderView(); + const page = screen.getByTestId("ethena-launch"); + const header = screen.getByTestId("ethena-launch-header"); + + // Clear over the hero at the top, where the wave runs up behind the X. + expect(header).not.toHaveClass("EthenaLaunch__header--scrolled"); + + fireEvent.scroll(page, { target: { scrollTop: 120 } }); + expect(header).toHaveClass("EthenaLaunch__header--scrolled"); + + fireEvent.scroll(page, { target: { scrollTop: 0 } }); + expect(header).not.toHaveClass("EthenaLaunch__header--scrolled"); + }); + + describe("scroll cue", () => { + // jsdom has no layout: the cue 544px down a page scrolled 20px, under + // the 80px bar. + const layOut = () => { + const page = screen.getByTestId("ethena-launch"); + const cue = screen.getByRole("button", { + name: "Scroll to discover tokens", + }); + const scrollTo = jest.fn(); + page.scrollTo = scrollTo; + page.scrollTop = 20; + jest + .spyOn(page, "getBoundingClientRect") + .mockReturnValue({ top: 0 } as DOMRect); + jest + .spyOn(cue, "getBoundingClientRect") + .mockReturnValue({ top: 544 } as DOMRect); + Object.defineProperty( + screen.getByTestId("ethena-launch-header"), + "offsetHeight", + { configurable: true, value: 80 }, + ); + return { cue, scrollTo }; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("scrolls the tokens up until the cue is 20px below the bar", async () => { + renderView(); + const { cue, scrollTo } = layOut(); + + await userEvent.click(cue); + + expect(scrollTo).toHaveBeenCalledWith({ + top: 20 + 544 - 80 - 20, + behavior: "smooth", + }); + }); + + it("jumps there instead under reduced motion", async () => { + jest.spyOn(window, "matchMedia").mockImplementation( + (query: string) => + ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + }) as MediaQueryList, + ); + renderView(); + const { cue, scrollTo } = layOut(); + + await userEvent.click(cue); + + expect(scrollTo).toHaveBeenCalledWith({ + top: 20 + 544 - 80 - 20, + behavior: "auto", + }); + }); + }); + + it("swaps from XLM to each token on Sushi", async () => { + renderView(); + + await userEvent.click(screen.getByTestId("ethena-launch-swap-usde")); + await userEvent.click(screen.getByTestId("ethena-launch-swap-susde")); + + expect(screen.getByTestId("ethena-launch-swap-usde")).toHaveTextContent( + "Swap to USDe", + ); + expect(screen.getByTestId("ethena-launch-swap-susde")).toHaveTextContent( + "Swap to sUSDe", + ); + expect(openTab).toHaveBeenNthCalledWith(1, SUSHI_USDE_URL); + expect(openTab).toHaveBeenNthCalledWith(2, SUSHI_SUSDE_URL); + }); + + it("transfers each token to Stellar through Stargate", async () => { + renderView(); + + await userEvent.click(screen.getByTestId("ethena-launch-transfer-usde")); + await userEvent.click(screen.getByTestId("ethena-launch-transfer-susde")); + + expect(openTab).toHaveBeenNthCalledWith(1, STARGATE_USDE_URL); + expect(openTab).toHaveBeenNthCalledWith(2, STARGATE_SUSDE_URL); + }); + + it("receives USDe on the wallet's address screen", async () => { + renderView(); + + expect(screen.getByTestId("ethena-launch-receive-usde")).toHaveTextContent( + "Receive USDe", + ); + await userEvent.click(screen.getByTestId("ethena-launch-receive-usde")); + + expect(await screen.findByText("view-public-key")).toBeInTheDocument(); + }); + + it("receives sUSDe on the wallet's address screen", async () => { + renderView(); + + expect(screen.getByTestId("ethena-launch-receive-susde")).toHaveTextContent( + "Receive sUSDe", + ); + await userEvent.click(screen.getByTestId("ethena-launch-receive-susde")); + + expect(await screen.findByText("view-public-key")).toBeInTheDocument(); + }); + + it("comes back from Receive as it was left: same scroll, no intro", async () => { + renderView(); + const page = screen.getByTestId("ethena-launch"); + expect(page).not.toHaveClass("EthenaLaunch--returning"); + page.scrollTop = 420; + fireEvent.scroll(page); + + await userEvent.click(screen.getByTestId("ethena-launch-receive-usde")); + await userEvent.click(await screen.findByText("view-public-key")); + + const returned = await screen.findByTestId("ethena-launch"); + expect(returned).toHaveClass("EthenaLaunch--returning"); + expect(returned.scrollTop).toBe(420); + expect(screen.getByTestId("ethena-launch-header")).toHaveClass( + "EthenaLaunch__header--scrolled", + ); + }); + + it("still closes to its destination after a trip to Receive", async () => { + renderView(ROUTES.settings); + + await userEvent.click(screen.getByTestId("ethena-launch-receive-usde")); + await userEvent.click(await screen.findByText("view-public-key")); + await userEvent.click(await screen.findByTestId("ethena-launch-close")); + + expect(await screen.findByText("settings-home")).toBeInTheDocument(); + }); + + it("links out to Ethena's docs", async () => { + renderView(); + + await userEvent.click(screen.getByTestId("ethena-launch-learn-more")); + + expect(openTab).toHaveBeenCalledWith("https://docs.ethena.fi/"); + }); + + it("closes to the destination it was given", async () => { + renderView(ROUTES.settings); + + await userEvent.click(screen.getByTestId("ethena-launch-close")); + + expect(await screen.findByText("settings-home")).toBeInTheDocument(); + }); + + it("closes to its destination on Esc, keeping the popup open", async () => { + renderView(ROUTES.settings); + + // fireEvent returns false when the key was marked handled, which is what + // stops Chrome closing the popup on Esc. + expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(false); + + expect(await screen.findByText("settings-home")).toBeInTheDocument(); + }); + + it("closes to the account view when no destination was given", async () => { + renderView(); + + await userEvent.click(screen.getByTestId("ethena-launch-close")); + + expect(await screen.findByText("account-home")).toBeInTheDocument(); + }); +});