Skip to content

Commit

Permalink
connected wallet dialogs and enable/disable (#689)
Browse files Browse the repository at this point in the history
* table cells to new components

* stub dialoges

* wire up revoke dialog stub

* ui for revoke wallet dialog

* style revoke dialog title area

* working connect dialog

* homogenize wallet table cell props to greatly simplify table row component

* explain in code comment

* xyo and provider logos

* decompose into small components

* track enabled state for all wallets

* enabled-only wallets, expose wallets as getter, rename interface

* more rename and make persistence configurable

* configure via class property instead of constructor

* even better naming and code comments

* support ignoring connect explanation dialog

* swap revoke button for info icon

* toggle for ignoring connect dialog

* more generic component name

* better default localStorage key

* missing image.d.ts
  • Loading branch information
jonesmac authored Dec 5, 2023
1 parent 4cdc0a7 commit 4b759c5
Show file tree
Hide file tree
Showing 37 changed files with 655 additions and 117 deletions.
128 changes: 128 additions & 0 deletions packages/sdk/packages/connected-accounts/src/classes/EnabledWallets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { DiscoveredWallets, EIP6963Connector } from '@xylabs/react-crypto'

const DEFAULT_LOCAL_STORAGE_KEY = 'XYO|EnabledWallets'

/**
* State for storing wallets and their enabled/disabled status by name
*/
export interface EnabledEthWalletsState {
[rdns: string]: {
enabled: boolean
wallet: EIP6963Connector
}
}

/**
* State for storing only enabled/disabled status of a wallet by name
*/
export interface EnabledWalletsSavedState {
[rdns: string]: boolean
}

export type WalletListener = () => void

export class EnabledEthWalletConnections {
// control whether or not enabled/disabled preferences are persisted (i.e. in localStorage)
public persistPreferences = true

// Map of wallet names and their enabled/disabled state
private enabledWallets: EnabledWalletsSavedState = {}

// Map of wallet names, their enabled/disabled state, and their wallet class
private ethWalletsState: EnabledEthWalletsState = {}

// list of listeners that want to be notified on wallet changes
private listeners: WalletListener[] = []

// key to use in localStorage when persisting preferences
private localStorageKey = DEFAULT_LOCAL_STORAGE_KEY

constructor(localStorageKey = DEFAULT_LOCAL_STORAGE_KEY) {
this.localStorageKey = localStorageKey
this.reviveSettings()
}

get wallets() {
return this.ethWalletsState
}

disableWallet(rdns: string) {
this.toggleEnabledWallet(rdns, false)
}

enableWallet(rdns: string) {
this.toggleEnabledWallet(rdns, true)
}

/**
* Given a new set of wallets, set their enabled state based off previous preferences
*/
resetWallets(wallets: DiscoveredWallets) {
const newWallets: EnabledEthWalletsState = {}

const addWallet = ([walletName, wallet]: [string, EIP6963Connector]) => {
newWallets[walletName] = {
// preserve the existing enabled state
enabled: walletName in this.enabledWallets ? this.enabledWallets[walletName] : true,
wallet,
}
}

Object.entries(wallets).forEach(addWallet.bind(this))
this.ethWalletsState = newWallets
this.emitChange()
}

subscribe(listener: WalletListener) {
this.listeners = [...this.listeners, listener]
return () => {
this.listeners = this.listeners.filter((existingListener) => existingListener !== listener)
}
}

toggleEnabledWallet(rdns: string, enabled: boolean) {
if (rdns && this.ethWalletsState[rdns]) {
this.ethWalletsState[rdns].enabled = enabled
this.ethWalletsState = { ...this.ethWalletsState }
this.emitChange()
}
}

private emitChange() {
for (const listener of this.listeners) {
listener()
}

this.persistSettings()
}

private isPersistance(method: () => void) {
if (this.persistPreferences) {
method()
}
}

private persistSettings() {
this.isPersistance(() => {
// convert wallet enabled selections into serializable state
const enabledWallets = Object.entries(this.ethWalletsState).reduce((acc, [rdns, { enabled }]) => {
acc[rdns] = enabled
return acc
}, {} as EnabledWalletsSavedState)

localStorage.setItem(this.localStorageKey, JSON.stringify(enabledWallets))
})
}

private reviveSettings() {
this.isPersistance(() => {
const existingEntries = localStorage.getItem(this.localStorageKey)
try {
const entries = existingEntries ? JSON.parse(existingEntries) : {}
this.enabledWallets = entries
} catch (e) {
console.warn(`Error parsing saved enabled wallet entries: ${(e as Error).message}`)
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './EnabledWallets'
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/* eslint-disable import/no-internal-modules */
import { Meta, StoryFn } from '@storybook/react'
import { useState } from 'react'

import { ConnectedAccountsFlexbox } from './ConnectedAccountsFlexbox'

const StorybookEntry: Meta = {
argTypes: {},
component: ConnectedAccountsFlexbox,
parameters: {
actions: { argTypesRegex: '!(^on.*)' },
docs: {
page: null,
},
Expand All @@ -18,9 +20,17 @@ const Template: StoryFn<typeof ConnectedAccountsFlexbox> = (props) => {
return <ConnectedAccountsFlexbox {...props} />
}

const TemplateWithIgnoreDialog: StoryFn<typeof ConnectedAccountsFlexbox> = (props) => {
const [ignoreDialog, setIgnoreDialog] = useState(false)
const listener = (checked: boolean) => setIgnoreDialog(checked)

return <ConnectedAccountsFlexbox ignoreConnectDialog={ignoreDialog} onIgnoreConnectDialog={listener} {...props} />
}

const Default = Template.bind({})
const WithIgnoreDialog = TemplateWithIgnoreDialog.bind({})

export { Default }
export { Default, WithIgnoreDialog }

// eslint-disable-next-line import/no-default-export
export default StorybookEntry
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
import { Typography, useTheme } from '@mui/material'
import { FlexBoxProps, FlexCol } from '@xylabs/react-flexbox'
import { forwardRef } from 'react'

import { useDetectedWallets } from '../hooks'
import { ConnectedWalletsTable } from './wallet'

export const ConnectedAccountsFlexbox: React.FC<FlexBoxProps> = (props) => {
const theme = useTheme()
export interface ConnectedAccountsFlexboxProps extends FlexBoxProps {
ignoreConnectDialog?: boolean
// A callback that is invoked when the option to ignore the dialog is checked
onIgnoreConnectDialog?: (checked: boolean) => void
}

export const ConnectedAccountsFlexbox = forwardRef<HTMLDivElement, ConnectedAccountsFlexboxProps>(
({ ignoreConnectDialog, onIgnoreConnectDialog, ...props }, ref) => {
const theme = useTheme()

const { totalConnectedAccounts, sortedWallets } = useDetectedWallets()
const { totalConnectedAccounts, sortedWallets } = useDetectedWallets()

return (
<FlexCol alignItems="stretch" justifyContent="start" gap={2} {...props}>
<FlexCol alignItems="start">
<Typography variant={'h2'} sx={{ mb: 0.5 }}>
Detected Web3 Wallets
</Typography>
{totalConnectedAccounts ? (
<Typography variant={'subtitle1'} color={theme.palette.secondary.main} sx={{ opacity: 0.5 }}>
Total Connected Accounts: {totalConnectedAccounts}
return (
<FlexCol alignItems="stretch" justifyContent="start" gap={2} ref={ref} {...props}>
<FlexCol alignItems="start">
<Typography variant={'h2'} sx={{ mb: 0.5 }}>
Detected Web3 Wallets
</Typography>
) : null}
{totalConnectedAccounts ? (
<Typography variant={'subtitle1'} color={theme.palette.secondary.main} sx={{ opacity: 0.5 }}>
Total Connected Accounts: {totalConnectedAccounts}
</Typography>
) : null}
</FlexCol>
<ConnectedWalletsTable wallets={sortedWallets} ignoreConnectDialog={ignoreConnectDialog} onIgnoreConnectDialog={onIgnoreConnectDialog} />
</FlexCol>
<ConnectedWalletsTable wallets={sortedWallets} />
</FlexCol>
)
}
)
},
)

ConnectedAccountsFlexbox.displayName = 'ConnectedAccountsFlexbox'
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Checkbox, FormControl, FormControlProps, FormLabel } from '@mui/material'

export interface CheckboxFormControlProps extends FormControlProps {
onCheckChanged?: (checked: boolean) => void
}

export const CheckboxFormControl: React.FC<CheckboxFormControlProps> = ({ onCheckChanged, ...props }) => {
return (
<FormControl {...props}>
<FormLabel>
<Checkbox onChange={(_, checked) => onCheckChanged?.(checked)} />
Do not show this again.
</FormLabel>
</FormControl>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from '@mui/material'

import { ActiveProvider } from '../../lib'
import { CheckboxFormControl } from './CheckboxFormControl'
import { LinkedProvidersFlexbox } from './LinkedProvidersFlexbox'
import { WalletPermissionsFlexbox } from './Permissions'

export interface ConnectWalletDialogProps extends DialogProps {
activeProvider?: ActiveProvider
onIgnoreConnectDialog?: (checked: boolean) => void
}

export const ConnectWalletDialog: React.FC<ConnectWalletDialogProps> = ({ activeProvider, onIgnoreConnectDialog, ...props }) => {
const { icon, providerName } = activeProvider ?? {}

const onConnect = async () => {
try {
await activeProvider?.connectWallet?.()
props.onClose?.({}, 'escapeKeyDown')
} catch (e) {
console.warn(`Error connecting to wallet: ${(e as Error).message}`)
}
}

return (
<Dialog PaperProps={{ sx: { display: 'flex', gap: 4 } }} {...props}>
<DialogTitle sx={{ textAlign: 'center' }}>XYO Wants To Access The Blockchain on Your Behalf</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<LinkedProvidersFlexbox icon={icon} providerName={providerName} />
<WalletPermissionsFlexbox />
<CheckboxFormControl onCheckChanged={onIgnoreConnectDialog} />
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => props.onClose?.({}, 'escapeKeyDown')}>
Close
</Button>
<Button variant="contained" onClick={onConnect}>
Connect
</Button>
</DialogActions>
</Dialog>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { SyncAlt } from '@mui/icons-material'
import { Typography } from '@mui/material'
import { ConstrainedImage } from '@xylabs/react-crypto'
import { FlexBoxProps, FlexCol, FlexRow } from '@xylabs/react-flexbox'

import { xyoColorLogo } from '../../../../img'

export interface LinkedProvidersFlexboxProps extends FlexBoxProps {
icon?: string
providerName?: string
}

export const LinkedProvidersFlexbox: React.FC<LinkedProvidersFlexboxProps> = ({ icon, providerName, ...props }) => {
return (
<FlexRow gap={4} justifyContent="space-evenly" {...props}>
<FlexCol gap={0.5}>
<img alt="XYO Logo" src={xyoColorLogo} style={{ height: '48px' }} />
<Typography variant="subtitle1">XYO App</Typography>
</FlexCol>
<SyncAlt fontSize={'large'} />
<FlexCol gap={0.5}>
<ConstrainedImage constrainedValue={'48px'} src={icon} alt={providerName} style={{ height: '48px', maxWidth: '48px' }} />
<Typography variant="subtitle1">{providerName}</Typography>
</FlexCol>
</FlexRow>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Link, Typography } from '@mui/material'
import { FlexBoxProps, FlexCol } from '@xylabs/react-flexbox'

export interface WalletPermissionsFlexBoxProps extends FlexBoxProps {}

export const WalletPermissionsFlexbox: React.FC<WalletPermissionsFlexBoxProps> = (props) => {
return (
<FlexCol gap={4} {...props}>
<Typography fontWeight="bold" sx={{ textAlign: 'center' }}>
This will allow XYO to:
</Typography>
<ul>
<li>View your wallet account(s) and address(es)</li>
<li>Read-only access to browse the public blockchain(s) you select</li>
</ul>
<Typography variant="subtitle1" sx={{ textAlign: 'center' }}>
You control what accounts to share and what blockchains to view. You can see or revoke access via your wallet&apos;s settings at anytime. View
more on XYO&apos;s sovereign data philosophy{' '}
<Link
href="https://cointelegraph.com/innovation-circle/decentralization-and-sovereignty-debunking-our-approach-to-digital-sovereignty"
sx={{ fontWeight: 'bold' }}
target="_blank"
>
here
</Link>
.
</Typography>
</FlexCol>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './CheckboxFormControl'
export * from './Dialog'
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './connect'
export * from './revoke'
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, Typography } from '@mui/material'
import { ConstrainedImage } from '@xylabs/react-crypto'
import { FlexRow } from '@xylabs/react-flexbox'

import { ActiveProvider } from '../../lib'

export interface RevokeWalletConnectionDialogProps extends DialogProps {
activeProvider?: ActiveProvider
}

export const RevokeWalletConnectionDialog: React.FC<RevokeWalletConnectionDialogProps> = ({ activeProvider, ...props }) => {
return (
<Dialog {...props}>
<FlexRow gap={2} justifyContent="start" pl={2}>
<ConstrainedImage src={activeProvider?.icon} constrainedValue={'24px'} />
<DialogTitle sx={{ pl: 0 }}>Revoke {activeProvider?.providerName} Access</DialogTitle>
</FlexRow>
<DialogContent>
<Typography>
Revoking access to your wallet must be done from the wallet&apos;s browser extension. Wallets grant access to specific domains please
consult {activeProvider?.providerName}&apos;s documentation on how to revoke access to this website:
</Typography>
<Typography>{window.location.origin}</Typography>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => props.onClose?.({}, 'escapeKeyDown')}>
Close
</Button>
</DialogActions>
</Dialog>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Dialog'
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './dialogs'
export * from './table'
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { EthWallet } from '@xylabs/react-crypto'

export interface ActiveProvider {
connectWallet?: EthWallet['connectWallet']
icon?: string
providerName?: string
}
Loading

0 comments on commit 4b759c5

Please sign in to comment.