Skip to content

Commit 330cb45

Browse files
committed
feat: add (minimalist) wallet connection (keplr, leap)
1 parent 2abcd4a commit 330cb45

9 files changed

Lines changed: 668 additions & 13 deletions

File tree

‎e2e/vue.spec.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ test('visits the app root url', async ({ page }) => {
44
await page.goto('/')
55
await expect(page.locator('text=AXONE / SURFACE')).toBeVisible()
66
await expect(page.locator('h1')).toHaveText('GOVERN ACT')
7+
await page.getByRole('button', { name: 'Connect identity' }).click()
8+
await expect(page.getByRole('menuitem', { name: 'Keplr unavailable' })).toBeVisible()
9+
await expect(page.getByRole('menuitem', { name: 'Leap unavailable' })).toBeVisible()
10+
await expect(page.getByText('Install Keplr or Leap to connect an Axone identity.')).toBeVisible()
711
})

‎src/App.vue‎

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
44
import HeroCanvas from './components/HeroCanvas.vue'
55
import SurfaceActStream from './components/SurfaceActStream.vue'
66
import { useSurfaceActs } from './composables/useSurfaceActs'
7+
import { useWalletConnection } from './composables/useWalletConnection'
8+
import type { WalletProviderId } from './domain/wallet-connection'
79
import { networks, type Network } from './networks'
810
import { surfaceLaws } from './surfaceLaws'
911
@@ -20,6 +22,8 @@ const activeLawId = ref(defaultLaw.id)
2022
const activeActorIndex = ref(0)
2123
const selectedNetworkKey = ref<Network['key']>('testnet')
2224
const networkMenuOpen = ref(false)
25+
const walletMenuOpen = ref(false)
26+
const connectingProvider = ref<WalletProviderId | null>(null)
2327
const surfaceActionsEl = ref<HTMLElement | null>(null)
2428
const { acts, loading, error, polling } = useSurfaceActs()
2529
@@ -38,6 +42,22 @@ const activeActorLine = computed(() => actorLines[activeActorIndex.value] ?? act
3842
const selectedNetwork = computed(
3943
() => networks.find((network) => network.key === selectedNetworkKey.value) ?? networks[0]!,
4044
)
45+
const {
46+
status: walletConnectionStatus,
47+
connection: walletConnection,
48+
errorMessage: walletErrorMessage,
49+
availableProviders: availableWalletProviders,
50+
connect: connectWalletClient,
51+
disconnect: disconnectWalletClient,
52+
} = useWalletConnection(selectedNetwork)
53+
const walletTriggerLabel = computed(() => {
54+
if (walletConnectionStatus.value === 'connecting') {
55+
return 'Connecting…'
56+
}
57+
58+
const address = walletConnection.value?.address
59+
return address ? `${address.slice(0, 10)}…${address.slice(-6)}` : 'Connect identity'
60+
})
4161
4262
function updateReducedMotion(event?: MediaQueryListEvent) {
4363
prefersReducedMotion.value = event?.matches ?? motionQuery?.matches ?? false
@@ -55,10 +75,32 @@ function selectNetwork(networkKey: Network['key']) {
5575
5676
function toggleNetworkMenu() {
5777
networkMenuOpen.value = !networkMenuOpen.value
78+
walletMenuOpen.value = false
79+
}
80+
81+
function toggleWalletMenu() {
82+
walletMenuOpen.value = !walletMenuOpen.value
83+
networkMenuOpen.value = false
5884
}
5985
60-
function closeNetworkMenu() {
86+
function closeMenus() {
6187
networkMenuOpen.value = false
88+
walletMenuOpen.value = false
89+
}
90+
91+
async function connectWallet(provider: WalletProviderId) {
92+
connectingProvider.value = provider
93+
await connectWalletClient(provider)
94+
connectingProvider.value = null
95+
}
96+
97+
function disconnectWallet() {
98+
disconnectWalletClient()
99+
closeMenus()
100+
}
101+
102+
function walletProviderName(provider: WalletProviderId) {
103+
return provider === 'keplr' ? 'Keplr' : 'Leap'
62104
}
63105
64106
function rotateActorLine() {
@@ -97,13 +139,18 @@ onMounted(() => {
97139
documentClickHandler = (event) => {
98140
const target = event.target as Node | null
99141
const root = surfaceActionsEl.value
100-
if (networkMenuOpen.value && root && target && !root.contains(target)) {
101-
closeNetworkMenu()
142+
if (
143+
(networkMenuOpen.value || walletMenuOpen.value) &&
144+
root &&
145+
target &&
146+
!root.contains(target)
147+
) {
148+
closeMenus()
102149
}
103150
}
104151
documentKeydownHandler = (event) => {
105152
if (event.key === 'Escape') {
106-
closeNetworkMenu()
153+
closeMenus()
107154
}
108155
}
109156
document.addEventListener('click', documentClickHandler)
@@ -139,7 +186,84 @@ onBeforeUnmount(() => {
139186
<nav class="surface-bar" aria-label="Surface heading">
140187
<p class="surface-mark">AXONE / SURFACE</p>
141188
<div ref="surfaceActionsEl" class="surface-actions" aria-label="Surface actions">
142-
<button class="top-connect" type="button">Connect identity</button>
189+
<button
190+
class="top-connect"
191+
:class="{ 'is-pending': walletConnectionStatus === 'connecting' }"
192+
type="button"
193+
aria-haspopup="menu"
194+
:aria-expanded="walletMenuOpen"
195+
aria-controls="wallet-menu"
196+
:disabled="walletConnectionStatus === 'connecting'"
197+
@click="toggleWalletMenu"
198+
>
199+
{{ walletTriggerLabel }}
200+
</button>
201+
<div
202+
v-if="walletMenuOpen"
203+
id="wallet-menu"
204+
class="network-menu wallet-menu"
205+
role="menu"
206+
aria-label="Wallet connection"
207+
>
208+
<p
209+
v-if="walletConnectionStatus === 'connecting'"
210+
class="wallet-menu-status"
211+
role="status"
212+
aria-live="polite"
213+
>
214+
Connecting to
215+
{{ connectingProvider ? walletProviderName(connectingProvider) : 'wallet' }}…
216+
</p>
217+
<template v-else-if="walletConnection">
218+
<p class="wallet-menu-metadata">
219+
{{ walletProviderName(walletConnection.provider) }} ·
220+
{{ selectedNetwork.displayName }}
221+
</p>
222+
<button
223+
class="network-option wallet-option"
224+
type="button"
225+
role="menuitem"
226+
@click="disconnectWallet"
227+
>
228+
Disconnect identity
229+
</button>
230+
</template>
231+
<template v-else>
232+
<button
233+
class="network-option wallet-option"
234+
:class="{ 'is-disabled': !availableWalletProviders.includes('keplr') }"
235+
type="button"
236+
role="menuitem"
237+
:aria-disabled="!availableWalletProviders.includes('keplr')"
238+
:disabled="!availableWalletProviders.includes('keplr')"
239+
@click="connectWallet('keplr')"
240+
>
241+
{{ availableWalletProviders.includes('keplr') ? 'Keplr' : 'Keplr unavailable' }}
242+
</button>
243+
<button
244+
class="network-option wallet-option"
245+
:class="{ 'is-disabled': !availableWalletProviders.includes('leap') }"
246+
type="button"
247+
role="menuitem"
248+
:aria-disabled="!availableWalletProviders.includes('leap')"
249+
:disabled="!availableWalletProviders.includes('leap')"
250+
@click="connectWallet('leap')"
251+
>
252+
{{ availableWalletProviders.includes('leap') ? 'Leap' : 'Leap unavailable' }}
253+
</button>
254+
<p
255+
v-if="availableWalletProviders.length === 0"
256+
class="wallet-menu-status"
257+
role="status"
258+
aria-live="polite"
259+
>
260+
Install Keplr or Leap to connect an Axone identity.
261+
</p>
262+
</template>
263+
<p v-if="walletErrorMessage" class="wallet-menu-error" role="alert">
264+
{{ walletErrorMessage }}
265+
</p>
266+
</div>
143267
<span class="surface-actions-divider" aria-hidden="true">|</span>
144268
<button
145269
class="network-trigger"

‎src/__tests__/App.spec.ts‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import { mount } from '@vue/test-utils'
2-
import { describe, expect, it, vi } from 'vitest'
2+
import { describe, expect, it, vi, beforeEach } from 'vitest'
33
import { flushPromises } from '@vue/test-utils'
4+
import type { WalletConnectionClient } from '../domain/wallet-connection'
5+
6+
const walletClient = vi.hoisted(() => ({
7+
availableProviders: vi.fn<WalletConnectionClient['availableProviders']>(),
8+
connect: vi.fn<WalletConnectionClient['connect']>(),
9+
watchAccount: vi.fn<WalletConnectionClient['watchAccount']>(),
10+
}))
11+
12+
vi.mock('../infra/browser-wallet-connection-client', () => ({
13+
browserWalletConnectionClient: walletClient,
14+
}))
415

516
import App from '../App.vue'
617

718
function createCanvasContextMock() {
819
return {
920
setTransform: vi.fn<() => void>(),
1021
fillRect: vi.fn<() => void>(),
22+
clearRect: vi.fn<() => void>(),
1123
save: vi.fn<() => void>(),
1224
restore: vi.fn<() => void>(),
1325
beginPath: vi.fn<() => void>(),
@@ -44,7 +56,32 @@ function createResponse(body: unknown, ok = true, status = 200): Response {
4456
} as unknown as Response
4557
}
4658

59+
function installSuccessfulBrowserMocks() {
60+
Object.defineProperty(window, 'matchMedia', {
61+
writable: true,
62+
value: createMatchMediaMock(true),
63+
})
64+
Object.defineProperty(window, 'fetch', {
65+
writable: true,
66+
value: vi.fn<() => Promise<Response>>().mockResolvedValue(createResponse(createEmptyTxList())),
67+
})
68+
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
69+
writable: true,
70+
value: vi
71+
.fn<() => CanvasRenderingContext2D | null>()
72+
.mockReturnValue(createCanvasContextMock()),
73+
})
74+
}
75+
4776
describe('App', () => {
77+
beforeEach(() => {
78+
walletClient.availableProviders.mockReset()
79+
walletClient.availableProviders.mockReturnValue([])
80+
walletClient.connect.mockReset()
81+
walletClient.watchAccount.mockReset()
82+
walletClient.watchAccount.mockReturnValue(vi.fn())
83+
})
84+
4885
it('renders the live act homepage shell', async () => {
4986
const matchMedia = createMatchMediaMock(true)
5087

@@ -113,4 +150,38 @@ describe('App', () => {
113150
expect(wrapper.text()).not.toContain('RECORDS')
114151
expect(wrapper.text()).not.toContain('LAST SYNC')
115152
})
153+
it('connects a chosen wallet and disconnects the local identity', async () => {
154+
installSuccessfulBrowserMocks()
155+
walletClient.availableProviders.mockReturnValue(['keplr'])
156+
const address = 'axone1abcdefghijklmnop'
157+
walletClient.connect.mockResolvedValue({ address })
158+
159+
const wrapper = mount(App)
160+
await flushPromises()
161+
await wrapper.vm.$nextTick()
162+
163+
await wrapper.get('.top-connect').trigger('click')
164+
await wrapper.get('#wallet-menu button').trigger('click')
165+
await flushPromises()
166+
await wrapper.vm.$nextTick()
167+
168+
expect(wrapper.get('.top-connect').text()).toBe(`${address.slice(0, 10)}…${address.slice(-6)}`)
169+
expect(wrapper.text()).toContain('Keplr · axone-testnet')
170+
171+
await wrapper.get('#wallet-menu button').trigger('click')
172+
expect(wrapper.get('.top-connect').text()).toBe('Connect identity')
173+
})
174+
175+
it('shows unavailable wallet options when no extension is installed', async () => {
176+
installSuccessfulBrowserMocks()
177+
const wrapper = mount(App)
178+
await flushPromises()
179+
await wrapper.vm.$nextTick()
180+
181+
await wrapper.get('.top-connect').trigger('click')
182+
183+
expect(wrapper.text()).toContain('Keplr unavailable')
184+
expect(wrapper.text()).toContain('Leap unavailable')
185+
expect(wrapper.text()).toContain('Install Keplr or Leap to connect an Axone identity.')
186+
})
116187
})

0 commit comments

Comments
 (0)