-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- 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
Showing
12 changed files
with
451 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 38 additions & 0 deletions
38
libs/oeth/shared/src/components/ActivityProvider/components/ActivityButton.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> | ||
</> | ||
); | ||
}; |
40 changes: 40 additions & 0 deletions
40
libs/oeth/shared/src/components/ActivityProvider/components/ActivityIcon.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
176 changes: 176 additions & 0 deletions
176
libs/oeth/shared/src/components/ActivityProvider/components/ActivityPopover.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.