Skip to content

Commit

Permalink
feat: add a quick way to authorize my current account (#1764)
Browse files Browse the repository at this point in the history
- Closes #1719
- Closes `FE-1174`

---

| 📷 Demo |
| --- |
| <video
src="https://github.com/user-attachments/assets/31acded5-4781-43e9-bab1-e6e8f636d3bb"
/> |
  • Loading branch information
helciofranco authored Jan 18, 2025
1 parent 2460bee commit d81ee48
Show file tree
Hide file tree
Showing 9 changed files with 318 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changeset/blue-ducks-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fuels-wallet": minor
---

Add current account connection status to the header
85 changes: 68 additions & 17 deletions packages/app/playwright/e2e/Accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import test, { chromium, expect } from '@playwright/test';
import {
getButtonByText,
getByAriaLabel,
getElementByText,
getInputByName,
hasAriaLabel,
hasText,
Expand Down Expand Up @@ -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();
Expand All @@ -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}`
Expand All @@ -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}`
Expand All @@ -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}`
Expand All @@ -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}`
Expand All @@ -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}`
Expand All @@ -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}`
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConnectionStatus>(() => {
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 (
<Box
css={styles.wrapper}
data-open={status === ConnectionStatus.OtherAccount && !dismissed}
>
<Alert status="info" css={styles.alert}>
<Alert.Description as="div">
<Avatar.Generated
size="sm"
hash={account?.address as string}
css={{ boxShadow: '$sm', flexShrink: 0 }}
/>

<VStack gap="$1">
<span>
{account?.name || 'This account'} isn't connected to{' '}
{origin?.short || 'this app'}
</span>

<Text color="scalesBlue10" css={styles.cta} onClick={onConnect}>
Connect account
</Text>
</VStack>

<IconButton
icon={Icon.is('X')}
aria-label="Close"
variant="link"
onPress={onDismiss}
/>
</Alert.Description>
</Alert>
</Box>
);
};

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',
},
}),
};
16 changes: 16 additions & 0 deletions packages/app/src/systems/Account/hooks/useCurrentAccount.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
28 changes: 28 additions & 0 deletions packages/app/src/systems/CRX/hooks/useCurrentTab.ts
Original file line number Diff line number Diff line change
@@ -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<CurrentTab | undefined>();

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,
};
}
26 changes: 26 additions & 0 deletions packages/app/src/systems/DApp/hooks/useConnection.ts
Original file line number Diff line number Diff line change
@@ -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<Connection | undefined>();

const fetchConnection = useCallback(async () => {
if (!origin) return;
const existingConnection = await ConnectionService.getConnection(origin);
setConnection(existingConnection);
}, [origin]);

useEffect(() => {
fetchConnection();
}, [fetchConnection]);

return {
connection,
fetchConnection,
};
};
27 changes: 27 additions & 0 deletions packages/app/src/systems/DApp/hooks/useOrigin.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
Loading

0 comments on commit d81ee48

Please sign in to comment.