ebc4bd6bcf
* [Portfolio Graph] Shows a graph of the portfolio value over time * [PortfolioGraph] Fix some nits. * [PortfolioGraph] Comment out portfolio-value-section Hides the component completely for now, so we can land today. My plan would be to land today, wait for the history to build up, and then revert this commit. As opposed to leaving the PR idle for a while, and then have to deal with conflicts. * [PortfolioGraph] Rm duplicate firestore rule
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import clsx from 'clsx'
|
|
import { uniq } from 'lodash'
|
|
import { useEffect, useState } from 'react'
|
|
import { useRouter } from 'next/router'
|
|
import { LinkIcon } from '@heroicons/react/solid'
|
|
import { PencilIcon } from '@heroicons/react/outline'
|
|
import Confetti from 'react-confetti'
|
|
|
|
import {
|
|
follow,
|
|
unfollow,
|
|
User,
|
|
getPortfolioHistory,
|
|
} from 'web/lib/firebase/users'
|
|
import { CreatorContractsList } from './contract/contracts-list'
|
|
import { SEO } from './SEO'
|
|
import { Page } from './page'
|
|
import { SiteLink } from './site-link'
|
|
import { Avatar } from './avatar'
|
|
import { Col } from './layout/col'
|
|
import { Linkify } from './linkify'
|
|
import { Spacer } from './layout/spacer'
|
|
import { Row } from './layout/row'
|
|
import { genHash } from 'common/util/random'
|
|
import { Tabs } from './layout/tabs'
|
|
import { UserCommentsList } from './comments-list'
|
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
import { Comment, getUsersComments } from 'web/lib/firebase/comments'
|
|
import { Contract } from 'common/contract'
|
|
import { getContractFromId, listContracts } from 'web/lib/firebase/contracts'
|
|
import { LoadingIndicator } from './loading-indicator'
|
|
import { BetsList } from './bets-list'
|
|
import { Bet } from 'common/bet'
|
|
import { getUserBets } from 'web/lib/firebase/bets'
|
|
import { FollowersButton, FollowingButton } from './following-button'
|
|
import { useFollows } from 'web/hooks/use-follows'
|
|
import { FollowButton } from './follow-button'
|
|
import { PortfolioMetrics } from 'common/user'
|
|
|
|
export function UserLink(props: {
|
|
name: string
|
|
username: string
|
|
showUsername?: boolean
|
|
className?: string
|
|
}) {
|
|
const { name, username, showUsername, className } = props
|
|
|
|
return (
|
|
<SiteLink
|
|
href={`/${username}`}
|
|
className={clsx('z-10 truncate', className)}
|
|
>
|
|
{name}
|
|
{showUsername && ` (@${username})`}
|
|
</SiteLink>
|
|
)
|
|
}
|
|
|
|
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
|
|
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
|
|
|
|
export function UserPage(props: {
|
|
user: User
|
|
currentUser?: User
|
|
defaultTabTitle?: string | undefined
|
|
}) {
|
|
const { user, currentUser, defaultTabTitle } = props
|
|
const router = useRouter()
|
|
const isCurrentUser = user.id === currentUser?.id
|
|
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
|
const [usersComments, setUsersComments] = useState<Comment[]>([] as Comment[])
|
|
const [usersContracts, setUsersContracts] = useState<Contract[] | 'loading'>(
|
|
'loading'
|
|
)
|
|
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
|
|
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([])
|
|
const [commentsByContract, setCommentsByContract] = useState<
|
|
Map<Contract, Comment[]> | 'loading'
|
|
>('loading')
|
|
const [showConfetti, setShowConfetti] = useState(false)
|
|
const { width, height } = useWindowSize()
|
|
|
|
useEffect(() => {
|
|
const claimedMana = router.query['claimed-mana'] === 'yes'
|
|
setShowConfetti(claimedMana)
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
if (!user) return
|
|
getUsersComments(user.id).then(setUsersComments)
|
|
listContracts(user.id).then(setUsersContracts)
|
|
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
|
|
getPortfolioHistory(user.id).then(setUsersPortfolioHistory)
|
|
}, [user])
|
|
|
|
// TODO: display comments on groups
|
|
useEffect(() => {
|
|
const uniqueContractIds = uniq(
|
|
usersComments.map((comment) => comment.contractId)
|
|
)
|
|
Promise.all(
|
|
uniqueContractIds.map(
|
|
(contractId) => contractId && getContractFromId(contractId)
|
|
)
|
|
).then((contracts) => {
|
|
const commentsByContract = new Map<Contract, Comment[]>()
|
|
contracts.forEach((contract) => {
|
|
if (!contract) return
|
|
commentsByContract.set(
|
|
contract,
|
|
usersComments.filter((comment) => comment.contractId === contract.id)
|
|
)
|
|
})
|
|
setCommentsByContract(commentsByContract)
|
|
})
|
|
}, [usersComments])
|
|
|
|
const yourFollows = useFollows(currentUser?.id)
|
|
const isFollowing = yourFollows?.includes(user.id)
|
|
|
|
const onFollow = () => {
|
|
if (!currentUser) return
|
|
follow(currentUser.id, user.id)
|
|
}
|
|
const onUnfollow = () => {
|
|
if (!currentUser) return
|
|
unfollow(currentUser.id, user.id)
|
|
}
|
|
|
|
return (
|
|
<Page key={user.id}>
|
|
<SEO
|
|
title={`${user.name} (@${user.username})`}
|
|
description={user.bio ?? ''}
|
|
url={`/${user.username}`}
|
|
/>
|
|
{showConfetti && (
|
|
<Confetti
|
|
width={width ? width : 500}
|
|
height={height ? height : 500}
|
|
recycle={false}
|
|
numberOfPieces={300}
|
|
/>
|
|
)}
|
|
{/* Banner image up top, with an circle avatar overlaid */}
|
|
<div
|
|
className="h-32 w-full bg-cover bg-center sm:h-40"
|
|
style={{
|
|
backgroundImage: `url(${bannerUrl})`,
|
|
}}
|
|
></div>
|
|
<div className="relative mb-20">
|
|
<div className="absolute -top-10 left-4">
|
|
<Avatar
|
|
username={user.username}
|
|
avatarUrl={user.avatarUrl}
|
|
size={20}
|
|
className="bg-white ring-4 ring-white"
|
|
/>
|
|
</div>
|
|
|
|
{/* Top right buttons (e.g. edit, follow) */}
|
|
<div className="absolute right-0 top-0 mt-4 mr-4">
|
|
{!isCurrentUser && (
|
|
<FollowButton
|
|
isFollowing={isFollowing}
|
|
onFollow={onFollow}
|
|
onUnfollow={onUnfollow}
|
|
/>
|
|
)}
|
|
{isCurrentUser && (
|
|
<SiteLink className="btn" href="/profile">
|
|
<PencilIcon className="h-5 w-5" />{' '}
|
|
<div className="ml-2">Edit</div>
|
|
</SiteLink>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
|
<Col className="mx-4 -mt-6">
|
|
<span className="text-2xl font-bold">{user.name}</span>
|
|
<span className="text-gray-500">@{user.username}</span>
|
|
|
|
<Spacer h={4} />
|
|
|
|
{user.bio && (
|
|
<>
|
|
<div>
|
|
<Linkify text={user.bio}></Linkify>
|
|
</div>
|
|
<Spacer h={4} />
|
|
</>
|
|
)}
|
|
|
|
<Col className="gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
<Row className="gap-4">
|
|
<FollowingButton user={user} />
|
|
<FollowersButton user={user} />
|
|
</Row>
|
|
|
|
{user.website && (
|
|
<SiteLink
|
|
href={
|
|
'https://' +
|
|
user.website.replace('http://', '').replace('https://', '')
|
|
}
|
|
>
|
|
<Row className="items-center gap-1">
|
|
<LinkIcon className="h-4 w-4" />
|
|
<span className="text-sm text-gray-500">{user.website}</span>
|
|
</Row>
|
|
</SiteLink>
|
|
)}
|
|
|
|
{user.twitterHandle && (
|
|
<SiteLink
|
|
href={`https://twitter.com/${user.twitterHandle
|
|
.replace('https://www.twitter.com/', '')
|
|
.replace('https://twitter.com/', '')
|
|
.replace('www.twitter.com/', '')
|
|
.replace('twitter.com/', '')}`}
|
|
>
|
|
<Row className="items-center gap-1">
|
|
<img
|
|
src="/twitter-logo.svg"
|
|
className="h-4 w-4"
|
|
alt="Twitter"
|
|
/>
|
|
<span className="text-sm text-gray-500">
|
|
{user.twitterHandle}
|
|
</span>
|
|
</Row>
|
|
</SiteLink>
|
|
)}
|
|
|
|
{user.discordHandle && (
|
|
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
|
<Row className="items-center gap-1">
|
|
<img
|
|
src="/discord-logo.svg"
|
|
className="h-4 w-4"
|
|
alt="Discord"
|
|
/>
|
|
<span className="text-sm text-gray-500">
|
|
{user.discordHandle}
|
|
</span>
|
|
</Row>
|
|
</SiteLink>
|
|
)}
|
|
</Col>
|
|
|
|
<Spacer h={10} />
|
|
|
|
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
|
<Tabs
|
|
className={'pb-2 pt-1 '}
|
|
defaultIndex={
|
|
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
|
|
}
|
|
onClick={(tabName) => {
|
|
const tabId = tabName.toLowerCase()
|
|
const subpath = tabId === 'markets' ? '' : '?tab=' + tabId
|
|
// BUG: if you start on `/Bob/bets`, then click on Markets, use-query-and-sort-params
|
|
// rewrites the url incorrectly to `/Bob/bets` instead of `/Bob`
|
|
router.push(`/${user.username}${subpath}`, undefined, {
|
|
shallow: true,
|
|
})
|
|
}}
|
|
tabs={[
|
|
{
|
|
title: 'Markets',
|
|
content: <CreatorContractsList creator={user} />,
|
|
tabIcon: (
|
|
<div className="px-0.5 font-bold">
|
|
{usersContracts.length}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'Comments',
|
|
content: (
|
|
<UserCommentsList
|
|
user={user}
|
|
commentsByUniqueContracts={commentsByContract}
|
|
/>
|
|
),
|
|
tabIcon: (
|
|
<div className="px-0.5 font-bold">{usersComments.length}</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'Bets',
|
|
content: (
|
|
<div>
|
|
{
|
|
// TODO: add portfolio-value-section here
|
|
}
|
|
<BetsList
|
|
user={user}
|
|
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
|
/>
|
|
</div>
|
|
),
|
|
tabIcon: (
|
|
<div className="px-0.5 font-bold">{usersBets.length}</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
) : (
|
|
<LoadingIndicator />
|
|
)}
|
|
</Col>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
// Assign each user to a random default banner based on the hash of userId
|
|
// TODO: Consider handling banner uploads using our own storage bucket, like user avatars.
|
|
export function defaultBannerUrl(userId: string) {
|
|
const defaultBanner = [
|
|
'https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2131&q=80',
|
|
'https://images.unsplash.com/photo-1458682625221-3a45f8a844c7?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1974&q=80',
|
|
'https://images.unsplash.com/photo-1558517259-165ae4b10f7f?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2080&q=80',
|
|
'https://images.unsplash.com/photo-1563260797-cb5cd70254c8?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2069&q=80',
|
|
'https://images.unsplash.com/photo-1603399587513-136aa9398f2d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1467&q=80',
|
|
]
|
|
return defaultBanner[genHash(userId)() % defaultBanner.length]
|
|
}
|