diff --git a/src/components/AccountDrawer/AuthenticatedHeader.tsx b/src/components/AccountDrawer/AuthenticatedHeader.tsx index a43e7ddefdf..e3d5b6bcb6f 100644 --- a/src/components/AccountDrawer/AuthenticatedHeader.tsx +++ b/src/components/AccountDrawer/AuthenticatedHeader.tsx @@ -111,13 +111,14 @@ const StatusWrapper = styled.div` display: inline-flex; ` -const AccountNamesWrapper = styled.div` +export const AccountNamesWrapper = styled.div` overflow: hidden; white-space: nowrap; display: flex; width: 100%; + height: 100%; flex-direction: column; - justify-content: center; + justify-content: flex-end; margin-left: 8px; ` diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx b/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx index ed479980d17..f56e04df2ba 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx @@ -1,18 +1,32 @@ import { BrowserEvent, InterfaceElementName, SharedEventName } from '@uniswap/analytics-events' +import ParentSize from '@visx/responsive/lib/components/ParentSize' import { TraceEvent } from 'analytics' +import { PriceChart } from 'components/Charts/PriceChart' import Column from 'components/Column' import AlertTriangleFilled from 'components/Icons/AlertTriangleFilled' import { LoaderV2 } from 'components/Icons/LoadingSpinner' import Row from 'components/Row' -import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks' +import { JudgementalActivity } from 'components/SocialFeed/hooks' +import { usePriceHistory } from 'components/Tokens/TokenDetails/ChartSection' +import { + Chain, + HistoryDuration, + TransactionStatus, + useTokenPriceQuery, +} from 'graphql/data/__generated__/types-and-hooks' +import { TimePeriod } from 'graphql/data/util' +import { getV2Prices } from 'graphql/thegraph/getV2Prices' import useENSName from 'hooks/useENSName' -import { useCallback } from 'react' -import styled from 'styled-components' +import { ClickableText } from 'pages/Pool/styled' +import { useCallback, useEffect, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import styled, { useTheme } from 'styled-components' import { EllipsisStyle, ThemedText } from 'theme/components' import { shortenAddress } from 'utils' +import { useFormatter } from 'utils/formatNumbers' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' -import { PortfolioLogo } from '../PortfolioLogo' +import { PortfolioAvatar, PortfolioLogo } from '../PortfolioLogo' import PortfolioRow from '../PortfolioRow' import { useOpenOffchainActivityModal } from './OffchainActivityModal' import { useTimeSince } from './parseRemote' @@ -90,3 +104,171 @@ export function ActivityRow({ activity }: { activity: Activity }) { ) } + +function isJudgementalActivity(activity: Activity | JudgementalActivity): activity is JudgementalActivity { + return (activity as JudgementalActivity).isJudgmental !== undefined +} + +const ActivityCard = styled.div` + display: flex; + flex-direction: column; + + gap: 20px; + padding: 20px; + width: 100%; + /* width: 420px; */ + + /* background-color: ${({ theme }) => theme.surface1}; */ + /* border-radius: 12px; */ + border-bottom: 1px solid ${({ theme }) => theme.surface3}; +` +const CardHeader = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 20px; + justify-content: space-between; + white-space: nowrap; +` + +const Who = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + width: 100%; +` + +const DescriptionContainer = styled(Row)` + white-space: nowrap; + flex-wrap: wrap; +` + +function NormalFeedRow({ activity }: { activity: Activity }) { + const { ENSName } = useENSName(activity.owner) + const { ENSName: otherAccountENS } = useENSName(activity.otherAccount) + const timeSince = useTimeSince(activity.timestamp) + + const navigate = useNavigate() + + const shouldHide = useMemo( + () => + activity.title.includes('Approv') || + activity.title.includes('Contract') || + activity.descriptor?.includes('Contract') || + activity.title.includes('Sent') || + activity.title?.includes('Swapped') || + activity.title.includes('Received') || + activity.title.includes('Unknown'), + [activity.title, activity.descriptor] + ) + if (shouldHide) return null + + return ( + + + + + navigate('/account/' + ENSName ?? activity.owner)}> + {ENSName ?? shortenAddress(activity.owner)} + + + {timeSince} + + + + {activity.title} {activity.descriptor}{' '} + {' '} + navigate(`/account/${otherAccountENS ?? activity.otherAccount}`)}> + {otherAccountENS ?? activity.otherAccount} + + + + + ) +} + +function JudgementChart({ activity, hidePrice }: { activity: JudgementalActivity; hidePrice?: boolean }) { + const theme = useTheme() + const { data: tokenPriceQuery } = useTokenPriceQuery({ + variables: { + address: activity.currency.wrapped.address, + chain: Chain.Ethereum, + duration: HistoryDuration.Year, + }, + errorPolicy: 'all', + }) + + const prices = usePriceHistory(tokenPriceQuery) + + useEffect(() => { + getV2Prices(activity.currency.wrapped.address).then(console.log) + }, []) + + if (!prices) return null + + return ( + + {({ width }) => ( + + )} + + ) +} + +function JudgementalActivityRow({ activity, hidePrice }: { activity: JudgementalActivity; hidePrice?: boolean }) { + const { ENSName } = useENSName(activity.owner) + const timeSince = useTimeSince(activity.timestamp) + const { formatNumber } = useFormatter() + const theme = useTheme() + const navigate = useNavigate() + + return ( + + + + + navigate('/account/' + activity.owner)}> + {ENSName ?? shortenAddress(activity.owner)} + + + {timeSince} + + + + + {activity.description} {' '} + + navigate( + '/tokens/ethereum/' + (activity.currency.isNative ? 'NATIVE' : activity.currency.wrapped.address) + ) + } + > + {activity.currency.symbol} + + 0 ? theme.success : theme.critical }}> + {activity.profit < 0 ? '-' : ''}(${Math.abs(activity.profit).toFixed(2)}) + {' '} + {activity.hodlingTimescale} + + + + ) +} + +export function FeedRow({ activity, hidePrice }: { activity: Activity | JudgementalActivity; hidePrice?: boolean }) { + if (!isJudgementalActivity(activity)) { + return + // return null + } + return +} diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts b/src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts index acb91b111e6..4f8c076e257 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts @@ -58,16 +58,13 @@ function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = export function useAllActivities(account: string) { const { formatNumberOrString } = useFormatter() const { data, loading, refetch } = useActivityQuery({ - variables: { account }, + variables: { accounts: [account] }, errorPolicy: 'all', fetchPolicy: 'cache-first', }) const localMap = useLocalActivities(account) - const remoteMap = useMemo( - () => parseRemoteActivities(formatNumberOrString, data?.portfolios?.[0].assetActivities), - [data?.portfolios, formatNumberOrString] - ) + const remoteMap = useMemo(() => parseRemoteActivities(formatNumberOrString, data), [data, formatNumberOrString]) const updateCancelledTx = useTransactionCanceller() /* Updates locally stored pendings tx's when remote data contains a conflicting cancellation tx */ diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx b/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx index 0bf8d54d936..ec5223cb68f 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx @@ -6,7 +6,6 @@ import { atom, useAtom } from 'jotai' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { useEffect, useMemo } from 'react' import styled from 'styled-components' -import { ThemedText } from 'theme/components' import { PortfolioSkeleton, PortfolioTabWrapper } from '../PortfolioRow' import { ActivityRow } from './ActivityRow' @@ -53,9 +52,9 @@ export function ActivityTab({ account }: { account: string }) { {activityGroups.map((activityGroup) => ( - + {/* {activityGroup.title} - + */} {activityGroup.transactions.map((activity) => ( diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts b/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts index 9d4e3d7d149..f85cfc4aa7e 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts @@ -192,6 +192,7 @@ export function transactionToActivity( const status = getTransactionStatus(details) const defaultFields = { + owner: details.from, hash: details.hash, chainId, title: getActivityTitle(details.info.type, status), @@ -249,6 +250,7 @@ export function signatureToActivity( const { title, statusMessage, status } = OrderTextTable[signature.status] return { + owner: signature.offerer, hash: signature.orderHash, chainId: signature.chainId, title, diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.test.tsx b/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.test.tsx deleted file mode 100644 index ae26c660604..00000000000 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { act, renderHook } from '@testing-library/react' -import ms from 'ms' - -import { - MockClosedUniswapXOrder, - MockMoonpayPurchase, - MockNFTApproval, - MockNFTApprovalForAll, - MockNFTPurchase, - MockNFTReceive, - MockNFTTransfer, - MockOpenUniswapXOrder, - MockRemoveLiquidity, - MockSwapOrder, - MockTokenApproval, - MockTokenReceive, - MockTokenSend, - MockTokenTransfer, - MockWrap, -} from './fixtures/activity' -import { parseRemoteActivities, useTimeSince } from './parseRemote' - -describe('parseRemote', () => { - beforeEach(() => { - jest.useFakeTimers() - }) - describe('parseRemoteActivities', () => { - it('should not parse open UniswapX order', () => { - const result = parseRemoteActivities(jest.fn(), [MockOpenUniswapXOrder]) - expect(result).toEqual({}) - }) - it('should parse closed UniswapX order', () => { - const result = parseRemoteActivities(jest.fn(), [MockClosedUniswapXOrder]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse NFT approval', () => { - const result = parseRemoteActivities(jest.fn(), [MockNFTApproval]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse NFT approval for all', () => { - const result = parseRemoteActivities(jest.fn(), [MockNFTApprovalForAll]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse NFT transfer', () => { - const result = parseRemoteActivities(jest.fn(), [MockNFTTransfer]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse swap', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue('100'), [MockTokenTransfer]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse nft purchase', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue('100'), [MockNFTPurchase]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse token approval', () => { - const result = parseRemoteActivities(jest.fn(), [MockTokenApproval]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse send', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue(100), [MockTokenSend]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse receive', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue(100), [MockTokenReceive]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse NFT receive', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue(100), [MockNFTReceive]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse remove liquidity', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue(100), [MockRemoveLiquidity]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse moonpay purchase', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue('100'), [MockMoonpayPurchase]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse swap order', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue('100'), [MockSwapOrder]) - expect(result?.['someHash']).toMatchSnapshot() - }) - it('should parse eth wrap', () => { - const result = parseRemoteActivities(jest.fn().mockReturnValue('100'), [MockWrap]) - expect(result?.['someHash']).toMatchSnapshot() - }) - }) - - describe('useTimeSince', () => { - beforeEach(() => { - jest.useFakeTimers() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('should initialize with the correct time since', () => { - const timestamp = Math.floor(Date.now() / 1000) - 60 // 60 seconds ago - const { result } = renderHook(() => useTimeSince(timestamp)) - - expect(result.current).toBe('1m') - }) - - it('should update time since every second', async () => { - const timestamp = Math.floor(Date.now() / 1000) - 50 // 50 seconds ago - const { result, rerender } = renderHook(() => useTimeSince(timestamp)) - - act(() => { - jest.advanceTimersByTime(ms('1.1s')) - }) - rerender() - - expect(result.current).toBe('51s') - }) - - it('should stop updating after 61 seconds', () => { - const timestamp = Math.floor(Date.now() / 1000) - 61 // 61 seconds ago - const { result, rerender } = renderHook(() => useTimeSince(timestamp)) - - act(() => { - jest.advanceTimersByTime(ms('121.1s')) - }) - rerender() - - // maxes out at 1m - expect(result.current).toBe('1m') - }) - }) -}) diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx b/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx index a5907ed2f7d..9607c3b2fae 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx @@ -4,6 +4,7 @@ import UniswapXBolt from 'assets/svg/bolt.svg' import moonpayLogoSrc from 'assets/svg/moonpay.svg' import { nativeOnChain } from 'constants/tokens' import { + ActivityQuery, ActivityType, AssetActivityPartsFragment, Currency as GQLCurrency, @@ -169,6 +170,14 @@ function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumb const adjustedInput = parseFloat(sent.quantity) - parseFloat(refund?.quantity ?? '0') const inputAmount = formatNumberOrString({ input: adjustedInput, type: NumberType.TokenNonTx }) const outputAmount = formatNumberOrString({ input: received.quantity, type: NumberType.TokenNonTx }) + + if (sent.transactedValue && sent.transactedValue?.value > 800) { + return { + title: t`Aped into`, + descriptor: getSwapDescriptor({ tokenIn: sent.asset, inputAmount, tokenOut: received.asset, outputAmount }), + currencies: [gqlToCurrency(sent.asset), gqlToCurrency(received.asset)], + } + } return { title: getSwapTitle(sent, received), descriptor: getSwapDescriptor({ tokenIn: sent.asset, inputAmount, tokenOut: received.asset, outputAmount }), @@ -179,10 +188,7 @@ function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumb return { title: t`Unknown Swap` } } -/** - * Wrap/unwrap transactions are labelled as lend transactions on the backend. - * This function parses the transaction changes to determine if the transaction is a wrap/unwrap transaction. - */ +/* function parses the transaction changes to determine if the transaction is a wrap/unwrap transaction. */ function parseLend(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) { const native = changes.TokenTransfer.find((t) => t.tokenStandard === 'NATIVE')?.asset const erc20 = changes.TokenTransfer.find((t) => t.tokenStandard === 'ERC20')?.asset @@ -337,7 +343,7 @@ function getLogoSrcs(changes: TransactionChanges): Array { return Array.from(logoSet) } -function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activity | undefined { +function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity, owner: string): Activity | undefined { // We currently only have a polling mechanism for locally-sent pending orders, so we hide remote pending orders since they won't update upon completion // TODO(WEB-2487): Add polling mechanism for remote orders to allow displaying remote pending orders if (details.orderStatus === SwapOrderStatus.Open) return undefined @@ -362,6 +368,7 @@ function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activ } return { + owner, hash: details.hash, chainId: supportedChain, status, @@ -377,13 +384,14 @@ function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activ } } -function parseRemoteActivity( +export function parseRemoteActivity( assetActivity: AssetActivityPartsFragment, + owner: string, formatNumberOrString: FormatNumberOrStringFunctionType ): Activity | undefined { try { if (assetActivity.details.__typename === 'SwapOrderDetails') { - return parseUniswapXOrder(assetActivity as OrderActivity) + return parseUniswapXOrder(assetActivity as OrderActivity, owner) } const changes = assetActivity.details.assetChanges.reduce( @@ -408,6 +416,7 @@ function parseRemoteActivity( } const defaultFields = { + owner, hash: assetActivity.details.hash, chainId: supportedChain, status: assetActivity.details.status, @@ -433,11 +442,13 @@ function parseRemoteActivity( export function parseRemoteActivities( formatNumberOrString: FormatNumberOrStringFunctionType, - assetActivities?: readonly AssetActivityPartsFragment[] + activityQuery?: ActivityQuery ) { - return assetActivities?.reduce((acc: { [hash: string]: Activity }, assetActivity) => { - const activity = parseRemoteActivity(assetActivity, formatNumberOrString) - if (activity) acc[activity.hash] = activity + return activityQuery?.portfolios?.reduce((acc: { [hash: string]: Activity }, portfolio) => { + const activitiesForCurrentOwner = portfolio.assetActivities?.map((assetActivity) => + parseRemoteActivity(assetActivity, portfolio.ownerAddress, formatNumberOrString) + ) + activitiesForCurrentOwner?.forEach((activity) => activity && (acc[activity.hash] = activity)) return acc }, {}) } diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts b/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts index 8fbe112149d..49e82ee7276 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts @@ -3,6 +3,7 @@ import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks' import { UniswapXOrderStatus } from 'lib/hooks/orders/types' export type Activity = { + owner: string hash: string chainId: ChainId status: TransactionStatus diff --git a/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx b/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx index 4833df30ce3..e1d8fd55d4c 100644 --- a/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx @@ -18,8 +18,8 @@ export default function NFTs({ account }: { account: string }) { DEFAULT_NFT_QUERY_AMOUNT, undefined, undefined, - undefined, - !walletDrawerOpen + undefined + // !walletDrawerOpen ) const [currentTokenPlayingMedia, setCurrentTokenPlayingMedia] = useState() diff --git a/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx b/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx index 4746de6406d..1b7634a21a3 100644 --- a/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx @@ -5,9 +5,7 @@ import { MissingImageLogo } from 'components/Logo/AssetLogo' import { ChainLogo, getDefaultBorderRadius } from 'components/Logo/ChainLogo' import { Unicon } from 'components/Unicon' import useTokenLogoSource from 'hooks/useAssetLogoSource' -import useENSAvatar from 'hooks/useENSAvatar' import React from 'react' -import { Loader } from 'react-feather' import styled from 'styled-components' const UnknownContract = styled(UnknownStatus)` @@ -118,16 +116,16 @@ function DoubleCurrencyLogo({ chainId, currencies, backupImages, size }: DoubleC ) } -function PortfolioAvatar({ accountAddress, size }: { accountAddress: string; size: string }) { - const { avatar, loading } = useENSAvatar(accountAddress, false) +export function PortfolioAvatar({ accountAddress, size = 40 }: { accountAddress: string; size?: number }) { + // const { avatar, loading } = useENSAvatar(accountAddress, false) - if (loading) { - return - } - if (avatar) { - return - } - return + // if (loading) { + // return + // } + // if (avatar) { + // return + // } + return } interface PortfolioLogoProps { @@ -164,7 +162,7 @@ export function PortfolioLogo(props: PortfolioLogoProps) { function getLogo({ chainId, accountAddress, currencies, images, size = '40px' }: PortfolioLogoProps) { if (accountAddress) { - return + return } if (currencies && currencies.length) { return diff --git a/src/components/AccountDrawer/index.tsx b/src/components/AccountDrawer/index.tsx index e357444d03f..58eb5760caf 100644 --- a/src/components/AccountDrawer/index.tsx +++ b/src/components/AccountDrawer/index.tsx @@ -78,7 +78,7 @@ export const Scrim = (props: ScrimBackgroundProps) => { return } -const AccountDrawerScrollWrapper = styled.div` +export const AccountDrawerScrollWrapper = styled.div` overflow: hidden; &:hover { overflow-y: auto; diff --git a/src/components/Charts/PriceChart/index.test.tsx b/src/components/Charts/PriceChart/index.test.tsx deleted file mode 100644 index 01139474e94..00000000000 --- a/src/components/Charts/PriceChart/index.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { TimePeriod } from 'graphql/data/util' -import { render, screen } from 'test-utils/render' - -import { PriceChart } from '.' - -jest.mock('components/Charts/AnimatedInLineChart', () => ({ - __esModule: true, - default: jest.fn(() => null), -})) -jest.mock('components/Charts/FadeInLineChart', () => ({ - __esModule: true, - default: jest.fn(() => null), -})) - -describe('PriceChart', () => { - it('renders correctly with all prices filled', () => { - const mockPrices = Array.from({ length: 13 }, (_, i) => ({ - value: 1, - timestamp: i * 3600, - })) - - const { asFragment } = render( - - ) - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('$1.00') - expect(asFragment().textContent).toContain('0.00%') - }) - it('renders correctly with some prices filled', () => { - const mockPrices = Array.from({ length: 13 }, (_, i) => ({ - value: i < 10 ? 1 : 0, - timestamp: i * 3600, - })) - - const { asFragment } = render( - - ) - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('$1.00') - expect(asFragment().textContent).toContain('0.00%') - }) - it('renders correctly with empty price array', () => { - const { asFragment } = render() - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('Price unavailable') - expect(asFragment().textContent).toContain('Missing price data due to recently low trading volume on Uniswap v3') - }) - it('renders correctly with undefined prices', () => { - const { asFragment } = render( - - ) - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('Price unavailable') - expect(asFragment().textContent).toContain('Missing chart data') - }) - it('renders stale UI', () => { - const { asFragment } = render( - - ) - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('$1.00') - expect(screen.getByTestId('chart-stale-icon')).toBeInTheDocument() - }) -}) diff --git a/src/components/Charts/PriceChart/index.tsx b/src/components/Charts/PriceChart/index.tsx index 3562328dc81..85c90c78281 100644 --- a/src/components/Charts/PriceChart/index.tsx +++ b/src/components/Charts/PriceChart/index.tsx @@ -4,6 +4,7 @@ import { localPoint } from '@visx/event' import { EventType } from '@visx/event/lib/types' import { GlyphCircle } from '@visx/glyph' import { Line } from '@visx/shape' +import { Activity } from 'components/AccountDrawer/MiniPortfolio/Activity/types' import AnimatedInLineChart from 'components/Charts/AnimatedInLineChart' import FadedInLineChart from 'components/Charts/FadeInLineChart' import { buildChartModel, ChartErrorType, ChartModel, ErroredChartModel } from 'components/Charts/PriceChart/ChartModel' @@ -12,9 +13,11 @@ import { getNearestPricePoint, getTicks } from 'components/Charts/PriceChart/uti import { MouseoverTooltip } from 'components/Tooltip' import { curveCardinal } from 'd3' import { PricePoint, TimePeriod } from 'graphql/data/util' +import { getV2Prices } from 'graphql/thegraph/getV2Prices' import { useActiveLocale } from 'hooks/useActiveLocale' -import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Info } from 'react-feather' +import { animated, SpringValue, useSpring } from 'react-spring' import styled, { useTheme } from 'styled-components' import { ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' @@ -22,7 +25,7 @@ import { useFormatter } from 'utils/formatNumbers' import { calculateDelta, DeltaArrow } from '../../Tokens/TokenDetails/Delta' -const CHART_MARGIN = { top: 100, bottom: 48, crosshair: 72 } +const CHART_MARGIN = { top: 70, bottom: 48, crosshair: 72 } const ChartHeaderWrapper = styled.div<{ stale?: boolean }>` position: absolute; @@ -75,6 +78,8 @@ function ChartHeader({ crosshairPrice, chart }: ChartHeaderProps) { const priceOutdated = lastValidPrice !== endingPrice const displayPrice = crosshairPrice ?? (priceOutdated ? lastValidPrice : endingPrice) + if (!crosshairPrice) return null + const displayIsStale = priceOutdated && !crosshairPrice return ( @@ -93,7 +98,19 @@ function ChartHeader({ crosshairPrice, chart }: ChartHeaderProps) { ) } -function ChartBody({ chart, timePeriod }: { chart: ChartModel; timePeriod: TimePeriod }) { +function ChartBody({ + chart, + timePeriod, + activity, + color, + hidePrice, +}: { + chart: ChartModel + timePeriod: TimePeriod + activity: Activity[] + color?: string + hidePrice?: boolean +}) { const locale = useActiveLocale() const { prices, blanks, timeScale, priceScale, dimensions } = chart @@ -142,9 +159,20 @@ function ChartBody({ chart, timePeriod }: { chart: ChartModel; timePeriod: TimeP const getY = useCallback((p: PricePoint) => priceScale(p.value), [priceScale]) const curve = useMemo(() => curveCardinal.tension(curveTension), [curveTension]) + const activityAtPricePoint = useMemo( + () => + activity?.map((activity) => ({ + activity, + pricePoint: getNearestPricePoint(timeScale(activity.timestamp), prices ?? [], timeScale), + })), + [activity, prices, timeScale] + ) + + const [selectedActivity, setSelectedActivity] = useState<{ activity: Activity; pricePoint: PricePoint }>() + return ( <> - + {!hidePrice && } {blanks.map((blank, index) => ( @@ -242,6 +271,27 @@ function ChartBody({ chart, timePeriod }: { chart: ChartModel; timePeriod: TimeP onMouseMove={setCrosshairOnHover} onMouseLeave={resetCrosshair} /> + {activityAtPricePoint?.map( + ({ activity, pricePoint }, index) => + pricePoint && ( + setSelectedActivity(selected ? { activity, pricePoint } : undefined)} + /> + ) + )} + {selectedActivity && ( + + )} ) @@ -288,21 +338,143 @@ interface PriceChartProps { height: number prices?: PricePoint[] timePeriod: TimePeriod + activity: Activity[] + color?: string + hidePrice?: boolean + backupAddress?: string } -export function PriceChart({ width, height, prices, timePeriod }: PriceChartProps) { +export function PriceChart({ + width, + height, + prices, + timePeriod, + activity, + color, + hidePrice, + backupAddress, +}: PriceChartProps) { + const [usedPrices, setUsedPrices] = useState(prices) + const chart = useMemo( () => buildChartModel({ dimensions: { width, height, marginBottom: CHART_MARGIN.bottom, marginTop: CHART_MARGIN.top }, - prices, + prices: usedPrices, }), - [width, height, prices] + [width, height, usedPrices] ) + useEffect(() => { + if (chart.error && backupAddress) { + getV2Prices(backupAddress).then((prices) => { + console.log('cartcrom', prices) + setUsedPrices(prices) + }) + } + }, [chart.error, backupAddress]) + if (chart.error !== undefined) { - return + return null + } + + return +} + +interface ActivityGlyphCircleProps extends Omit, 'ref'> { + setSelected: (selected: boolean) => void +} + +const ActivityGlyphCircle = animated(GlyphCircle) + +function ActivityGlyph({ setSelected, ...rest }: ActivityGlyphCircleProps) { + const [animatedProps, setAnimatedProps] = useSpring(() => ({ + size: 80, + textOpacity: 0, + config: { tension: 170, friction: 26 }, + })) + + const onMouseEnter = () => { + setSelected(true) + setAnimatedProps({ size: 250, textOpacity: 1 }) } - return + const onMouseLeave = () => { + setSelected(false) + setAnimatedProps({ size: 80, textOpacity: 0 }) + } + + return +} + +interface TextWithBackgroundProps { + text: string + x: number + y: number + opacity?: SpringValue +} + +const ACTIVITY_BOX_PADDING = 12 +const ACTIVITY_BOX_MARGIN = 16 +const ACTIVITY_FONT_SIZE = 14 + +function TextWithBackground({ text, x, y }: TextWithBackgroundProps) { + const [bbox, setBbox] = useState(null) + const textRef = useRef(null) + const theme = useTheme() + + const [{ opacity }, setAnimatedProps] = useSpring(() => ({ + opacity: 0, + config: { tension: 170, friction: 26 }, + })) + + useEffect(() => { + setAnimatedProps({ opacity: 0.8 }) + }, [setAnimatedProps]) + + useEffect(() => { + if (textRef.current) { + const currentBbox = textRef.current.getBBox() + setBbox(currentBbox) + } + }, [text]) + + const backgroundWidth = (bbox?.width ?? 0) + 2 * ACTIVITY_BOX_PADDING + const backgroundHeight = ACTIVITY_FONT_SIZE + 2 * ACTIVITY_BOX_PADDING + + const flip = x - (backgroundWidth + ACTIVITY_BOX_MARGIN) <= 0 + + const backgroundX = flip ? x + ACTIVITY_BOX_MARGIN : x - backgroundWidth - ACTIVITY_BOX_MARGIN + const backgroundY = y - 20 + + const textX = x + (flip ? 1 : -1) * (ACTIVITY_BOX_MARGIN + ACTIVITY_BOX_PADDING) + const textY = y + 4 + + return ( + + {bbox && ( + + )} + + {text} + + + ) } diff --git a/src/components/NavBar/SearchBarDropdown.tsx b/src/components/NavBar/SearchBarDropdown.tsx index 62e44377718..039e49ddf1b 100644 --- a/src/components/NavBar/SearchBarDropdown.tsx +++ b/src/components/NavBar/SearchBarDropdown.tsx @@ -11,7 +11,7 @@ import { useTrendingCollections } from 'graphql/data/nft/TrendingCollections' import { SearchToken } from 'graphql/data/SearchTokens' import useTrendingTokens from 'graphql/data/TrendingTokens' import { BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS } from 'graphql/data/util' -import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' +import useENS from 'hooks/useENS' import { useIsNftPage } from 'hooks/useIsNftPage' import { Box } from 'nft/components/Box' import { Column, Row } from 'nft/components/Flex' @@ -27,15 +27,20 @@ import { SuspendConditionally } from '../Suspense/SuspendConditionally' import { SuspenseWithPreviousRenderAsFallback } from '../Suspense/SuspenseWithPreviousRenderAsFallback' import { useRecentlySearchedAssets } from './RecentlySearchedAssets' import * as styles from './SearchBar.css' -import { CollectionRow, SkeletonRow, TokenRow } from './SuggestionRow' +import { CollectionRow, ProfileRow, SkeletonRow, TokenRow } from './SuggestionRow' -function isCollection(suggestion: GenieCollection | SearchToken | TrendingCollection) { +function isCollection(suggestion: GenieCollection | SearchToken | TrendingCollection | Profile) { return (suggestion as SearchToken).decimals === undefined } +type Profile = { address: string; name?: string; isProfile: true } +function isProfile(suggestion: GenieCollection | SearchToken | TrendingCollection | Profile): suggestion is Profile { + return (suggestion as Profile).isProfile === true +} + interface SearchBarDropdownSectionProps { toggleOpen: () => void - suggestions: (GenieCollection | SearchToken)[] + suggestions: (GenieCollection | SearchToken | Profile)[] header: JSX.Element headerIcon?: JSX.Element hoveredIndex?: number @@ -63,25 +68,38 @@ const SearchBarDropdownSection = ({ {header} - {suggestions.map((suggestion, index) => - isLoading || !suggestion ? ( - - ) : isCollection(suggestion) ? ( - - ) : ( + {suggestions.map((suggestion, index) => { + if (isLoading || !suggestion) return + if (isProfile(suggestion)) + return ( + + ) + + if (isCollection(suggestion)) + return ( + + ) + return ( ) - )} + })} ) @@ -169,7 +187,6 @@ function SearchBarDropdownContents({ const { pathname } = useLocation() const isNFTPage = useIsNftPage() const isTokenPage = pathname.includes('/tokens') - const shouldDisableNFTRoutes = useDisableNFTRoutes() const { data: trendingCollections, loading: trendingCollectionsAreLoading } = useTrendingCollections( 3, @@ -247,6 +264,34 @@ function SearchBarDropdownContents({ ...JSON.parse(trace), } + const ensResult = useENS(queryText) + const ensResultEthAppended = useENS(queryText + '.eth') + const profileAddress = ensResult.address ?? ensResultEthAppended.address ?? undefined + const profileName = ensResult.name ?? ensResultEthAppended.name ?? undefined + + const profileResult = profileAddress + ? ({ + address: profileAddress, + name: profileName ?? undefined, + isProfile: true, + } as const) + : undefined + + const ensSearchResults = profileResult ? ( + Profiles} + /> + ) : null + const tokenSearchResults = tokens.length > 0 ? ( ) - const collectionSearchResults = - collections.length > 0 ? ( - NFT collections} - /> - ) : ( - No NFT collections found. - ) - return hasInput ? ( // Empty or Up to 8 combined tokens and nfts - {showCollectionsFirst ? ( - <> - {collectionSearchResults} - {tokenSearchResults} - - ) : ( - <> - {tokenSearchResults} - {collectionSearchResults} - - )} + {ensSearchResults} + {tokenSearchResults} ) : ( // Recent Searches, Trending Tokens, Trending Collections @@ -335,22 +353,6 @@ function SearchBarDropdownContents({ isLoading={!trendingTokenData} /> )} - {Boolean(!isTokenPage && !shouldDisableNFTRoutes) && ( - Popular NFT collections} - headerIcon={} - isLoading={trendingCollectionsAreLoading} - /> - )} ) } diff --git a/src/components/NavBar/SuggestionRow.tsx b/src/components/NavBar/SuggestionRow.tsx index ed581a6f122..441079e13bf 100644 --- a/src/components/NavBar/SuggestionRow.tsx +++ b/src/components/NavBar/SuggestionRow.tsx @@ -1,6 +1,7 @@ import { InterfaceEventName } from '@uniswap/analytics-events' import { sendAnalyticsEvent } from 'analytics' import clsx from 'clsx' +import { PortfolioAvatar } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo' import QueryTokenLogo from 'components/Logo/QueryTokenLogo' import TokenSafetyIcon from 'components/TokenSafety/TokenSafetyIcon' import { checkSearchTokenWarning } from 'constants/tokenSafety' @@ -205,6 +206,73 @@ export const TokenRow = ({ token, isHovered, setHoveredIndex, toggleOpen, index, ) } +interface ProfileRowProps { + profile: { address: string; name?: string } + isHovered: boolean + setHoveredIndex: (index: number | undefined) => void + toggleOpen: () => void + index: number +} + +export const ProfileRow = ({ profile, isHovered, setHoveredIndex, toggleOpen, index }: ProfileRowProps) => { + const navigate = useNavigate() + + const profilePath = `/account/${profile.address}` + // Close the modal on escape + useEffect(() => { + const keyDownHandler = (event: KeyboardEvent) => { + if (event.key === 'Enter' && isHovered) { + event.preventDefault() + navigate(profilePath) + } + } + document.addEventListener('keydown', keyDownHandler) + return () => { + document.removeEventListener('keydown', keyDownHandler) + } + }, [toggleOpen, isHovered, navigate, profilePath]) + + // console.log('cartcrom2', profile) + + return ( + !isHovered && setHoveredIndex(index)} + onMouseLeave={() => isHovered && setHoveredIndex(undefined)} + className={styles.suggestionRow} + style={{ background: isHovered ? vars.color.lightGrayOverlay : 'none' }} + > + + + + + {profile.name ?? profile.address} + + + + + {/* + {!!token.market?.price?.value && ( + <> + + {formatFiatPrice({ price: token.market.price.value })} + + + + + + {formatDelta(Math.abs(token.market?.pricePercentChange?.value ?? 0))} + + + + + )} + */} + + ) +} + export const SkeletonRow = () => { return ( diff --git a/src/components/NavBar/index.tsx b/src/components/NavBar/index.tsx index aaeed3908b1..0aadee8c0c1 100644 --- a/src/components/NavBar/index.tsx +++ b/src/components/NavBar/index.tsx @@ -55,7 +55,7 @@ const MenuItem = ({ href, dataTestId, id, isActive, children }: MenuItemProps) = export const PageTabs = () => { const { pathname } = useLocation() - const { chainId: connectedChainId } = useWeb3React() + const { account, chainId: connectedChainId } = useWeb3React() const chainName = chainIdToBackendName(connectedChainId) const isPoolActive = useIsPoolsPage() @@ -88,6 +88,12 @@ export const PageTabs = () => { Pools + + Profile + + + Feed + diff --git a/src/components/Profile/FollowingModal.tsx b/src/components/Profile/FollowingModal.tsx new file mode 100644 index 00000000000..9b733d77d9c --- /dev/null +++ b/src/components/Profile/FollowingModal.tsx @@ -0,0 +1,122 @@ +import { PortfolioAvatar } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo' +import Column from 'components/Column' +import useENS from 'hooks/useENS' +import { useFollowedAccounts } from 'pages/Profile' +import { ReactNode } from 'react' +import { X } from 'react-feather' +import { useNavigate } from 'react-router-dom' +import { useModalIsOpen, useToggleFollowingModal } from 'state/application/hooks' +import { ApplicationModal } from 'state/application/reducer' +import styled from 'styled-components' +import { ThemedText } from 'theme/components' +import { shortenAddress } from 'utils/addresses' + +const StyledModal = styled.div` + position: fixed; + display: flex; + left: 50%; + top: 50vh; + transform: translate(-50%, -50%); + width: 400px; + height: fit-content; + color: ${({ theme }) => theme.neutral1}; + font-size: 18px; + padding: 20px 0px; + background-color: ${({ theme }) => theme.surface2}; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.surface3}; + z-index: 100; + flex-direction: column; + gap: 8px; + border: 1px solid ${({ theme }) => theme.surface3}; + + @media screen and (max-width: ${({ theme }) => theme.breakpoint.sm}px) { + max-height: 100vh; + } +` + +function Modal({ open, children }: { open: boolean; children: ReactNode }) { + return open ? {children} : null +} + +const FlagsColumn = styled(Column)` + max-height: 600px; + overflow-y: auto; + padding: 0px 20px; + + @media screen and (max-width: ${({ theme }) => theme.breakpoint.sm}px) { + max-height: unset; + } +` + +const Row = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0px; +` + +const CloseButton = styled.button` + cursor: pointer; + background: transparent; + border: none; + color: ${({ theme }) => theme.neutral1}; +` + +const Header = styled(Row)` + font-weight: 535; + font-size: 16px; + border-bottom: 1px solid ${({ theme }) => theme.surface3}; + margin-bottom: 8px; +` + +const Who = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + width: 100%; + align-items: center; + margin-top: 15px; +` + +const Following = ({ key, address, onClick }: { key: string; address: string; onClick: () => void }) => { + const { name } = useENS(address) + + return ( + + + {name ?? shortenAddress(address)} + + ) +} + +export default function FollowingModal() { + const open = useModalIsOpen(ApplicationModal.FOLLOWING) + const toggleModal = useToggleFollowingModal() + const navigate = useNavigate() + + const following = useFollowedAccounts() + + return ( + + +
+ Following + + + +
+ {following.map((address) => ( + { + toggleModal() + navigate(`/account/${address}`) + }} + key={address} + address={address} + /> + ))} +
+
+ ) +} diff --git a/src/components/SocialFeed/hooks.ts b/src/components/SocialFeed/hooks.ts new file mode 100644 index 00000000000..dfe4aa621ec --- /dev/null +++ b/src/components/SocialFeed/hooks.ts @@ -0,0 +1,388 @@ +import { t } from '@lingui/macro' +import { Currency } from '@uniswap/sdk-core' +import { parseRemoteActivities, parseRemoteActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/parseRemote' +import { Activity, ActivityMap } from 'components/AccountDrawer/MiniPortfolio/Activity/types' +import { + ActivityQuery, + TokenTransferPartsFragment, + TransactionType, + useActivityQuery, +} from 'graphql/data/__generated__/types-and-hooks' +import { AssetActivityDetails } from 'graphql/data/activity' +import { gqlToCurrency } from 'graphql/data/util' +import { useMemo } from 'react' +import { isSameAddress } from 'utils/addresses' +import { useFormatter } from 'utils/formatNumbers' + +// Mock data for friends' activity. +export const friendsActivity = [ + { + ensName: 'friend1.eth', + address: '0x24791Cac57BF48328D9FE103Ce402Cfe4c0D8b07', + description: 'Minted Azuki #2214', + timestamp: Date.now(), // 1 hour ago + image: + 'https://cdn.center.app/1/0xED5AF388653567Af2F388E6224dC7C4b3241C544/2214/92acd1de09f0f5e1c12a4f1b47306a8f7393f4053a32b439f5fc7ba8b797961e.png', + }, + { + address: '0x24791Cac57BF48328D9FE103Ce402Cfe4c0D8b07', + description: 'Swapped 0.1 ETH for 100 DAI', + timestamp: Date.now() - 1000 * 60 * 5, // 5 min ago + }, + { + ensName: 'friend1.eth', + address: '0x24791Cac57BF48328D9FE103Ce402Cfe4c0D8b07', + description: 'Swapped 0.1 ETH for 100 DAI', + timestamp: Date.now() - 1000 * 60 * 60 * 5, // 5 hours ago + }, + // More activities... +] + +enum JudgmentalTransaction { + GOT_RUGGED, + APED_INTO, + DUMPED, + STILL_HODLING, + GAINS, +} + +const JudgmentalTransactionTitleTable: { [key in JudgmentalTransaction]: string } = { + [JudgmentalTransaction.GOT_RUGGED]: t`Got rugged by`, + [JudgmentalTransaction.APED_INTO]: t`Aped into`, + [JudgmentalTransaction.DUMPED]: t`Dumped`, + [JudgmentalTransaction.STILL_HODLING]: t`Has been hodling`, + [JudgmentalTransaction.GAINS]: t`Made gains on`, +} + +function getProfit(buysAndSells: ReturnType['judgementalActivityMap'][string][string]) { + const { buys, sells, currentBalanceUSD } = buysAndSells + let profit = currentBalanceUSD + + for (const buy of buys) { + profit -= buy.USDValue + } + + for (const sell of sells) { + profit += sell.USDValue + } + + // console.log('cartcrom', buysAndSells, tokenName, profit) + return profit +} + +export type JudgementalActivity = { + negative: boolean + isJudgmental: boolean + owner: string + description: string + currency: Currency + timestamp: number + profit: number + activities: Activity[] + hodlingTimescale?: string +} + +function shouldHideNormalActivity(activity: Activity) { + return ( + activity.title.includes('Approv') || + activity.title.includes('Contract') || + activity.descriptor?.includes('Contract') || + activity.title.includes('Sent') || + activity.title?.includes('Swapped') || + activity.title.includes('Received') || + activity.title.includes('Unknown') + ) +} + +const NORMAL_FEED_LIMIT = 40 +export function useFeed(accounts: string[], filterAddress?: string) { + const { judgementalActivityMap: friendsBuysAndSells, normalActivityMap } = useAllFriendsBuySells(accounts) + + return useMemo(() => { + const sortedFeed = (Object.values(normalActivityMap ?? {}) as Activity[]).sort((a, b) => b.timestamp - a.timestamp) + const feed: (JudgementalActivity | Activity)[] = [] + let count = 0 + for (const item of sortedFeed) { + if ((count < 40 && !shouldHideNormalActivity(item)) || item.title.includes('Aped')) { + if (!filterAddress || item.currencies?.some((c) => isSameAddress(c?.wrapped.address, filterAddress))) { + feed.push(item) + count++ + } + } + } + + for (const friend in friendsBuysAndSells) { + const friendsTradedTokens = friendsBuysAndSells[friend] + console.log('cartcrom', friendsTradedTokens) + for (const tokenAddress in friendsTradedTokens) { + if (filterAddress && !isSameAddress(tokenAddress, filterAddress)) continue + const { currentBalanceUSD, currency, activities } = friendsTradedTokens[tokenAddress] + const userSold = currentBalanceUSD === 0 + const profit = getProfit(friendsTradedTokens[tokenAddress]) + + // if (friend === '0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F') console.log('cartcrom', { profit, tokenAddress }) + + const feedItemBase = { isJudgmental: true, owner: friend, timeStamp: Date.now(), currency, profit } // TODO(now) use time relevant to transaction + + const lastSellTimestamp = friendsTradedTokens[tokenAddress].sells.reduce((max, sell) => { + return Math.max(max, sell.timestamp) + }, 0) + const firstBuyTimestamp = friendsTradedTokens[tokenAddress].buys.reduce((min, buy) => { + return Math.min(min, buy.timestamp) + }, Infinity) + const sixMonthsAgo = Date.now() - 6 * 30 * 24 * 60 * 60 * 1000 + const oneYearAgo = Date.now() - 12 * 30 * 24 * 60 * 60 * 1000 + if ((currentBalanceUSD > 0 && lastSellTimestamp < sixMonthsAgo) || firstBuyTimestamp > oneYearAgo) { + feed.push({ + ...feedItemBase, + description: JudgmentalTransactionTitleTable[JudgmentalTransaction.STILL_HODLING], + timestamp: lastSellTimestamp === 0 ? firstBuyTimestamp : lastSellTimestamp, + activities, + negative: true, + hodlingTimescale: `for ${lastSellTimestamp === 0 ? 'one year' : 'six months'} now`, + }) + } + if (currentBalanceUSD > 0 && lastSellTimestamp !== 0 && lastSellTimestamp < oneYearAgo) { + feed.push({ + ...feedItemBase, + description: JudgmentalTransactionTitleTable[JudgmentalTransaction.STILL_HODLING], + timestamp: lastSellTimestamp, + activities, + negative: true, + hodlingTimescale: `for one year now`, + }) + } + if (profit < -100) { + feed.push({ + ...feedItemBase, + description: JudgmentalTransactionTitleTable[JudgmentalTransaction.GOT_RUGGED], + timestamp: Math.max(...(activities.map((a) => a.timestamp) ?? Date.now() / 1000)) + 1, + activities, + negative: true, + }) + } else if (profit > 200) { + feed.push({ + ...feedItemBase, + description: JudgmentalTransactionTitleTable[JudgmentalTransaction.GAINS], + timestamp: Math.max(...(activities.map((a) => a.timestamp) ?? Date.now() / 1000)) + 1, + activities, + negative: false, + }) + } + } + } + return feed.sort((a, b) => b.timestamp - a.timestamp).slice(0, 300) + }, [filterAddress, friendsBuysAndSells, normalActivityMap]) + + // console.log('cartcrom', feed) +} + +// function getJudgmentalTransactionTitle(tx: TransactionDetails): string { +// const changes: Readonly = tx.assetChanges +// for (const c of changes) { +// if (c.transactedValue && c.transactedValue.value > 500) { +// // fixme is value in bips? or usd? +// return JudgmentalTransactionTitleTable[JudgmentalTransaction.APED_INTO] +// } +// } +// } + +function assetIsEthStablecoin(symbol: string) { + return symbol === 'USDT' || symbol === 'USDC' || symbol === 'DAI' || symbol === 'ETH' || symbol === 'WETH' +} + +/* Returns allFriendsActivities in shape of [friend1Portfolio, friend2Portfolio]. Each portfolio contains attribute ownerAddress */ +function useAllFriendsActivites(accounts: string[]): { + allFriendsActivities?: ActivityQuery + loading: boolean + refetch: () => Promise +} { + // const followingAccounts = useFollowedAccounts() + const { + data: allFriendsActivities, + loading, + refetch, + } = useActivityQuery({ + variables: { accounts }, + errorPolicy: 'all', + fetchPolicy: 'cache-first', + }) + return { allFriendsActivities, loading, refetch } +} + +type BuySellMap = { + [ownerAddress: string]: { + [tokenAddress: string]: { + currency: Currency + buys: SwapInfo[] + sells: SwapInfo[] + activities: Activity[] + currentBalanceUSD: number + } + } +} + +type SwapInfo = { + type: 'buy' | 'sell' + inputToken: Currency + outputToken: Currency + txHash: string + quantity: number + USDValue: number + timestamp: number +} + +// Returns all activites by ownerAddress : tokenId : [buys & sells] +function useAllFriendsBuySells(accounts: string[]): { + judgementalActivityMap: BuySellMap + normalActivityMap?: ActivityMap +} { + const { allFriendsActivities, loading } = useAllFriendsActivites(accounts) + const { formatNumberOrString } = useFormatter() + + return useMemo(() => { + const normalActivityMap = parseRemoteActivities(formatNumberOrString, allFriendsActivities) + const map: BuySellMap = {} + if (loading) return { judgementalActivityMap: {}, normalActivityMap: {} } + + const friendBalanceMap = allFriendsActivities?.portfolios?.reduce((acc, curr) => { + if (!curr) return acc + if (!acc[curr.ownerAddress]) acc[curr.ownerAddress] = {} + + curr.tokenBalances?.forEach((balance) => { + acc[curr.ownerAddress][balance.token?.address ?? 'NATIVE'] = balance.denominatedValue?.value ?? 0 + }, {}) + + return acc + }, {} as { [ownerAddress: string]: { [tokenAddress: string]: number } }) + + allFriendsActivities?.portfolios?.map((portfolio) => { + const buySells: BuySellMap[string] = {} + + for (const tx of portfolio.assetActivities ?? []) { + const details: AssetActivityDetails = tx.details + if (details.__typename === 'TransactionDetails' && details.type === TransactionType.Swap) { + const transfers = details.assetChanges.filter( + (c) => c.__typename === 'TokenTransfer' + ) as TokenTransferPartsFragment[] + + const sent = transfers.find((t) => t.direction === 'OUT') + // Any leftover native token is refunded on exact_out swaps where the input token is native + const refund = transfers.find( + (t) => t.direction === 'IN' && t.asset.id === sent?.asset.id && t.asset.standard === 'NATIVE' + ) + const received = transfers.find((t) => t.direction === 'IN' && t !== refund) + + const sentCurrency = sent?.asset ? gqlToCurrency(sent?.asset) : undefined + const receivedCurrency = received?.asset ? gqlToCurrency(received?.asset) : undefined + if (!sentCurrency || !receivedCurrency || !received || !sent) continue + + // if sent is ethstablecoin, received is not ==> IS BUY + if (assetIsEthStablecoin(sent?.asset.symbol ?? '') && !assetIsEthStablecoin(received?.asset.symbol ?? '')) { + const swapInfo = { + type: 'buy', + inputToken: sentCurrency, + outputToken: receivedCurrency, + txHash: details.hash, + quantity: Number(received.quantity), + USDValue: sent.transactedValue?.value ?? 0, + activity: parseRemoteActivity(tx, portfolio.ownerAddress, formatNumberOrString), + timestamp: tx.timestamp, + } as const + const receivedAssetAddress = received.asset.address ?? 'Other' + if (!buySells[receivedAssetAddress]) { + buySells[receivedAssetAddress] = { + currency: receivedCurrency, + buys: [], + sells: [], + activities: [], + currentBalanceUSD: friendBalanceMap?.[portfolio.ownerAddress][receivedAssetAddress] ?? 0, + } + } + buySells[receivedAssetAddress].buys.push(swapInfo) + const activity = parseRemoteActivity(tx, portfolio.ownerAddress, formatNumberOrString) + if (activity) buySells[receivedAssetAddress].activities.push(activity) + } else if ( + sent && + received && + assetIsEthStablecoin(received?.asset.symbol ?? '') && + !assetIsEthStablecoin(sent?.asset.symbol ?? '') + ) { + const swapInfo = { + type: 'sell', + inputToken: sentCurrency, + outputToken: receivedCurrency, + txHash: details.hash, + quantity: Number(sent.quantity), + USDValue: received.transactedValue?.value ?? 0, + timestamp: tx.timestamp, + } as const + const sentAssetAddress = sent.asset.address ?? 'Other' + if (!buySells[sentAssetAddress]) { + buySells[sentAssetAddress] = { + currency: sentCurrency, + buys: [], + sells: [], + activities: [], + currentBalanceUSD: friendBalanceMap?.[portfolio.ownerAddress][sentAssetAddress] ?? 0, + } + } + buySells[sentAssetAddress].sells.push(swapInfo) + const activity = parseRemoteActivity(tx, portfolio.ownerAddress, formatNumberOrString) + if (activity) buySells[sentAssetAddress].activities.push(activity) + } + // if sent is not & received is ethstablecoin => iS SELL + + // if (transfers.length == 2) { + // // lol make our lives easier, ignore refund exact swaps for now + // for (let i = 0; i < 2; i++) { + // const assetChange = transfers[i] + // const otherAssetChange = transfers[i === 1 ? 0 : 1] as AssetChange + // if (assetChange.__typename === 'TokenTransfer' && otherAssetChange.__typename === 'TokenTransfer') { + // if ( + // assetIsEthStablecoin(assetChange.asset.symbol ?? '') && + // !assetIsEthStablecoin(otherAssetChange.asset.symbol ?? '') + // ) { + // const otherAssetAddress = otherAssetChange.asset.address ?? 'Other' + + // if (!buySells[otherAssetAddress]) { + // buySells[otherAssetAddress] = { + // buys: [], + // sells: [], + // currentBalanceUSD: friendBalanceMap?.[portfolio.ownerAddress][otherAssetAddress] ?? 0, + // } + // } + // if (assetChange.direction === 'OUT') { + // // if stablecoin goes out, it's a buy + // const swapInfo = { + // type: 'buy', + // inputToken: assetChange.asset.symbol ?? '', + // outputToken: otherAssetChange.asset.symbol ?? '', + // txHash: details.hash, + // quantity: Number(otherAssetChange.quantity), + // USDValue: assetChange.transactedValue?.value ?? 0, + // } as const + // buySells[otherAssetAddress].buys.push(swapInfo) + // } else { + // const swapInfo = { + // type: 'sell', + // inputToken: otherAssetChange.asset.symbol ?? '', + // outputToken: assetChange.asset.symbol ?? '', + // txHash: details.hash, + // quantity: Number(otherAssetChange.quantity), + // USDValue: assetChange.transactedValue?.value ?? 0, + // } as const + // buySells[otherAssetAddress].sells.push(swapInfo) + // } + // continue + // } + // } + // } + // } + } + } + map[portfolio.ownerAddress] = buySells + }) + return { judgementalActivityMap: map, normalActivityMap } + }, [allFriendsActivities, formatNumberOrString, loading]) +} diff --git a/src/components/Tokens/TokenDetails/ChartSection.tsx b/src/components/Tokens/TokenDetails/ChartSection.tsx index 4775363e030..d408d3f581a 100644 --- a/src/components/Tokens/TokenDetails/ChartSection.tsx +++ b/src/components/Tokens/TokenDetails/ChartSection.tsx @@ -1,7 +1,11 @@ +import { Currency } from '@uniswap/sdk-core' import { ParentSize } from '@visx/responsive' +import { useWeb3React } from '@web3-react/core' +import { useAllActivities } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' +import { Activity } from 'components/AccountDrawer/MiniPortfolio/Activity/types' import { ChartContainer, LoadingChart } from 'components/Tokens/TokenDetails/Skeleton' import { TokenPriceQuery } from 'graphql/data/TokenPrice' -import { isPricePoint, PricePoint } from 'graphql/data/util' +import { gqlToCurrency, isPricePoint, PricePoint } from 'graphql/data/util' import { TimePeriod } from 'graphql/data/util' import { useAtomValue } from 'jotai/utils' import { pageTimePeriodAtom } from 'pages/TokenDetails' @@ -10,10 +14,28 @@ import { startTransition, Suspense, useMemo } from 'react' import { PriceChart } from '../../Charts/PriceChart' import TimePeriodSelector from './TimeSelector' -function usePriceHistory(tokenPriceData: TokenPriceQuery): PricePoint[] | undefined { +const TOKEN_ACTIVITIES = ['Swapped', 'Sent', 'Received'] + +function activityIncludesCurrency(activity: Activity, currency: Currency): boolean { + return activity.currencies?.some((_currency) => _currency?.equals(currency)) ?? false +} + +function useTokenActivity(account?: string, currency?: Currency, types: string[] = TOKEN_ACTIVITIES): Activity[] { + const { activities } = useAllActivities(account ?? '') + + return useMemo(() => { + if (!account || !activities || !currency) return [] + + return activities.filter( + (activity) => activityIncludesCurrency(activity, currency) && types.includes(activity.title) + ) + }, [account, activities, currency, types]) +} + +export function usePriceHistory(tokenPriceData?: TokenPriceQuery): PricePoint[] | undefined { // Appends the current price to the end of the priceHistory array const priceHistory = useMemo(() => { - const market = tokenPriceData.token?.market + const market = tokenPriceData?.token?.market const priceHistory = market?.priceHistory?.filter(isPricePoint) const currentPrice = market?.price?.value if (Array.isArray(priceHistory) && currentPrice !== undefined) { @@ -57,10 +79,23 @@ function Chart({ // Initializes time period to global & maintain separate time period for subsequent changes const timePeriod = useAtomValue(pageTimePeriodAtom) + const { account } = useWeb3React() + const currency = tokenPriceQuery.token ? gqlToCurrency(tokenPriceQuery.token) : undefined + const activity = useTokenActivity(account, currency) + return ( - {({ width }) => } + {({ width }) => ( + + )} ) => void }>() - const [openTokenSafetyModal, setOpenTokenSafetyModal] = useState(false) + const feed = useFeed([...useFollowedAccounts(), useWeb3React().account ?? ''], detailedToken?.wrapped.address) const onResolveSwap = useCallback( (value: boolean) => { @@ -270,6 +278,13 @@ export default function TokenDetails({ {tokenWarning && } {!isInfoTDPEnabled && detailedToken && } + + + + Activity + + + {!isInfoTDPEnabled && detailedToken && } diff --git a/src/graphql/data/activity.graphql b/src/graphql/data/activity.graphql index 416f6b34bd0..32e0ffe6b2f 100644 --- a/src/graphql/data/activity.graphql +++ b/src/graphql/data/activity.graphql @@ -161,11 +161,56 @@ fragment AssetActivityParts on AssetActivity { } # TODO(UniswapX): return to a pagesize of 50 pre-launch -query Activity($account: String!) { - portfolios(ownerAddresses: [$account]) { +query Activity($accounts: [String!]!) { + portfolios(ownerAddresses: $accounts) { id + ownerAddress assetActivities(pageSize: 100, page: 1, includeOffChain: true) { ...AssetActivityParts } + tokensTotalDenominatedValue { + id + value + } + tokensTotalDenominatedValueChange(duration: DAY) { + absolute { + id + value + } + percentage { + id + value + } + } + tokenBalances { + id + quantity + denominatedValue { + id + currency + value + } + tokenProjectMarket { + id + pricePercentChange(duration: DAY) { + id + value + } + tokenProject { + id + logoUrl + isSpam + } + } + token { + id + chain + address + name + symbol + standard + decimals + } + } } } diff --git a/src/graphql/data/activity.ts b/src/graphql/data/activity.ts new file mode 100644 index 00000000000..1499a004deb --- /dev/null +++ b/src/graphql/data/activity.ts @@ -0,0 +1,5 @@ +import { ActivityQuery } from './__generated__/types-and-hooks' + +type Portfolio = NonNullable[number] +export type AssetActivity = NonNullable[number] +export type AssetActivityDetails = AssetActivity['details'] diff --git a/src/graphql/thegraph/getV2Prices.ts b/src/graphql/thegraph/getV2Prices.ts new file mode 100644 index 00000000000..ab116c1614b --- /dev/null +++ b/src/graphql/thegraph/getV2Prices.ts @@ -0,0 +1,35 @@ +import { PricePoint } from 'graphql/data/util' +export async function getV2Prices(tokenAddress: string): Promise { + const requestBody = ` + query tokenDayDatas { + tokenDayDatas(first: 1000, orderBy: date, orderDirection: asc, where: { token: "${tokenAddress.toLowerCase()}" }) { + id + date + priceUSD + totalLiquidityToken + totalLiquidityUSD + totalLiquidityETH + dailyVolumeETH + dailyVolumeToken + dailyVolumeUSD + } + } + ` + + try { + const data = await fetch('https://api.thegraph.com/subgraphs/name/ianlapham/uniswap-v2-dev', { + method: 'POST', + body: JSON.stringify({ query: requestBody }), + }) + + const response = await data.json() + const pricePoints: PricePoint[] = [] + response.data.tokenDayDatas?.map((day: any) => { + pricePoints.push({ timestamp: day.date, value: day.priceUSD }) + }) + return pricePoints + } catch (e) { + console.log('cartcrom', 'v2 prices error', e) + return [] + } +} diff --git a/src/pages/Explore/index.tsx b/src/pages/Explore/index.tsx index 5f4c6cc8edf..4a43b3e6db6 100644 --- a/src/pages/Explore/index.tsx +++ b/src/pages/Explore/index.tsx @@ -19,7 +19,7 @@ import { ThemedText } from 'theme/components' import { useExploreParams } from './redirects' -const ExploreContainer = styled.div` +export const ExploreContainer = styled.div` width: 100%; min-width: 320px; padding: 68px 12px 0px; diff --git a/src/pages/Pool/styled.tsx b/src/pages/Pool/styled.tsx index 862865ddae2..02a79560169 100644 --- a/src/pages/Pool/styled.tsx +++ b/src/pages/Pool/styled.tsx @@ -1,6 +1,7 @@ import { LoadingRows as BaseLoadingRows } from 'components/Loader/styled' import { Text } from 'rebass' import styled from 'styled-components' +import { ClickableStyle } from 'theme/components' export const Wrapper = styled.div` position: relative; @@ -11,7 +12,8 @@ export const ClickableText = styled(Text)` :hover { cursor: pointer; } - color: ${({ theme }) => theme.accent1}; + ${ClickableStyle}; + /* color: ${({ theme }) => theme.accent1}; */ ` export const MaxButton = styled.button<{ width: string }>` padding: 0.5rem 1rem; diff --git a/src/pages/Profile/index.tsx b/src/pages/Profile/index.tsx new file mode 100644 index 00000000000..ea9445d0cfb --- /dev/null +++ b/src/pages/Profile/index.tsx @@ -0,0 +1,274 @@ +import { Trans } from '@lingui/macro' +import { InterfaceElementName } from '@uniswap/analytics-events' +import { ParentSize } from '@visx/responsive' +import { useWeb3React } from '@web3-react/core' +import { AccountDrawerScrollWrapper } from 'components/AccountDrawer' +import { AccountNamesWrapper } from 'components/AccountDrawer/AuthenticatedHeader' +import NFTs from 'components/AccountDrawer/MiniPortfolio/NFTs' +import { PortfolioAvatar } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo' +import Tokens from 'components/AccountDrawer/MiniPortfolio/Tokens' +import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button' +import { PriceChart } from 'components/Charts/PriceChart' +import Column from 'components/Column' +import FollowingModal from 'components/Profile/FollowingModal' +import Row, { RowBetween } from 'components/Row' +import { useFeed } from 'components/SocialFeed/hooks' +import { usePriceHistory } from 'components/Tokens/TokenDetails/ChartSection' +import { getConnection } from 'connection' +import { UNI } from 'constants/tokens' +import { + Chain, + HistoryDuration, + usePortfolioBalancesQuery, + useTokenPriceQuery, +} from 'graphql/data/__generated__/types-and-hooks' +import { GQL_MAINNET_CHAINS, TimePeriod } from 'graphql/data/util' +import useENS from 'hooks/useENS' +import { atomWithStorage, useAtomValue, useUpdateAtom } from 'jotai/utils' +import { ActivityList } from 'pages/SocialFeed' +import { useCallback } from 'react' +import { useParams } from 'react-router-dom' +import { useOpenModal } from 'state/application/hooks' +import { ApplicationModal } from 'state/application/reducer' +import styled from 'styled-components' +import { CopyHelper, Separator, ThemedText } from 'theme/components' +import { opacify } from 'theme/utils' +import { shortenAddress } from 'utils' + +const followingAtom = atomWithStorage('following', []) +export function useFollowedAccounts() { + return useAtomValue(followingAtom) +} + +function useIsFollowingAccount(account: string) { + return useFollowedAccounts().includes(account.toLowerCase()) +} + +function useToggleFollowingAccount(): (account: string) => void { + const updateFollowing = useUpdateAtom(followingAtom) + return useCallback( + (account: string) => { + updateFollowing((following) => { + const lowercasedAccount = account.toLowerCase() + const index = following.indexOf(lowercasedAccount, 0) + + if (index !== -1) { + const newFollowing = [...following] + newFollowing.splice(index, 1) + return newFollowing + } // remove if already following + return [...following, lowercasedAccount] // add follower if not yet following + }) + }, + [updateFollowing] + ) +} + +const Container = styled(Column)` + /* justify-items: center; */ + margin-top: 64px; + width: 1120px; + gap: 20px; +` + +const RemoveMarginWrapper = styled.div` + margin: 0 -16px 0 -16px; +` + +const TabContainer = styled(Column)` + background-color: ${({ theme }) => theme.surface2}; + padding: 16px; + border-radius: 20px; + width: 360px; + height: 580px; + overflow: hidden; +` + +const ChartContainer = styled(TabContainer)` + background-color: ${({ theme }) => opacify(8, theme.accent1)}; + padding: 16px; + height: unset; + width: 100%; +` + +const ActivityContainer = styled(TabContainer)` + height: 990px; + min-width: 500px; +` + +const CopyText = styled(CopyHelper).attrs({ + iconSize: 14, + iconPosition: 'right', +})`` + +const FollowButton = styled.button` + border-radius: 20px; + border: 1.5px solid ${({ theme }) => theme.accent1}; + width: 200px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + background-color: ${({ theme }) => opacify(10, theme.accent1)}; + :hover { + background-color: ${({ theme }) => opacify(24, theme.accent1)}; + } + + transition: ${({ + theme: { + transition: { duration, timing }, + }, + }) => `background-color ${duration.fast} ${timing.ease}`}; +` + +const FollowingContainer = styled.button` + border-radius: 20px; + border: 1.5px solid #ffefff; + width: 200px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + text-color: #fc72ff; + background-color: #ffefff; + :hover { + background-color: ${({ theme }) => opacify(24, theme.accent1)}; + } + + transition: ${({ + theme: { + transition: { duration, timing }, + }, + }) => `background-color ${duration.fast} ${timing.ease}`}; +` + +const Pages = [ + { + title: Tokens, + key: 'tokens', + Component: Tokens, + loggingElementName: InterfaceElementName.MINI_PORTFOLIO_TOKENS_TAB, + }, + { + title: NFTs, + key: 'nfts', + Component: NFTs, + loggingElementName: InterfaceElementName.MINI_PORTFOLIO_NFT_TAB, + }, + // { + // title: Pools, + // key: 'pools', + // Component: Pools, + // loggingElementName: InterfaceElementName.MINI_PORTFOLIO_POOLS_TAB, + // }, + // { + // title: + // key: 'activity', + // Component: ActivityTab, + // loggingElementName: InterfaceElementName.MINI_PORTFOLIO_ACTIVITY_TAB, + // }, +] + +export default function ProfilePage() { + const { accountAddress = '' } = useParams() + const { account: currentAccount } = useWeb3React() + const connection = getConnection(useWeb3React().connector) + const { address: ensResolvedAddress, name } = useENS(accountAddress) + + const account = ensResolvedAddress ?? accountAddress + + usePortfolioBalancesQuery({ + skip: !account, + variables: { ownerAddress: account ?? '', chains: GQL_MAINNET_CHAINS }, + errorPolicy: 'all', + }) + + const { data: tokenPriceQuery } = useTokenPriceQuery({ + variables: { + address: UNI[1].address, + chain: Chain.Ethereum, + duration: HistoryDuration.Week, + }, + errorPolicy: 'all', + }) + const prices = usePriceHistory(tokenPriceQuery) + + const isFollowingAccount = useIsFollowingAccount(account) + const toggleFollowingAccount = useToggleFollowingAccount() + + const openFollowingModal = useOpenModal(ApplicationModal.FOLLOWING) + + const feed = useFeed([account]) + + return ( + + + + + + + {name ? shortenAddress(account) : account} + + {/* Displays smaller view of account if ENS name was rendered above */} + {name && ( + + + {name} + + + )} + + + {currentAccount === account ? ( + openFollowingModal()}> + Followers + + ) : ( + toggleFollowingAccount(account)}> + + {isFollowingAccount ? Following : + Follow} + + + )} + + + + + + + + {({ width }) => ( + + )} + + + + + {Pages.map(({ title, key, Component }) => ( + + + {title} + + + + + + ))} + + + + + + Activity + + + + + + + + + ) +} diff --git a/src/pages/RouteDefinitions.tsx b/src/pages/RouteDefinitions.tsx index 0ff735ad111..8dd52162acf 100644 --- a/src/pages/RouteDefinitions.tsx +++ b/src/pages/RouteDefinitions.tsx @@ -13,7 +13,7 @@ import Swap from './Swap' const NftExplore = lazy(() => import('nft/pages/explore')) const Collection = lazy(() => import('nft/pages/collection')) -const Profile = lazy(() => import('nft/pages/profile')) +const NftProfile = lazy(() => import('nft/pages/profile')) const Asset = lazy(() => import('nft/pages/asset/Asset')) const AddLiquidity = lazy(() => import('pages/AddLiquidity')) const Explore = lazy(() => import('pages/Explore')) @@ -30,8 +30,10 @@ const PoolDetails = lazy(() => import('pages/PoolDetails')) const PoolFinder = lazy(() => import('pages/PoolFinder')) const RemoveLiquidity = lazy(() => import('pages/RemoveLiquidity')) const RemoveLiquidityV3 = lazy(() => import('pages/RemoveLiquidity/V3')) +const SocialFeed = lazy(() => import('pages/SocialFeed')) const TokenDetails = lazy(() => import('pages/TokenDetails')) const Vote = lazy(() => import('pages/Vote')) +const Profile = lazy(() => import('pages/Profile')) // this is the same svg defined in assets/images/blue-loader.svg // it is defined here because the remote asset may not have had time to load when this file is executing @@ -218,7 +220,7 @@ export const routes: RouteDefinition[] = [ path: '/nfts/profile', getElement: () => ( - + ), enabled: (args) => !args.shouldDisableNFTRoutes, @@ -241,6 +243,24 @@ export const routes: RouteDefinition[] = [ ), enabled: (args) => !args.shouldDisableNFTRoutes, }), + createRouteDefinition({ + path: '/account/:accountAddress', + getElement: () => ( + + + + ), + enabled: (args) => !args.shouldDisableNFTRoutes, + }), + createRouteDefinition({ + path: '/feed', + getElement: () => ( + + + + ), + enabled: (args) => !args.shouldDisableNFTRoutes, + }), createRouteDefinition({ path: '*', getElement: () => }), createRouteDefinition({ path: '/not-found', getElement: () => }), ] diff --git a/src/pages/SocialFeed/index.tsx b/src/pages/SocialFeed/index.tsx new file mode 100644 index 00000000000..e36be62d039 --- /dev/null +++ b/src/pages/SocialFeed/index.tsx @@ -0,0 +1,155 @@ +import { Trans } from '@lingui/macro' +import { FeedRow } from 'components/AccountDrawer/MiniPortfolio/Activity/ActivityRow' +import { Activity } from 'components/AccountDrawer/MiniPortfolio/Activity/types' +import LoadingSpinner from 'components/Icons/LoadingSpinner' +import Row from 'components/Row' +import { JudgementalActivity, useFeed } from 'components/SocialFeed/hooks' +import { Unicon } from 'components/Unicon' +import useENSAvatar from 'hooks/useENSAvatar' +import { useFollowedAccounts } from 'pages/Profile' +import React from 'react' +import styled from 'styled-components' +import { ThemedText } from 'theme/components' + +export const ExploreContainer = styled.div` + width: 100%; + display: flex; + flex-direction: column; + min-width: 320px; + padding: 68px 12px 0px; + + @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) { + padding-top: 48px; + } + + @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) { + padding-top: 20px; + } +` +const TitleContainer = styled.div` + margin-bottom: 32px; + max-width: ${({ theme }) => theme.breakpoint.lg}; + margin-left: auto; + margin-right: auto; + display: flex; +` + +const FeedContainer = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + + max-width: ${({ theme }) => theme.breakpoint.lg}; + + margin-left: auto; + margin-right: auto; + + justify-content: flex-start; + align-items: flex-start; +` + +const ActivityCard = styled.div` + display: flex; + flex-direction: column; + + gap: 20px; + padding: 20px; + width: 500px; + + background-color: ${({ theme }) => theme.surface1}; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.surface3}; +` +const CardHeader = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 20px; + justify-content: space-between; + white-space: nowrap; +` + +const Who = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + width: 100%; +` + +const ENSAvatarImg = styled.img` + border-radius: 8px; + height: 30px; + width: 30px; +` +function PortfolioAvatar({ accountAddress }: { accountAddress: string }) { + const { avatar, loading } = useENSAvatar(accountAddress, false) + + // if (loading) { + // return + // } + if (avatar) { + return + } + return +} + +export const ActivityList = ({ + feed, + hidePrice, +}: { + feed: (Activity | JudgementalActivity)[] + hidePrice?: boolean +}) => { + return ( + + {feed.map((activity, i) => ( + + ))} + {/* {friendsActivity + .sort((a, b) => b.timestamp - a.timestamp) + .map((activity, index) => { + return ( + + + + + + {activity.ensName ?? shortenAddress(activity.address)} + + + {getTimeDifference(activity.timestamp.toString())} + + {activity.description} + {activity.image && ( + activity image + )} + + ) + })} */} + + ) +} + +function ActivityFeed() { + const friends = useFollowedAccounts() + const feed = useFeed(friends) + return ( + + + + + Feed + + + + {feed.length === 0 && ( + + + + )} + + + ) +} + +export default ActivityFeed diff --git a/src/state/application/hooks.ts b/src/state/application/hooks.ts index a4123bfbddd..94ec760e208 100644 --- a/src/state/application/hooks.ts +++ b/src/state/application/hooks.ts @@ -150,6 +150,10 @@ export function useToggleFeatureFlags(): () => void { return useToggleModal(ApplicationModal.FEATURE_FLAGS) } +export function useToggleFollowingModal(): () => void { + return useToggleModal(ApplicationModal.FOLLOWING) +} + // returns a function that allows adding a popup export function useAddPopup(): (content: PopupContent, key?: string, removeAfterMs?: number) => void { const dispatch = useAppDispatch() diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index 747153d2fae..d65868ec650 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -30,6 +30,7 @@ export enum ApplicationModal { EXECUTE, FEATURE_FLAGS, FIAT_ONRAMP, + FOLLOWING, MENU, METAMASK_CONNECTION_ERROR, NETWORK_FILTER,