From 456d9398a105b0421696988bb1c4dd0c9fae4844 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Fri, 12 Aug 2022 20:42:58 -0700 Subject: [PATCH] Revamp a lot of stuff on the user page to make it usably efficient (#751) * Load bets and comments tabs data on user page independently * Implement basic pagination on profile comments list * Tweak server auth to return `null` instead of `undefined` * Switch to SSR for user page * Fix lint * Fix broken contract fetching in user bets list * Tidying --- common/util/array.ts | 19 +++++ web/components/bets-list.tsx | 42 +++++++--- web/components/comments-list.tsx | 66 ++++++++++++---- web/components/user-page.tsx | 132 +++++++------------------------ web/lib/firebase/server-auth.ts | 9 ++- web/pages/[username]/index.tsx | 45 ++++------- 6 files changed, 154 insertions(+), 159 deletions(-) diff --git a/common/util/array.ts b/common/util/array.ts index 2ad86843..8a429262 100644 --- a/common/util/array.ts +++ b/common/util/array.ts @@ -17,3 +17,22 @@ export function buildArray( return array } + +export function groupConsecutive(xs: T[], key: (x: T) => U) { + if (!xs.length) { + return [] + } + const result = [] + let curr = { key: key(xs[0]), items: [xs[0]] } + for (const x of xs.slice(1)) { + const k = key(x) + if (k !== curr.key) { + result.push(curr) + curr = { key: k, items: [x] } + } else { + curr.items.push(x) + } + } + result.push(curr) + return result +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 18349597..b919cccd 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,5 +1,14 @@ import Link from 'next/link' -import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' +import { + Dictionary, + keyBy, + groupBy, + mapValues, + sortBy, + partition, + sumBy, + uniq, +} from 'lodash' import dayjs from 'dayjs' import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' @@ -19,6 +28,7 @@ import { Contract, contractPath, getBinaryProbPercent, + getContractFromId, } from 'web/lib/firebase/contracts' import { Row } from './layout/row' import { UserLink } from './user-page' @@ -41,10 +51,12 @@ import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' +import { useUserBets } from 'web/hooks/use-user-bets' import { SellSharesModal } from './sell-modal' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' +import { filterDefined } from 'common/util/array' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' @@ -52,25 +64,35 @@ type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' const CONTRACTS_PER_PAGE = 50 +const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf() -export function BetsList(props: { - user: User - bets: Bet[] | undefined - contractsById: { [id: string]: Contract } | undefined - hideBetsBefore?: number -}) { - const { user, bets: allBets, contractsById, hideBetsBefore } = props +export function BetsList(props: { user: User }) { + const { user } = props const signedInUser = useUser() const isYourBets = user.id === signedInUser?.id + const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022 + const userBets = useUserBets(user.id, { includeRedemptions: true }) + const [contractsById, setContractsById] = useState< + Dictionary | undefined + >() // Hide bets before 06-01-2022 if this isn't your own profile // NOTE: This means public profits also begin on 06-01-2022 as well. const bets = useMemo( - () => allBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), - [allBets, hideBetsBefore] + () => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), + [userBets, hideBetsBefore] ) + useEffect(() => { + if (bets) { + const contractIds = uniq(bets.map((b) => b.contractId)) + Promise.all(contractIds.map(getContractFromId)).then((contracts) => { + setContractsById(keyBy(filterDefined(contracts), 'id')) + }) + } + }, [bets]) + const [sort, setSort] = useState('newest') const [filter, setFilter] = useState('open') const [page, setPage] = useState(0) diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 304a213f..90542d4b 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -1,6 +1,12 @@ +import { useEffect, useState } from 'react' +import { Dictionary, keyBy, uniq } from 'lodash' + import { Comment } from 'common/comment' import { Contract } from 'common/contract' +import { filterDefined, groupConsecutive } from 'common/util/array' import { contractPath } from 'web/lib/firebase/contracts' +import { getUsersComments } from 'web/lib/firebase/comments' +import { getContractFromId } from 'web/lib/firebase/contracts' import { SiteLink } from './site-link' import { Row } from './layout/row' import { Avatar } from './avatar' @@ -8,26 +14,52 @@ import { RelativeTimestamp } from './relative-timestamp' import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' -import { groupBy } from 'lodash' import { Content } from './editor' +import { Pagination } from './pagination' +import { LoadingIndicator } from './loading-indicator' -export function UserCommentsList(props: { - user: User - comments: Comment[] - contractsById: { [id: string]: Contract } -}) { - const { comments, contractsById } = props +const COMMENTS_PER_PAGE = 50 - // we don't show comments in groups here atm, just comments on contracts - const contractComments = comments.filter((c) => c.contractId) - const commentsByContract = groupBy(contractComments, 'contractId') +type ContractComment = Comment & { contractId: string } +export function UserCommentsList(props: { user: User }) { + const { user } = props + const [comments, setComments] = useState() + const [contracts, setContracts] = useState | undefined>() + const [page, setPage] = useState(0) + const start = page * COMMENTS_PER_PAGE + const end = start + COMMENTS_PER_PAGE + + useEffect(() => { + getUsersComments(user.id).then((cs) => { + // we don't show comments in groups here atm, just comments on contracts + setComments(cs.filter((c) => c.contractId) as ContractComment[]) + }) + }, [user.id]) + + useEffect(() => { + if (comments) { + const contractIds = uniq(comments.map((c) => c.contractId)) + Promise.all(contractIds.map(getContractFromId)).then((contracts) => { + setContracts(keyBy(filterDefined(contracts), 'id')) + }) + } + }, [comments]) + + if (comments == null || contracts == null) { + return + } + + const pageComments = groupConsecutive( + comments.slice(start, end), + (c) => c.contractId + ) return ( - {Object.entries(commentsByContract).map(([contractId, comments]) => { - const contract = contractsById[contractId] + {pageComments.map(({ key, items }, i) => { + const contract = contracts[key] return ( -
+
- {comments.map((comment) => ( + {items.map((comment) => ( ) })} + ) } diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 6a901b13..2069ef72 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -1,11 +1,11 @@ import clsx from 'clsx' -import { Dictionary, keyBy, 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 { User } from 'web/lib/firebase/users' +import { useUser } from 'web/hooks/use-user' import { CreatorContractsList } from './contract/contracts-grid' import { SEO } from './SEO' import { Page } from './page' @@ -18,18 +18,12 @@ import { Row } from './layout/row' import { genHash } from 'common/util/random' import { QueryUncontrolledTabs } from './layout/tabs' import { UserCommentsList } from './comments-list' -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 { FullscreenConfetti } from 'web/components/fullscreen-confetti' import { BetsList } from './bets-list' import { FollowersButton, FollowingButton } from './following-button' import { UserFollowButton } from './follow-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' -import { filterDefined } from 'common/util/array' -import { useUserBets } from 'web/hooks/use-user-bets' import { ReferralsButton } from 'web/components/referrals-button' import { formatMoney } from 'common/util/format' import { ShareIconButton } from 'web/components/share-icon-button' @@ -56,26 +50,13 @@ export function UserLink(props: { } 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 }) { - const { user, currentUser } = props +export function UserPage(props: { user: User }) { + const { user } = props const router = useRouter() + const currentUser = useUser() const isCurrentUser = user.id === currentUser?.id const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) - const [usersComments, setUsersComments] = useState() - const [usersContracts, setUsersContracts] = useState( - 'loading' - ) - const userBets = useUserBets(user.id, { includeRedemptions: true }) - const betCount = - userBets === undefined - ? 0 - : userBets.filter((bet) => !bet.isRedemption && bet.amount !== 0).length - - const [contractsById, setContractsById] = useState< - Dictionary | undefined - >() const [showConfetti, setShowConfetti] = useState(false) useEffect(() => { @@ -83,30 +64,6 @@ export function UserPage(props: { user: User; currentUser?: User }) { setShowConfetti(claimedMana) }, [router]) - useEffect(() => { - if (!user) return - getUsersComments(user.id).then(setUsersComments) - listContracts(user.id).then(setUsersContracts) - }, [user]) - - // TODO: display comments on groups - useEffect(() => { - if (usersComments && userBets) { - const uniqueContractIds = uniq([ - ...usersComments.map((comment) => comment.contractId), - ...(userBets?.map((bet) => bet.contractId) ?? []), - ]) - Promise.all( - uniqueContractIds.map((contractId) => - contractId ? getContractFromId(contractId) : undefined - ) - ).then((contracts) => { - const contractsById = keyBy(filterDefined(contracts), 'id') - setContractsById(contractsById) - }) - } - }, [userBets, usersComments]) - const profit = user.profitCached.allTime return ( @@ -163,9 +120,7 @@ export function UserPage(props: { user: User; currentUser?: User }) { {' '} profit - - {user.bio && ( <>
@@ -174,7 +129,6 @@ export function UserPage(props: { user: User; currentUser?: User }) { )} - @@ -236,7 +190,6 @@ export function UserPage(props: { user: User; currentUser?: User }) { )} - {currentUser?.id === user.id && ( )} - - {usersContracts !== 'loading' && contractsById && usersComments ? ( - - ), - tabIcon: ( - - {usersContracts.length} - - ), - }, - { - title: 'Comments', - content: ( - - ), - tabIcon: ( - - {usersComments.length} - - ), - }, - { - title: 'Bets', - content: ( -
- - -
- ), - tabIcon: {betCount}, - }, - ]} - /> - ) : ( - - )} + + ), + }, + { + title: 'Comments', + content: , + }, + { + title: 'Bets', + content: ( + <> + + + + ), + }, + ]} + /> ) diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index ebcb23d4..7ce8a814 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -81,7 +81,7 @@ const authAndRefreshTokens = async (ctx: RequestContext) => { // step 0: if you have no refresh token you are logged out if (refresh == null) { console.debug('User is unauthenticated.') - return undefined + return null } console.debug('User may be authenticated; checking cookies.') @@ -107,7 +107,7 @@ const authAndRefreshTokens = async (ctx: RequestContext) => { } catch (e) { // big unexpected problem -- functionally, they are not logged in console.error(e) - return undefined + return null } } @@ -136,9 +136,10 @@ const authAndRefreshTokens = async (ctx: RequestContext) => { } catch (e) { // big unexpected problem -- functionally, they are not logged in console.error(e) - return undefined + return null } } + return null } export const authenticateOnServer = async (ctx: RequestContext) => { @@ -158,7 +159,7 @@ export const authenticateOnServer = async (ctx: RequestContext) => { // definitely not supposed to happen, but let's be maximally robust console.error(e) } - return creds + return creds ?? null } // note that we might want to define these types more generically if we want better diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index 22083c90..bf6e8442 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -1,48 +1,37 @@ import { useRouter } from 'next/router' import React from 'react' -import { getUserByUsername, User } from 'web/lib/firebase/users' +import { + getUserByUsername, + getUserAndPrivateUser, + User, + UserAndPrivateUser, +} from 'web/lib/firebase/users' import { UserPage } from 'web/components/user-page' -import { useUser } from 'web/hooks/use-user' import Custom404 from '../404' import { useTracking } from 'web/hooks/use-tracking' -import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { GetServerSideProps } from 'next' +import { authenticateOnServer } from 'web/lib/firebase/server-auth' -export const getStaticProps = fromPropz(getStaticPropz) -export async function getStaticPropz(props: { params: { username: string } }) { - const { username } = props.params - const user = await getUserByUsername(username) - - return { - props: { - user, - }, - - revalidate: 60, // regenerate after a minute - } -} - -export async function getStaticPaths() { - return { paths: [], fallback: 'blocking' } +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const creds = await authenticateOnServer(ctx) + const username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion + const [auth, user] = (await Promise.all([ + creds != null ? getUserAndPrivateUser(creds.user.uid) : null, + getUserByUsername(username), + ])) as [UserAndPrivateUser | null, User | null] + return { props: { auth, user } } } export default function UserProfile(props: { user: User | null }) { - props = usePropz(props, getStaticPropz) ?? { user: undefined } const { user } = props const router = useRouter() const { username } = router.query as { username: string } - const currentUser = useUser() useTracking('view user profile', { username }) - if (user === undefined) return
- - return user ? ( - - ) : ( - - ) + return user ? : }