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 && }
>
)
@@ -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
+
+
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.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 && (
+
+ )}
+
+ )
+ })} */}
+
+ )
+}
+
+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,