Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PE-5967: wallet connection profile menu AR connect #5

Merged
merged 15 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20.9.0
20.12.2
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
"vis": "yarn vite-bundle-visualizer"
},
"dependencies": {
"@ar.io/sdk": "^1.0.2-alpha.1",
"@ar.io/sdk": "^1.0.3",
"@fontsource/rubik": "^5.0.19",
"@headlessui/react": "^1.7.19",
"@sentry/browser": "^7.101.1",
"@sentry/react": "^7.101.1",
"arweave": "^1.15.0",
"axios": "^1.6.7",
"axios-retry": "^4.0.0",
"react": "^18.2.0",
Expand Down Expand Up @@ -80,7 +82,7 @@
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.11",
"prettier-plugin-tailwindcss": "^0.5.14",
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.1.2",
Expand Down
22 changes: 15 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
createRoutesFromElements,
} from 'react-router-dom';

import GlobalDataProvider from './components/GlobalDataProvider';
import WalletProvider from './components/WalletProvider';
import AppRouterLayout from './layout/AppRouterLayout';
import Loading from './pages/Loading';
import NotFound from './pages/NotFound';
Expand All @@ -32,40 +34,46 @@ function App() {
<Dashboard />
</Suspense>
}
/>,
/>
,
<Route
path="gateways"
element={
<Suspense fallback={<Loading />}>
<Gateways />
</Suspense>
}
/>,
/>
,
<Route
path="staking"
element={
<Suspense fallback={<Loading />}>
<Staking />
</Suspense>
}
/>,
/>
,
<Route
path="observers"
element={
<Suspense fallback={<Loading />}>
<Observers />
</Suspense>
}
/>,
/>
,
<Route path="*" element={<Navigate to="/" />} />
</Route>,
),
);

return (
<>
<RouterProvider router={router} />
</>
<GlobalDataProvider>
<WalletProvider>
<RouterProvider router={router} />
</WalletProvider>
</GlobalDataProvider>
);
}

Expand Down
8 changes: 6 additions & 2 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MouseEventHandler, ReactElement } from 'react';
import { LegacyRef, MouseEventHandler, ReactElement } from 'react';

