Skip to content

Commit 7d2d596

Browse files
feat(mosaic): wire up user profile Web3 wallets
1 parent a0df666 commit 7d2d596

15 files changed

Lines changed: 701 additions & 13 deletions

‎.changeset/web3-wallets-wire-up.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
},
6464
"dependencies": {
6565
"@clerk/shared": "workspace:^",
66-
"@floating-ui/react": "catalog:repo"
66+
"@floating-ui/react": "catalog:repo",
67+
"@wallet-standard/core": "catalog:module-manager"
6768
},
6869
"devDependencies": {
6970
"@stylexjs/eslint-plugin": "0.19.0",

‎packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect
3535
onRemoveConnectedAccount?: (id: string) => void | Promise<void>;
3636
/** Connected accounts section. Replaces the one built from `connectedAccounts` and its callbacks. */
3737
connectedAccountsSlot?: ReactNode;
38+
web3WalletsSlot?: ReactNode;
3839
onConnectWeb3Wallet?: (id: string) => void;
3940
onSetPrimaryWeb3Wallet?: (id: string) => void;
4041
onRemoveWeb3Wallet?: (id: string) => void | Promise<void>;
@@ -80,6 +81,7 @@ export function UserProfileProfilePanelView({
8081
onConnectAccount,
8182
onRemoveConnectedAccount,
8283
connectedAccountsSlot,
84+
web3WalletsSlot,
8385
onConnectWeb3Wallet,
8486
onSetPrimaryWeb3Wallet,
8587
onRemoveWeb3Wallet,
@@ -136,14 +138,16 @@ export function UserProfileProfilePanelView({
136138
onRemove={onRemoveConnectedAccount}
137139
/>
138140
)}
139-
<UserProfileWeb3WalletsSectionView
140-
fallbackFocus={() => pageTitle.current}
141-
wallets={web3Wallets}
142-
availableProviders={availableWeb3Providers}
143-
onConnect={onConnectWeb3Wallet}
144-
onRemove={onRemoveWeb3Wallet}
145-
onSetPrimary={onSetPrimaryWeb3Wallet}
146-
/>
141+
{web3WalletsSlot ?? (
142+
<UserProfileWeb3WalletsSectionView
143+
fallbackFocus={() => pageTitle.current}
144+
wallets={web3Wallets}
145+
availableProviders={availableWeb3Providers}
146+
onConnect={onConnectWeb3Wallet}
147+
onRemove={onRemoveWeb3Wallet}
148+
onSetPrimary={onSetPrimaryWeb3Wallet}
149+
/>
150+
)}
147151
{onDeleteAccount ? <UserProfileDeleteSectionView onDelete={onDeleteAccount} /> : null}
148152
</Panel.Sections>
149153
</Panel.Root>

‎packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Ref } from 'react';
44
import type { ActionMenuAction } from '../../components/action-menu';
55
import { ActionMenu } from '../../components/action-menu';
66
import { Badge } from '../../components/badge';
7-
import { Button } from '../../components/button';
7+
import { SubmitButton } from '../../components/button';
88
import { Icon, IconFrame } from '../../components/icon';
99
import { Section } from '../../components/section';
1010
import { fill, useMessages } from '../../localization';
@@ -14,12 +14,16 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-prof
1414
export function UserProfileWeb3WalletRowView({
1515
wallet,
1616
triggerRef,
17+
isPending = false,
18+
isDisabled = false,
1719
onConnect,
1820
onSetPrimary,
1921
onRemove,
2022
}: {
2123
wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider;
2224
triggerRef?: Ref<HTMLButtonElement>;
25+
isPending?: boolean;
26+
isDisabled?: boolean;
2327
onConnect?: (id: string) => void;
2428
onSetPrimary?: (id: string) => void;
2529
onRemove?: (wallet: UserProfileWeb3Wallet) => void;
@@ -31,7 +35,7 @@ export function UserProfileWeb3WalletRowView({
3135
const shortAddress = address && (address.length <= 10 ? address : `${address.slice(0, 6)}...${address.slice(-4)}`);
3236
const actions: ActionMenuAction[] = [];
3337

34-
if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) {
38+
if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary && !isDisabled) {
3539
actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) });
3640
}
3741
if (linkedWallet && onRemove && linkedWallet.canRemove !== false) {
@@ -79,10 +83,12 @@ export function UserProfileWeb3WalletRowView({
7983
</Section.Content>
8084
{onConnect ? (
8185
<Section.Actions>
82-
<Button
86+
<SubmitButton
8387
color='neutral'
8488
size='sm'
8589
variant='outline'
90+
isPending={isPending}
91+
disabled={isDisabled && !isPending}
8692
aria-label={fill(m.connectLabel, { provider: wallet.provider ?? '' })}
8793
onClick={() => onConnect(wallet.id)}
8894
>
@@ -92,7 +98,7 @@ export function UserProfileWeb3WalletRowView({
9298
placement='inline-end'
9399
size='sm'
94100
/>
95-
</Button>
101+
</SubmitButton>
96102
</Section.Actions>
97103
) : actions.length > 0 ? (
98104
<Section.Actions>

‎packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface UserProfileWeb3WalletsSectionViewProps {
2929
fallbackFocus?: () => HTMLElement | null;
3030
wallets: UserProfileWeb3Wallet[];
3131
availableProviders?: UserProfileWeb3Provider[];
32+
pendingId?: string;
3233
onConnect?: (id: string) => void;
3334
onSetPrimary?: (id: string) => void;
3435
onRemove?: (id: string) => void | Promise<void>;
@@ -38,6 +39,7 @@ export function UserProfileWeb3WalletsSectionView({
3839
wallets,
3940
fallbackFocus,
4041
availableProviders = [],
42+
pendingId,
4143
onConnect,
4244
onSetPrimary,
4345
onRemove,
@@ -55,6 +57,7 @@ export function UserProfileWeb3WalletsSectionView({
5557
});
5658
const removeWallet = useMemo(() => Confirmation.createHandle<UserProfileWeb3Wallet>(), []);
5759
const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect));
60+
const isBusy = pendingId !== undefined;
5861

5962
return (
6063
<>
@@ -70,6 +73,7 @@ export function UserProfileWeb3WalletsSectionView({
7073
key={wallet.id}
7174
wallet={wallet}
7275
triggerRef={removalFocus.registerTrigger(wallet.id)}
76+
isDisabled={isBusy}
7377
onSetPrimary={onSetPrimary}
7478
onRemove={onRemove ? wallet => removeWallet.open(wallet) : undefined}
7579
/>
@@ -79,6 +83,8 @@ export function UserProfileWeb3WalletsSectionView({
7983
<UserProfileWeb3WalletRowView
8084
key={provider.id}
8185
wallet={provider}
86+
isPending={pendingId === provider.id}
87+
isDisabled={isBusy}
8288
onConnect={onConnect}
8389
/>
8490
))
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { getWallets } from '@wallet-standard/core';
2+
import { useEffect, useState } from 'react';
3+
4+
import { Banner } from '../../../components/banner';
5+
import { Button, SubmitButton } from '../../../components/button';
6+
import { Card } from '../../../components/card';
7+
import { Dialog } from '../../../components/dialog';
8+
import { useMessages } from '../../../localization';
9+
10+
function installedSolanaWallets() {
11+
return getWallets()
12+
.get()
13+
.filter(
14+
wallet => wallet.chains.some(chain => chain.startsWith('solana:')) && 'solana:signMessage' in wallet.features,
15+
);
16+
}
17+
18+
export function UserProfileSolanaWalletDialog({
19+
open,
20+
pending,
21+
error,
22+
onOpenChange,
23+
onConnect,
24+
}: {
25+
open: boolean;
26+
pending: boolean;
27+
error?: string;
28+
onOpenChange: (open: boolean) => void;
29+
onConnect: (walletName: string) => void;
30+
}) {
31+
const m = useMessages('userProfileWeb3Wallets');
32+
const [wallets, setWallets] = useState(installedSolanaWallets);
33+
34+
useEffect(() => {
35+
const registry = getWallets();
36+
const update = () => setWallets(installedSolanaWallets());
37+
const unregister = registry.on('register', update);
38+
const unadvertise = registry.on('unregister', update);
39+
update();
40+
return () => {
41+
unregister();
42+
unadvertise();
43+
};
44+
}, []);
45+
46+
return (
47+
<Dialog.Root
48+
open={open}
49+
onOpenChange={onOpenChange}
50+
>
51+
<Dialog.Popup variant='card'>
52+
<Card.Root
53+
elevation='overlay'
54+
renderBranding={false}
55+
>
56+
<Card.Header>
57+
<Card.Title>{m.solanaDialog.title}</Card.Title>
58+
<Card.Description>{m.solanaDialog.description}</Card.Description>
59+
</Card.Header>
60+
<Card.Content>
61+
{error ? (
62+
<Banner.Root
63+
role='alert'
64+
color='negative'
65+
>
66+
<Banner.Label>{error}</Banner.Label>
67+
</Banner.Root>
68+
) : null}
69+
{wallets.length === 0 ? (
70+
<p>
71+
{m.solanaDialog.noneAvailable}{' '}
72+
<a
73+
href='https://solana.com/solana-wallets'
74+
target='_blank'
75+
rel='noreferrer'
76+
>
77+
{m.solanaDialog.findWallet}
78+
</a>
79+
</p>
80+
) : null}
81+
{wallets.map(wallet => (
82+
<SubmitButton
83+
key={wallet.name}
84+
type='button'
85+
variant='outline'
86+
color='neutral'
87+
fullWidth
88+
isPending={pending}
89+
disabled={pending}
90+
onClick={() => onConnect(wallet.name)}
91+
>
92+
{wallet.icon ? (
93+
<img
94+
src={wallet.icon}
95+
alt=''
96+
aria-hidden
97+
width={20}
98+
height={20}
99+
/>
100+
) : null}
101+
{wallet.name}
102+
</SubmitButton>
103+
))}
104+
</Card.Content>
105+
<Card.Footer>
106+
<Dialog.Close
107+
render={
108+
<Button
109+
variant='outline'
110+
color='neutral'
111+
fullWidth
112+
>
113+
{m.solanaDialog.cancel}
114+
</Button>
115+
}
116+
/>
117+
</Card.Footer>
118+
</Card.Root>
119+
</Dialog.Popup>
120+
</Dialog.Root>
121+
);
122+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { createDeferredPromise } from '@clerk/shared/utils';
2+
import { act, renderHook, waitFor } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller';
6+
7+
const wallets = [{ id: 'wallet_1', address: '0x1234567890abcdef', isVerified: true }];
8+
const availableProviders = [{ id: 'web3_metamask_signature', provider: 'MetaMask' }];
9+
10+
describe('Web3 wallet controller', () => {
11+
it('WEB3-06 holds a pending connection and blocks another attempt', async () => {
12+
const pending = createDeferredPromise();
13+
const connect = vi.fn(() => pending.promise);
14+
const { result } = renderHook(() =>
15+
useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }),
16+
);
17+
18+
act(() => {
19+
void result.current.onConnect('web3_metamask_signature');
20+
void result.current.onConnect('web3_metamask_signature');
21+
void result.current.onConnect('web3_solana_signature');
22+
});
23+
expect(result.current.pendingId).toBe('web3_metamask_signature');
24+
expect(connect).toHaveBeenCalledOnce();
25+
expect(result.current.solanaPickerOpen).toBe(false);
26+
27+
await act(async () => {
28+
pending.resolve();
29+
await pending.promise;
30+
});
31+
await waitFor(() => expect(result.current.pendingId).toBeUndefined());
32+
});
33+
34+
it('WEB3-07 surfaces connection errors on the provider and allows retry', async () => {
35+
const connect = vi
36+
.fn()
37+
.mockRejectedValueOnce(new Error('Wallet extension missing'))
38+
.mockResolvedValueOnce(undefined);
39+
const { result } = renderHook(() =>
40+
useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }),
41+
);
42+
43+
await act(async () => result.current.onConnect('web3_metamask_signature'));
44+
expect(result.current.availableProviders[0].connectError).toBe('Wallet extension missing');
45+
await act(async () => result.current.onConnect('web3_metamask_signature'));
46+
expect(result.current.availableProviders[0].connectError).toBeUndefined();
47+
});
48+
49+
it('WEB3-08 surfaces primary selection errors on the wallet', async () => {
50+
const { result } = renderHook(() =>
51+
useUserProfileWeb3WalletsController({
52+
wallets,
53+
availableProviders,
54+
connect: vi.fn(),
55+
setPrimary: vi.fn().mockRejectedValue(new Error('Reverification canceled')),
56+
}),
57+
);
58+
59+
await act(async () => result.current.onSetPrimary('wallet_1'));
60+
expect(result.current.wallets[0].primaryError).toBe('Reverification canceled');
61+
});
62+
63+
it('WEB3-09 opens the Solana picker before connecting', () => {
64+
const connect = vi.fn();
65+
const { result } = renderHook(() =>
66+
useUserProfileWeb3WalletsController({ wallets, availableProviders, connect, setPrimary: vi.fn() }),
67+
);
68+
act(() => void result.current.onConnect('web3_solana_signature'));
69+
expect(result.current.solanaPickerOpen).toBe(true);
70+
expect(connect).not.toHaveBeenCalled();
71+
act(() => result.current.closeSolanaPicker());
72+
expect(result.current.solanaPickerOpen).toBe(false);
73+
});
74+
});

0 commit comments

Comments
 (0)