diff --git a/.changeset/blue-ducks-brush.md b/.changeset/blue-ducks-brush.md new file mode 100644 index 0000000000..eb27805570 --- /dev/null +++ b/.changeset/blue-ducks-brush.md @@ -0,0 +1,5 @@ +--- +"fuels-wallet": minor +--- + +Add current account connection status to the header diff --git a/packages/app/playwright/e2e/Accounts.test.ts b/packages/app/playwright/e2e/Accounts.test.ts index 5bc46bb424..599eb355ba 100644 --- a/packages/app/playwright/e2e/Accounts.test.ts +++ b/packages/app/playwright/e2e/Accounts.test.ts @@ -4,7 +4,6 @@ import test, { chromium, expect } from '@playwright/test'; import { getButtonByText, getByAriaLabel, - getElementByText, getInputByName, hasAriaLabel, hasText, @@ -44,14 +43,26 @@ test.describe('New Accounts', () => { await visit(page, '/wallet'); await hasText(page, /Assets/i); await getByAriaLabel(page, 'Accounts').click(); - await hasText(page, data.accounts[0].name); - await hasText(page, data.accounts[1].name); - await getByAriaLabel(page, data.accounts[1].name).click({ - position: { - x: 10, - y: 10, - }, - }); + await expect( + page.getByRole('heading', { name: data.accounts[0].name, exact: true }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { + name: data.accounts[1].name, + exact: true, + }) + ).toBeVisible(); + await page + .getByRole('heading', { + name: data.accounts[1].name, + exact: true, + }) + .click({ + position: { + x: 10, + y: 10, + }, + }); await waitUrl(page, '/wallet'); await hasText(page, /Assets/i); const address = data.accounts[1].address.toString(); @@ -62,7 +73,12 @@ test.describe('New Accounts', () => { await visit(page, '/wallet'); await hasText(page, /Assets/i); await getByAriaLabel(page, 'Accounts').click(); - await hasText(page, data.accounts[0].name); + await expect( + page.getByRole('heading', { + name: data.accounts[0].name, + exact: true, + }) + ).toBeVisible(); await getByAriaLabel( page, `Account Actions ${data.accounts[0].name}` @@ -82,7 +98,12 @@ test.describe('New Accounts', () => { await visit(page, '/wallet'); await hasText(page, /Assets/i); await getByAriaLabel(page, 'Accounts').click(); - await hasText(page, data.accounts[0].name); + await expect( + page.getByRole('heading', { + name: data.accounts[0].name, + exact: true, + }) + ).toBeVisible(); await getByAriaLabel( page, `Account Actions ${data.accounts[0].name}` @@ -100,8 +121,18 @@ test.describe('New Accounts', () => { await visit(page, '/wallet'); await hasText(page, /Assets/i); await getByAriaLabel(page, 'Accounts').click(); - await hasText(page, data.accounts[0].name); - await hasText(page, data.accounts[1].name); + await expect( + page.getByRole('heading', { + name: data.accounts[0].name, + exact: true, + }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { + name: data.accounts[1].name, + exact: true, + }) + ).toBeVisible(); await getByAriaLabel( page, `Account Actions ${data.accounts[1].name}` @@ -110,7 +141,12 @@ test.describe('New Accounts', () => { await hasText(page, 'Show hidden accounts'); await page.getByText(data.accounts[1].name).isHidden(); await getByAriaLabel(page, 'Toggle hidden accounts').click(); - await hasText(page, data.accounts[1].name); + await expect( + page.getByRole('heading', { + name: data.accounts[1].name, + exact: true, + }) + ).toBeVisible(); await getByAriaLabel( page, `Account Actions ${data.accounts[1].name}` @@ -123,8 +159,18 @@ test.describe('New Accounts', () => { await visit(page, '/wallet'); await hasText(page, /Assets/i); await getByAriaLabel(page, 'Accounts').click(); - await hasText(page, data.accounts[0].name); - await hasText(page, data.accounts[1].name); + await expect( + page.getByRole('heading', { + name: data.accounts[0].name, + exact: true, + }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { + name: data.accounts[1].name, + exact: true, + }) + ).toBeVisible(); await getByAriaLabel( page, `Account Actions ${data.accounts[1].name}` @@ -143,7 +189,12 @@ test.describe('New Accounts', () => { await visit(page, '/wallet'); await hasText(page, /Assets/i); await getByAriaLabel(page, 'Accounts').click(); - await hasText(page, data.accounts[0].name); + await expect( + page.getByRole('heading', { + name: data.accounts[0].name, + exact: true, + }) + ).toBeVisible(); await getByAriaLabel( page, `Account Actions ${data.accounts[0].name}` diff --git a/packages/app/src/systems/Account/components/QuickAccountConnect/QuickAccountConnect.tsx b/packages/app/src/systems/Account/components/QuickAccountConnect/QuickAccountConnect.tsx new file mode 100644 index 0000000000..e761ac0be1 --- /dev/null +++ b/packages/app/src/systems/Account/components/QuickAccountConnect/QuickAccountConnect.tsx @@ -0,0 +1,144 @@ +import { cssObj } from '@fuel-ui/css'; +import { + Alert, + Avatar, + Box, + Icon, + IconButton, + Text, + VStack, + toast, +} from '@fuel-ui/react'; +import { useEffect, useMemo, useState } from 'react'; +import { useCurrentTab } from '~/systems/CRX/hooks/useCurrentTab'; +import { useConnection } from '~/systems/DApp/hooks/useConnection'; +import { useOrigin } from '~/systems/DApp/hooks/useOrigin'; +import { ConnectionService } from '~/systems/DApp/services'; +import { useCurrentAccount } from '../../hooks/useCurrentAccount'; + +enum ConnectionStatus { + CurrentAccount = 'CURRENT_ACCOUNT', + OtherAccount = 'OTHER_ACCOUNT', + NoAccounts = 'NO_ACCOUNTS', +} + +export const getDismissKey = (account: string, origin: string) => { + return `quick-account-connect-${account}-${origin}`; +}; + +export const QuickAccountConnect = () => { + const { account } = useCurrentAccount(); + const { currentTab } = useCurrentTab(); + const origin = useOrigin({ url: currentTab?.url }); + const { connection, fetchConnection } = useConnection({ + origin: origin?.full, + }); + + const [dismissed, setDismissed] = useState(true); + + const status = useMemo(() => { + if (!account || !connection) { + return ConnectionStatus.NoAccounts; + } + + if (connection.accounts.includes(account.address)) { + return ConnectionStatus.CurrentAccount; + } + + return ConnectionStatus.OtherAccount; + }, [account, connection]); + + const onConnect = async () => { + if (!origin || !account) return; + await ConnectionService.addAccountTo({ + origin: origin.full, + account: account.address, + }); + await fetchConnection(); + toast.success(`${account?.name} connected`); + }; + + const onDismiss = () => { + if (!origin || !account) return; + setDismissed(true); + localStorage.setItem(getDismissKey(account.address, origin.full), 'true'); + }; + + useEffect(() => { + if (!origin || !account) return; + const hasDismissed = localStorage.getItem( + getDismissKey(account.address, origin.full) + ); + setDismissed(!!hasDismissed); + }, [account, origin]); + + return ( + + + + + + + + {account?.name || 'This account'} isn't connected to{' '} + {origin?.short || 'this app'} + + + + Connect account + + + + + + + + ); +}; + +const styles = { + wrapper: cssObj({ + position: 'fixed', + paddingLeft: '$4', + paddingRight: '$4', + paddingBottom: '$4', + bottom: 0, + zIndex: '$2', + opacity: 0, + transition: 'opacity 0.2s ease-in-out', + pointerEvents: 'none', + + '&[data-open="true"]': { + opacity: 1, + pointerEvents: 'auto', + }, + }), + alert: cssObj({ + '& .fuel_Alert-icon': { + display: 'none', + }, + '& .fuel_Alert-description': { + display: 'flex', + gap: '$2', + alignItems: 'flex-start', + }, + }), + cta: cssObj({ + '&:hover': { + cursor: 'pointer', + textDecoration: 'underline', + }, + }), +}; diff --git a/packages/app/src/systems/Account/hooks/useCurrentAccount.ts b/packages/app/src/systems/Account/hooks/useCurrentAccount.ts new file mode 100644 index 0000000000..b093da47e3 --- /dev/null +++ b/packages/app/src/systems/Account/hooks/useCurrentAccount.ts @@ -0,0 +1,16 @@ +import { Services, store } from '~/store'; +import type { AccountsMachineState } from '../machines'; + +const selectors = { + account(state: AccountsMachineState) { + return state.context.account; + }, +}; + +export function useCurrentAccount() { + const account = store.useSelector(Services.accounts, selectors.account); + + return { + account, + }; +} diff --git a/packages/app/src/systems/CRX/hooks/useCurrentTab.ts b/packages/app/src/systems/CRX/hooks/useCurrentTab.ts new file mode 100644 index 0000000000..c5959e224a --- /dev/null +++ b/packages/app/src/systems/CRX/hooks/useCurrentTab.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +interface CurrentTab { + url: string | undefined; + title: string | undefined; + faviconUrl: string | undefined; +} + +export function useCurrentTab() { + const [currentTab, setCurrentTab] = useState(); + + useEffect(() => { + if (!chrome?.tabs) return; + + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const currentTab = tabs[0]; + setCurrentTab({ + url: currentTab?.url, + title: currentTab?.title, + faviconUrl: currentTab?.favIconUrl, + }); + }); + }, []); + + return { + currentTab, + }; +} diff --git a/packages/app/src/systems/DApp/hooks/useConnection.ts b/packages/app/src/systems/DApp/hooks/useConnection.ts new file mode 100644 index 0000000000..486b733fc7 --- /dev/null +++ b/packages/app/src/systems/DApp/hooks/useConnection.ts @@ -0,0 +1,26 @@ +import type { Connection } from '@fuel-wallet/types'; +import { useCallback, useEffect, useState } from 'react'; +import { ConnectionService } from '../services'; + +interface UseConnectionProps { + origin: string | undefined; +} + +export const useConnection = ({ origin }: UseConnectionProps) => { + const [connection, setConnection] = useState(); + + const fetchConnection = useCallback(async () => { + if (!origin) return; + const existingConnection = await ConnectionService.getConnection(origin); + setConnection(existingConnection); + }, [origin]); + + useEffect(() => { + fetchConnection(); + }, [fetchConnection]); + + return { + connection, + fetchConnection, + }; +}; diff --git a/packages/app/src/systems/DApp/hooks/useOrigin.ts b/packages/app/src/systems/DApp/hooks/useOrigin.ts new file mode 100644 index 0000000000..6c7a6c8f39 --- /dev/null +++ b/packages/app/src/systems/DApp/hooks/useOrigin.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react'; + +interface UseOriginProps { + url: string | undefined; +} + +interface Origin { + short: string; + full: string; +} + +const parseUrl = (url: string): Origin | undefined => { + try { + const { protocol, hostname, port } = new URL(url); + const short = `${hostname}${port ? `:${port}` : ''}`; + return { + short, + full: `${protocol}//${short}`, + }; + } catch (_e) { + return undefined; + } +}; + +export const useOrigin = ({ url }: UseOriginProps) => { + return useMemo(() => (url ? parseUrl(url) : undefined), [url]); +}; diff --git a/packages/app/src/systems/Home/pages/Home/Home.tsx b/packages/app/src/systems/Home/pages/Home/Home.tsx index 1fd283262c..067d2b7779 100644 --- a/packages/app/src/systems/Home/pages/Home/Home.tsx +++ b/packages/app/src/systems/Home/pages/Home/Home.tsx @@ -7,6 +7,7 @@ import { useBalanceVisibility } from '~/systems/Core/hooks/useVisibility'; import { BalanceAssets } from '~/systems/Account/components/BalanceAssets/BalanceAssets'; import { BalanceNFTs } from '~/systems/Account/components/BalanceNFTs/BalanceNFTs'; +import { QuickAccountConnect } from '~/systems/Account/components/QuickAccountConnect/QuickAccountConnect'; import { HomeActions } from '../../components'; export function Home() { @@ -26,6 +27,7 @@ export function Home() { + a?.address === account); } + function toggleAccount(account: string, isConnected?: boolean) { service.send({ type: isConnected ? 'REMOVE_ACCOUNT' : 'ADD_ACCOUNT',