Skip to content

Commit

Permalink
feat: activity context
Browse files Browse the repository at this point in the history
- add new provider and components for activities
- update use flow to account for activities
- use pending states
- add different activity cards WIP🚧
  • Loading branch information
toniocodo committed Oct 3, 2023
1 parent 54b3c78 commit 9968b98
Show file tree
Hide file tree
Showing 12 changed files with 451 additions and 26 deletions.
12 changes: 11 additions & 1 deletion apps/oeth/src/components/Topnav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
useMediaQuery,
useTheme,
} from '@mui/material';
import { AccountPopover } from '@origin/oeth/shared';
import { AccountPopover, ActivityButton } from '@origin/oeth/shared';
import { OpenAccountModalButton } from '@origin/shared/providers';
import { useIntl } from 'react-intl';
import { Link, useLocation, useNavigate } from 'react-router-dom';
Expand Down Expand Up @@ -198,6 +198,16 @@ export function Topnav(props: BoxProps) {
anchor={accountModalAnchor}
setAnchor={setAccountModalAnchor}
/>
<ActivityButton
sx={{
width: { xs: 36, md: 44 },
height: { xs: 36, md: 44 },
padding: {
xs: 0.75,
md: 1,
},
}}
/>
</Box>
<Divider
sx={{
Expand Down
8 changes: 7 additions & 1 deletion apps/oeth/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom/client';

import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material';
import { chains, queryClient, wagmiConfig } from '@origin/oeth/shared';
import {
ActivityProvider,
chains,
queryClient,
wagmiConfig,
} from '@origin/oeth/shared';
import {
CurveProvider,
GeoFenceProvider,
Expand Down Expand Up @@ -43,6 +48,7 @@ root.render(
[RainbowKitProvider, { chains: chains, theme: darkTheme() }],
[CurveProvider],
[NotificationsProvider],
[ActivityProvider],
[GeoFenceProvider],
],
<RouterProvider router={createHashRouter(routes)} />,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useState } from 'react';

import { IconButton } from '@mui/material';

import { useGlobalStatus } from '../hooks';
import { ActivityIcon } from './ActivityIcon';
import { ActivityPopover } from './ActivityPopover';

import type { IconButtonProps } from '@mui/material';

export const ActivityButton = (
props: Omit<IconButtonProps, 'children' | 'onClick'>,
) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const status = useGlobalStatus();

return (
<>
<IconButton
{...props}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
img: {
height: 24,
width: 24,
},
...props?.sx,
}}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<ActivityIcon status={status} />
</IconButton>
<ActivityPopover anchor={anchorEl} setAnchor={setAnchorEl} />
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { keyframes } from '@emotion/react';
import { Box, Fade } from '@mui/material';

import type { BoxProps } from '@mui/material';

import type { GlobalActivityStatus } from '../types';

const spin = keyframes`
to {
transform: rotate(360deg);
}
`;

const iconPaths: Record<GlobalActivityStatus, string> = {
idle: '/images/activity.svg',
pending: '/images/pending.svg',
error: '/images/failed.svg',
success: '/images/success.svg',
};

type ActivityIconProps = { status: GlobalActivityStatus } & BoxProps<'img'>;

export const ActivityIcon = ({ status, ...rest }: ActivityIconProps) => {
return (
<Fade in appear>
<Box
{...rest}
component="img"
src={iconPaths[status]}
alt={`Activity-${status}`}
{...(status === 'pending' && {
sx: {
animation: `${spin} 3s linear infinite`,
...rest?.sx,
},
})}
/>
</Fade>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
Box,
Divider,
Popover,
Stack,
Typography,
useTheme,
} from '@mui/material';
import { LinkIcon } from '@origin/shared/components';
import { isNilOrEmpty } from '@origin/shared/utils';
import { descend, pipe, prop, sort, take } from 'ramda';
import { defineMessage, useIntl } from 'react-intl';
import { formatUnits } from 'viem';

import { useActivityState } from '../state';
import { ActivityIcon } from './ActivityIcon';

import type { StackProps } from '@mui/material';
import type { MessageDescriptor } from 'react-intl';

import type { Activity, ActivityType } from '../types';

export type AcitivityPopoverProps = {
anchor: HTMLElement | null;
setAnchor: (value: HTMLButtonElement | null) => void;
};

export const ActivityPopover = ({
anchor,
setAnchor,
}: AcitivityPopoverProps) => {
const intl = useIntl();
const theme = useTheme();
const [{ activities, maxVisible }] = useActivityState();

const handleClose = () => {
setAnchor(null);
};

const sortedActivities = pipe(
sort(descend(prop('createdOn'))),
take(maxVisible),
)(activities) as Activity[];

return (
<Popover
open={!!anchor}
anchorEl={anchor}
onClose={handleClose}
anchorOrigin={{
vertical: 50,
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
sx={{
'& .MuiPopover-paper': {
borderRadius: 1,
width: (theme) => ({
xs: '90vw',
md: `min(${theme.typography.pxToRem(400)}, 90vw)`,
}),
[theme.breakpoints.down('md')]: {
left: '0 !important',
right: 0,
marginInline: 'auto',
},
},
}}
>
<Stack>
<Typography sx={{ px: 3, py: 2 }}>
{intl.formatMessage({ defaultMessage: 'Recent activity' })}
</Typography>
<Divider />
<Stack divider={<Divider />}>
{isNilOrEmpty(sortedActivities) ? (
<EmptyActivity sx={{ px: 3, py: 3 }} />
) : (
sortedActivities.map((a) => (
<ActivityItem key={a.id} activity={a} sx={{ px: 3, py: 2 }} />
))
)}
</Stack>
</Stack>
</Popover>
);
};

type ActivityItemProps = {
activity: Activity;
} & StackProps;

const activityLabel: Record<ActivityType, MessageDescriptor> = {
swap: defineMessage({ defaultMessage: 'Swapped' }),
approval: defineMessage({ defaultMessage: 'Approved' }),
};

function ActivityItem({ activity, ...rest }: ActivityItemProps) {
const intl = useIntl();

return (
<Stack {...rest} direction="row" justifyContent="space-between">
<Stack spacing={1}>
<Stack direction="row" alignItems="center" spacing={1}>
<ActivityIcon
status={activity.status}
sx={{ width: 20, height: 20 }}
/>
<Typography>
{intl.formatMessage(activityLabel[activity.type])}
</Typography>
{!isNilOrEmpty(activity?.txReceipt?.transactionHash) && (
<LinkIcon
size={10}
url={`https://etherscan.io/tx/${activity.txReceipt.transactionHash}`}
/>
)}
</Stack>
<Stack direction="row" alignItems="center">
<Typography color="text.tertiary">
{intl.formatMessage(
{
defaultMessage:
'{amountIn} {symbolIn} for {amountOut} {symbolOut}',
},
{
amountIn: intl.formatNumber(
+formatUnits(activity.amountIn, activity.tokenIn.decimals),
{ minimumFractionDigits: 4, maximumFractionDigits: 4 },
),
symbolIn: activity.tokenIn.symbol,
amountOut: intl.formatNumber(
+formatUnits(activity.amountOut, activity.tokenOut.decimals),
{ minimumFractionDigits: 4, maximumFractionDigits: 4 },
),
symbolOut: activity.tokenOut.symbol,
},
)}
</Typography>
</Stack>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<Box
component="img"
src={activity.tokenIn.icon}
sx={{ width: 24, height: 24 }}
/>
<Box
component="img"
src="images/arrow-right.svg"
sx={{ width: 12, height: 12 }}
/>
<Box
component="img"
src={activity.tokenOut.icon}
sx={{ width: 24, height: 24 }}
/>
</Stack>
</Stack>
);
}

function EmptyActivity(props: StackProps) {
const intl = useIntl();

return (
<Stack {...props} justifyContent="center" alignItems="center" py={3}>
<Typography>
{intl.formatMessage({ defaultMessage: 'No activity' })}
</Typography>
</Stack>
);
}
86 changes: 86 additions & 0 deletions libs/oeth/shared/src/components/ActivityProvider/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from 'react';

import { isNilOrEmpty } from '@origin/shared/utils';
import { usePrevious } from '@react-hookz/web';
import { produce } from 'immer';
import { groupBy, prop, propEq } from 'ramda';

import { useActivityState } from './state';

import type { Activity, GlobalActivityStatus } from './types';

export const usePushActivity = () => {
const [, setState] = useActivityState();

return useCallback(
(value: Omit<Activity, 'id' | 'createdOn'>) => {
const activity = {
...value,
id: Date.now().toString(),
createdOn: Date.now(),
};
setState(
produce((state) => {
state.activities.unshift(activity);
}),
);

return activity;
},
[setState],
);
};

export const useUpdateActivity = () => {
const [, setState] = useActivityState();

return useCallback(
(activity: Partial<Activity>) => {
setState(
produce((state) => {
const idx = state.activities.findIndex(propEq(activity.id, 'id'));
console.log('update ', idx, state.activities, activity);
if (idx > -1) {
state.activities[idx] = {
...state.activities[idx],
...activity,
};
}
}),
);
},
[setState],
);
};

export const useGlobalStatus = () => {
const [{ activities }] = useActivityState();
const [status, setStatus] = useState<GlobalActivityStatus>('idle');
const prev = usePrevious(activities);

useEffect(() => {
const prevGrouped = groupBy(prop('status'), prev ?? []);
const grouped = groupBy(prop('status'), activities ?? []);

if (isNilOrEmpty(grouped.pending)) {
if (prevGrouped?.success?.length !== grouped?.success?.length) {
setStatus('success');
setTimeout(() => {
setStatus('idle');
}, 2000);
} else if (prevGrouped?.error?.length !== grouped?.error?.length) {
setStatus('error');
setTimeout(() => {
setStatus('idle');
}, 2000);
} else {
setStatus('idle');
}
} else {
setStatus('pending');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activities]);

return status;
};
4 changes: 4 additions & 0 deletions libs/oeth/shared/src/components/ActivityProvider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './components/ActivityButton';
export * from './hooks';
export * from './state';
export * from './types';
Loading

0 comments on commit 9968b98

Please sign in to comment.