diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index db526741..24e855a2 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -47,6 +47,7 @@ import { BuyButton } from '../yes-no-selector' import { getDpmOutcomeProbability } from '../../../common/calculate-dpm' import { AnswerBetPanel } from '../answers/answer-bet-panel' import { useSaveSeenContract } from '../../hooks/use-seen-contracts' +import { User } from '../../../common/user' export function FeedItems(props: { contract: Contract @@ -109,7 +110,7 @@ function FeedItem(props: { item: ActivityItem }) { } } -function FeedComment(props: { +export function FeedComment(props: { contract: Contract comment: Comment bet: Bet @@ -171,13 +172,14 @@ function RelativeTimestamp(props: { time: number }) { ) } -function FeedBet(props: { +export function FeedBet(props: { contract: Contract bet: Bet hideOutcome: boolean smallAvatar: boolean + bettor?: User // If set: reveal bettor identity }) { - const { contract, bet, hideOutcome, smallAvatar } = props + const { contract, bet, hideOutcome, smallAvatar, bettor } = props const { id, amount, outcome, createdTime, userId } = bet const user = useUser() const isSelf = user?.id === userId @@ -204,6 +206,13 @@ function FeedBet(props: { avatarUrl={user.avatarUrl} username={user.username} /> + ) : bettor ? ( + ) : (
@@ -212,9 +221,10 @@ function FeedBet(props: {
)}
-
+
- {isSelf ? 'You' : 'A trader'} {bought} {money} + {isSelf ? 'You' : bettor ? bettor.name : 'A trader'}{' '} + {bought} {money} {!hideOutcome && ( <> {' '} diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx index 30f4d596..5ae3ddd3 100644 --- a/web/components/leaderboard.tsx +++ b/web/components/leaderboard.tsx @@ -14,6 +14,7 @@ export function Leaderboard(props: { }[] className?: string }) { + // TODO: Ideally, highlight your own entry on the leaderboard const { title, users, columns, className } = props return (
diff --git a/web/hooks/use-users.ts b/web/hooks/use-users.ts index fbf5feaf..35244d73 100644 --- a/web/hooks/use-users.ts +++ b/web/hooks/use-users.ts @@ -1,6 +1,10 @@ import { useState, useEffect } from 'react' import { PrivateUser, User } from '../../common/user' -import { listenForAllUsers, listenForPrivateUsers } from '../lib/firebase/users' +import { + getUser, + listenForAllUsers, + listenForPrivateUsers, +} from '../lib/firebase/users' export const useUsers = () => { const [users, setUsers] = useState([]) @@ -12,6 +16,16 @@ export const useUsers = () => { return users } +export const useUserById = (userId: string) => { + const [user, setUser] = useState(undefined) + + useEffect(() => { + getUser(userId).then(setUser) + }, [userId]) + + return user +} + export const usePrivateUsers = () => { const [users, setUsers] = useState([]) diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 599e711a..43253f45 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -126,6 +126,16 @@ export async function uploadData( return await getDownloadURL(uploadRef) } +export async function listUsers(userIds: string[]) { + if (userIds.length > 10) { + throw new Error('Too many users requested at once; Firestore limits to 10') + } + const userCollection = collection(db, 'users') + const q = query(userCollection, where('id', 'in', userIds)) + const docs = await getDocs(q) + return docs.docs.map((doc) => doc.data() as User) +} + export async function listAllUsers() { const userCollection = collection(db, 'users') const q = query(userCollection) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 90929ec9..487942d5 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { useContractWithPreload } from '../../hooks/use-contract' import { ContractOverview } from '../../components/contract-overview' @@ -10,7 +10,7 @@ import { ContractBetsTable, MyBetsSummary } from '../../components/bets-list' import { useBets } from '../../hooks/use-bets' import { Title } from '../../components/title' import { Spacer } from '../../components/layout/spacer' -import { User } from '../../lib/firebase/users' +import { listUsers, User } from '../../lib/firebase/users' import { Contract, getContractFromSlug, @@ -30,6 +30,12 @@ import { listAllAnswers } from '../../lib/firebase/answers' import { Answer } from '../../../common/answer' import { AnswersPanel } from '../../components/answers/answers-panel' import { fromPropz, usePropz } from '../../hooks/use-propz' +import { Leaderboard } from '../../components/leaderboard' +import _ from 'lodash' +import { calculatePayout, resolvedPayout } from '../../../common/calculate' +import { formatMoney } from '../../../common/util/format' +import { FeedBet, FeedComment } from '../../components/feed/feed-items' +import { useUserById } from '../../hooks/use-users' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -147,6 +153,19 @@ export default function ContractPage(props: { )} + {contract.isResolved && ( + <> +
+ + +
+ + + )}
@@ -195,6 +214,122 @@ function BetsSection(props: { ) } +function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) { + const { contract, bets } = props + const [users, setUsers] = useState() + + // Create a map of userIds to total profits (including sales) + const betsByUser = _.groupBy(bets, 'userId') + const userProfits = _.mapValues(betsByUser, (bets) => + _.sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) + ) + + // Find the 5 users with the most profits + const top5Ids = _.entries(userProfits) + .sort(([i1, p1], [i2, p2]) => p2 - p1) + .filter(([, p]) => p > 0) + .slice(0, 5) + .map(([id]) => id) + + useEffect(() => { + if (top5Ids.length > 0) { + listUsers(top5Ids).then((users) => { + const sortedUsers = _.sortBy(users, (user) => -userProfits[user.id]) + setUsers(sortedUsers) + }) + } + }, []) + + return users && users.length > 0 ? ( + formatMoney(userProfits[user.id] || 0), + }, + ]} + className="mt-12 max-w-sm" + /> + ) : null +} + +function ContractTopTrades(props: { + contract: Contract + bets: Bet[] + comments: Comment[] +}) { + const { contract, bets, comments } = props + const commentsById = _.keyBy(comments, 'id') + const betsById = _.keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = _.sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = useUserById(betsById[topBetId].userId) + + // And also the commentId of the comment with the highest profit + const topCommentId = _.sortBy(comments, (c) => -profitById[c.betId])[0]?.id + + return ( +
+ {topCommentId && ( + <> + + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedComment + contract={contract} + comment={commentsById[topCommentId]} + bet={betsById[topCommentId]} + hideOutcome={false} + truncate={false} + smallAvatar={false} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {commentsById[topCommentId].userName} made{' '} + {formatMoney(profitById[topCommentId] || 0)}! + </div> + <Spacer h={16} /> + </> + )} + + {/* If they're the same, only show the comment; otherwise show both */} + {topBettor && topBetId !== topCommentId && ( + <> + <Title text="💸 Smartest money" className="!mt-0" /> + <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> + <FeedBet + contract={contract} + bet={betsById[topBetId]} + hideOutcome={false} + smallAvatar={false} + bettor={topBettor} + /> + </div> + <div className="mt-2 text-sm text-gray-500"> + {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! + </div> + </> + )} + </div> + ) +} + const getOpenGraphProps = (contract: Contract) => { const { resolution, question, creatorName, creatorUsername, outcomeType } = contract