export enum ButtonType {
PRIMARY = 'primary',
Expand All @@ -7,6 +7,7 @@ export enum ButtonType {
}

export const Button = ({
forwardRef,
className,
buttonType = ButtonType.SECONDARY,
icon,
Expand All @@ -16,6 +17,7 @@ export const Button = ({
active = false,
onClick,
}: {
forwardRef?: LegacyRef<HTMLButtonElement>;
className?: string;
buttonType?: ButtonType;
icon?: ReactElement;
Expand All @@ -33,6 +35,7 @@ export const Button = ({
>
<button
title={title}
ref={forwardRef}
className="inline-flex items-center justify-start
gap-[11px] rounded-md bg-btn-primary-base bg-gradient-to-b
from-btn-primary-gradient-start to-btn-primary-gradient-end
Expand Down Expand Up @@ -66,13 +69,14 @@ export const Button = ({
].join(' ');
return (
<button
ref={forwardRef}
title={title}
className={buttonClassNames}
onClick={onClick}
>
{icon}
{text && (
<div className="flex grow items-center space-x-[4px] text-left">
<div className="flex grow items-center space-x-[4px] text-left leading-none">
{text} {rightIcon}
</div>
)}
Expand Down
42 changes: 42 additions & 0 deletions src/components/GlobalDataProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffectOnce } from '@src/hooks/useEffectOnce';
import { useGlobalState } from '@src/store';
import { ReactElement, useEffect } from 'react';

const GlobalDataProvider = ({
children,
}: {
children: ReactElement;
}) => {
const twoMinutes = 120000;

atticusofsparta marked this conversation as resolved.
Show resolved Hide resolved
const setBlockHeight = useGlobalState((state) => state.setBlockHeight);
const setCurrentEpoch = useGlobalState((state) => state.setCurrentEpoch);
const arweave = useGlobalState((state) => state.arweave);
const arioReadSDK = useGlobalState((state) => state.arIOReadSDK);

useEffectOnce(() => {
const update = async () => {
const currentEpoch = await arioReadSDK.getCurrentEpoch();
setCurrentEpoch(currentEpoch);
};

update();
});

useEffect(() => {
const updateBlockHeight = async () => {
const blockHeight = await (await arweave.blocks.getCurrent()).height;
setBlockHeight(blockHeight);
};
updateBlockHeight();
const interval = setInterval(updateBlockHeight, twoMinutes);

return () => {
clearInterval(interval);
};
});

return <>{children}</>;
};
Comment on lines +17 to +40
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could use a simple and small promise cache for these API calls and avoid the setInterval - https://github.com/ardriveapp/promise-cache

e.g. the hook would be responsible for calling the cache for the height - and with a TTL of two minutes it'll refresh when it needs to based on what's being renedered

you could have a separate cache for the epoch information

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think promise-cache might not be the right tool here. We want to trigger a re-render of the app when data changes, which occurs when the polling result comes back and we send an update to the global data store. If promise-cache was added, we would have to poll against the read-through-cache's status to identify when to update the global data store to trigger the re-render.

Also for context, this class isn't a hook, it's more of a global wrapper to the app to have a place for code that can interconnect global data fetching together with our global data store and have it all interact well with react's events and lifecycle.

I think for now it'd be best to keep moving forward and I'll keep promise-cache on my mind to see if it's a good fit for the uses cases coming up.


export default GlobalDataProvider;
22 changes: 16 additions & 6 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import Button, { ButtonType } from './Button';
import { ConnectIcon } from './icons';
import { NBSP } from '@src/constants';
import { useGlobalState } from '@src/store';
import Profile from './Profile';

const Header = () => {
const blockHeight = useGlobalState((state) => state.blockHeight);
const currentEpoch = useGlobalState((state) => state.currentEpoch);

return (
<header className="mt-[24px] flex h-[72px] rounded-[12px] border-[1px] py-[16px] pl-[24px] pr-[16px] leading-[1.4] dark:border-transparent-100-8 dark:bg-grey-1000 dark:text-grey-300">
<header className="mt-[24px] flex h-[72px] rounded-[12px] border py-[16px] pl-[24px] pr-[16px] leading-[1.4] dark:border-transparent-100-8 dark:bg-grey-1000 dark:text-grey-300">
<div className="inline-flex h-[38px] flex-col items-start justify-start gap-1 border-r pr-6 dark:border-transparent-100-8">
<div className="text-[12px] text-high">15</div>
<div className="text-[12px] text-high">
{currentEpoch
? currentEpoch.epochPeriod.toLocaleString('en-US')
: NBSP}
</div>
<div className="pt-[4px] text-[12px] leading-none text-low">
AR.IO EPOCH
</div>
</div>
<div className="inline-flex h-[38px] flex-col items-start justify-start gap-1 border-r px-6 dark:border-transparent-100-8">
<div className="text-[12px] text-high">1,367,904</div>
<div className="text-[12px] text-high">
{blockHeight ? blockHeight.toLocaleString('en-US') : NBSP}
</div>
<div className="pt-[4px] text-[12px] leading-none text-low">
ARWEAVE BLOCK
</div>
</div>
<div className="grow" />
<div className="content-center">
<Button buttonType={ButtonType.PRIMARY} icon={<ConnectIcon />} title="Connect" text="Connect" onClick={() => {}} />
<Profile />
</div>
</header>
);
Expand Down
99 changes: 99 additions & 0 deletions src/components/Profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Popover } from '@headlessui/react';
import { useGlobalState } from '@src/store';
import { formatBalance, formatWalletAddress } from '@src/utils';
import { forwardRef, useState } from 'react';
import Button, { ButtonType } from './Button';
import {
ConnectIcon,
GearIcon,
LogoutIcon,
StakingIcon,
WalletIcon,
} from './icons';
import ConnectModal from './modals/ConnectModal';

// eslint-disable-next-line react/display-name
const CustomPopoverButton = forwardRef<HTMLButtonElement>((props, ref) => {
return (
<Button
forwardRef={ref}
buttonType={ButtonType.PRIMARY}
icon={<ConnectIcon />}
title="Profile"
{...props}
/>
);
});

const Profile = () => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);

const walletStateInitialized = useGlobalState(
(state) => state.walletStateInitialized,
);

const wallet = useGlobalState((state) => state.wallet);
const balances = useGlobalState((state) => state.balances);
const updateWallet = useGlobalState((state) => state.updateWallet);
const walletAddress = useGlobalState((state) => state.walletAddress);

return walletAddress ? (
<Popover className="relative">
<Popover.Button as={CustomPopoverButton} />

<Popover.Panel
className="absolute right-0 z-10 mt-[10px] w-[240px]
rounded-[12px] border border-grey-800 bg-grey-1000 px-[16px] text-sm shadow-md"
>
<div className="flex gap-[8px] py-[20px]">
<WalletIcon />
<div className="text-sm text-high">
{formatWalletAddress(walletAddress.toString())}
</div>
</div>
<div className="rounded-[6px] border border-grey-800 py-[12px]">
<div className="px-[16px] text-xs text-low">IO Balance</div>
<div className="border-b border-grey-800 px-[16px] pb-[12px] pt-[4px] text-high">
{formatBalance(balances.io)}
</div>
<div className="px-[16px] pt-[12px] text-xs text-low">AR Balance</div>
<div className="px-[16px] pt-[4px] text-high">
{formatBalance(balances.ar)}
</div>
</div>
<div className="flex flex-col gap-[12px] py-[12px] text-mid">
<button className="flex items-center gap-[8px]">
<GearIcon /> Gateway Management
</button>
<button className="flex items-center gap-[8px]">
<StakingIcon /> Delegated Staking
</button>
<button
className="flex items-center gap-[8px]"
title="Logout"
onClick={async () => {
await wallet?.disconnect();
updateWallet(undefined, undefined);
}}
>
<LogoutIcon /> Logout
</button>
</div>
</Popover.Panel>
</Popover>
) : walletStateInitialized ? (
<div>
<Button
buttonType={ButtonType.PRIMARY}
icon={<ConnectIcon />}
title="Connect"
text="Connect"
onClick={() => setIsModalOpen(true)}
/>
<ConnectModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
) : (
<div></div>
);
};
export default Profile;
79 changes: 79 additions & 0 deletions src/components/WalletProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffectOnce } from '@src/hooks/useEffectOnce';
import { ArConnectWalletConnector } from '@src/services/wallets/ArConnectWalletConnector';
import { useGlobalState } from '@src/store';
import { WALLET_TYPES } from '@src/types';
import { mioToIo } from '@src/utils';
import { ArweaveTransactionID } from '@src/utils/ArweaveTransactionId';
import Ar from 'arweave/web/ar';
import { ReactElement, useEffect } from 'react';

const AR = new Ar();

const WalletProvider = ({ children }: { children: ReactElement }) => {
const blockHeight = useGlobalState((state) => state.blockHeight);
const walletAddress = useGlobalState((state) => state.walletAddress);
const setWalletStateInitialized = useGlobalState(
(state) => state.setWalletStateInitialized,
);
const updateWallet = useGlobalState((state) => state.updateWallet);
const setBalances = useGlobalState((state) => state.setBalances);
const arweave = useGlobalState((state) => state.arweave);
const arIOReadSDK = useGlobalState((state) => state.arIOReadSDK);

useEffect(() => {
window.addEventListener('arweaveWalletLoaded', updateIfConnected);

return () => {
window.removeEventListener('arweaveWalletLoaded', updateIfConnected);
};
});

useEffectOnce(() => {
setTimeout(() => {
setWalletStateInitialized(true);
}, 5000);
});

useEffect(() => {
if (walletAddress) {
const updateBalances = async (address: ArweaveTransactionID) => {
try {
const [mioBalance, winstonBalance] = await Promise.all([
arIOReadSDK.getBalance({ address: address.toString() }),
arweave.wallets.getBalance(address.toString()),
]);

const arBalance = +AR.winstonToAr(winstonBalance);
const ioBalance = mioToIo(mioBalance);

setBalances(arBalance, ioBalance);
} catch (error) {
// eventEmitter.emit('error', error);
}
};

updateBalances(walletAddress);
}
}, [walletAddress, blockHeight, arIOReadSDK, arweave, setBalances]);

const updateIfConnected = async () => {
const walletType = window.localStorage.getItem('walletType');

try {
if (walletType === WALLET_TYPES.ARCONNECT) {
const connector = new ArConnectWalletConnector();
const address = await connector?.getWalletAddress();

updateWallet(address, connector);
}
} catch (error) {
// eventEmitter.emit('error', error);
} finally {
setWalletStateInitialized(true);
}
};

return <>{children}</>;
};

export default WalletProvider;
Loading
Loading