From 5b431226d498039ebc7f72f8b6e2c173c21d86ab Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Tue, 11 Jan 2022 11:56:26 -0500 Subject: [PATCH] Show all recent activity on a central feed (#24) * Tracks all market activity on a single page * Support both global and per-contract feeds * UI tweaks * Include contract description in activity feed * Show activity feed on Create page --- web/components/contract-feed.tsx | 88 ++++++++++++++++++++----- web/components/contract-overview.tsx | 2 +- web/hooks/use-comments.ts | 12 +++- web/lib/firebase/comments.ts | 35 +++++++++- web/pages/activity.tsx | 99 ++++++++++++++++++++++++++++ web/pages/create.tsx | 7 +- 6 files changed, 219 insertions(+), 24 deletions(-) create mode 100644 web/pages/activity.tsx diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index b2418085..c6f31d1a 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -16,13 +16,21 @@ import { Comment, mapCommentsByBetId } from '../lib/firebase/comments' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { OutcomeLabel } from './outcome-label' -import { Contract, updateContract } from '../lib/firebase/contracts' +import { + compute, + Contract, + path, + updateContract, +} from '../lib/firebase/contracts' import { useUser } from '../hooks/use-user' import { Linkify } from './linkify' import { Row } from './layout/row' import { createComment } from '../lib/firebase/comments' import { useComments } from '../hooks/use-comments' import { formatMoney } from '../lib/util/format' +import { ResolutionOrChance } from './contract-card' +import Link from 'next/link' +import { SiteLink } from './site-link' dayjs.extend(relativeTime) function FeedComment(props: { activityItem: any }) { @@ -95,10 +103,8 @@ function FeedBet(props: { activityItem: any }) {
- - {isCreator ? 'You' : 'A trader'} - {' '} - placed {formatMoney(amount)} on {' '} + {isCreator ? 'You' : 'A trader'} placed{' '} + {formatMoney(amount)} on {' '} {isCreator && ( // Allow user to comment in an textarea if they are the creator @@ -193,7 +199,41 @@ export function ContractDescription(props: { ) } -function FeedStart(props: { contract: Contract }) { +function FeedQuestion(props: { contract: Contract }) { + const { contract } = props + const { probPercent } = compute(contract) + + return ( + <> +
+
+
+
+
+
+
+
+ {contract.creatorName} asked{' '} + +
+ + + {contract.question} + + + + +
+ + ) +} + +function FeedDescription(props: { contract: Contract }) { const { contract } = props const user = useUser() const isCreator = user?.id === contract.creatorId @@ -314,12 +354,19 @@ function toFeedComment(bet: Bet, comment: Comment) { } } +const DAY_IN_MS = 24 * 60 * 60 * 1000 + // Group together bets that are: -// - Within 24h of the first in the group +// - Within `windowMs` of the first in the group // - Do not have a comment // - Were not created by this user // Return a list of ActivityItems -function group(bets: Bet[], comments: Comment[], userId?: string) { +function groupBets( + bets: Bet[], + comments: Comment[], + windowMs: number, + userId?: string +) { const commentsMap = mapCommentsByBetId(comments) const items: any[] = [] let group: Bet[] = [] @@ -349,9 +396,9 @@ function group(bets: Bet[], comments: Comment[], userId?: string) { } else { if ( group.length > 0 && - dayjs(bet.createdTime).diff(dayjs(group[0].createdTime), 'hour') > 24 + bet.createdTime - group[0].createdTime > windowMs ) { - // More than 24h has passed; start a new group + // More than `windowMs` has passed; start a new group pushGroup() } group.push(bet) @@ -398,8 +445,7 @@ function FeedBetGroup(props: { activityItem: any }) {
- {traderCount} traders placed{' '} - {yesSpan} + {traderCount} traders placed {yesSpan} {yesAmount && noAmount ? ' and ' : ''} {noSpan}
@@ -415,8 +461,12 @@ type ActivityItem = { type: 'bet' | 'comment' | 'start' | 'betgroup' | 'close' | 'resolve' } -export function ContractFeed(props: { contract: Contract }) { - const { contract } = props +export function ContractFeed(props: { + contract: Contract + // Feed types: 'activity' = Activity feed, 'market' = Comments feed on a market + feedType: 'activity' | 'market' +}) { + const { contract, feedType } = props const { id } = contract const user = useUser() @@ -426,9 +476,11 @@ export function ContractFeed(props: { contract: Contract }) { let comments = useComments(id) if (comments === 'loading') comments = [] + const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS + const allItems = [ { type: 'start', id: 0 }, - ...group(bets, comments, user?.id), + ...groupBets(bets, comments, groupWindow, user?.id), ] if (contract.closeTime && contract.closeTime <= Date.now()) { allItems.push({ type: 'close', id: `${contract.closeTime}` }) @@ -451,7 +503,11 @@ export function ContractFeed(props: { contract: Contract }) { ) : null}
{activityItem.type === 'start' ? ( - + feedType == 'activity' ? ( + + ) : ( + + ) ) : activityItem.type === 'comment' ? ( ) : activityItem.type === 'bet' ? ( diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 6eff1bda..0bfb4909 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -91,7 +91,7 @@ export const ContractOverview = (props: { )} - + ) } diff --git a/web/hooks/use-comments.ts b/web/hooks/use-comments.ts index 0a9dead3..bedc7598 100644 --- a/web/hooks/use-comments.ts +++ b/web/hooks/use-comments.ts @@ -1,5 +1,9 @@ import { useEffect, useState } from 'react' -import { Comment, listenForComments } from '../lib/firebase/comments' +import { + Comment, + listenForComments, + listenForRecentComments, +} from '../lib/firebase/comments' export const useComments = (contractId: string) => { const [comments, setComments] = useState('loading') @@ -10,3 +14,9 @@ export const useComments = (contractId: string) => { return comments } + +export const useRecentComments = () => { + const [recentComments, setRecentComments] = useState() + useEffect(() => listenForRecentComments(setRecentComments), []) + return recentComments +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index c55e16eb..071dab7d 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -1,5 +1,15 @@ -import { doc, collection, onSnapshot, setDoc } from 'firebase/firestore' - +import { + doc, + collection, + onSnapshot, + setDoc, + query, + collectionGroup, + getDocs, + where, + orderBy, +} from 'firebase/firestore' +import { listenForValues } from './utils' import { db } from './init' import { User } from '../../../common/user' import { Comment } from '../../../common/comment' @@ -48,3 +58,24 @@ export function mapCommentsByBetId(comments: Comment[]) { } return map } + +const DAY_IN_MS = 24 * 60 * 60 * 1000 + +// Define "recent" as "<3 days ago" for now +const recentCommentsQuery = query( + collectionGroup(db, 'comments'), + where('createdTime', '>', Date.now() - 3 * DAY_IN_MS), + orderBy('createdTime', 'desc') +) + +export async function getRecentComments() { + const snapshot = await getDocs(recentCommentsQuery) + const comments = snapshot.docs.map((doc) => doc.data() as Comment) + return comments +} + +export function listenForRecentComments( + setComments: (comments: Comment[]) => void +) { + return listenForValues(recentCommentsQuery, setComments) +} diff --git a/web/pages/activity.tsx b/web/pages/activity.tsx new file mode 100644 index 00000000..4feb494c --- /dev/null +++ b/web/pages/activity.tsx @@ -0,0 +1,99 @@ +import _ from 'lodash' +import { ContractFeed } from '../components/contract-feed' +import { Row } from '../components/layout/row' +import { Page } from '../components/page' +import { Title } from '../components/title' +import { useRecentComments } from '../hooks/use-comments' +import { useContracts } from '../hooks/use-contracts' +import { Contract } from '../lib/firebase/contracts' +import { Comment } from '../lib/firebase/comments' + +function FeedCard(props: { contract: Contract }) { + const { contract } = props + return ( +
+ +
+ ) +} + +// This does NOT include comment times, since those aren't part of the contract atm. +// TODO: Maybe store last activity time directly in the contract? +// Pros: simplifies this code; cons: harder to tweak "activity" definition later +function lastActivityTime(contract: Contract) { + return Math.max( + contract.resolutionTime || 0, + contract.lastUpdatedTime, + contract.createdTime + ) +} + +// Types of activity to surface: +// - Comment on a market +// - New market created +// - Market resolved +function findActiveContracts( + allContracts: Contract[], + recentComments: Comment[] +) { + const idToActivityTime = new Map() + function record(contractId: string, time: number) { + // Only record if the time is newer + const oldTime = idToActivityTime.get(contractId) + idToActivityTime.set(contractId, Math.max(oldTime ?? 0, time)) + } + + let contracts: Contract[] = [] + + // Find contracts with activity in the last 3 days + const DAY_IN_MS = 24 * 60 * 60 * 1000 + for (const contract of allContracts || []) { + if (lastActivityTime(contract) > Date.now() - 3 * DAY_IN_MS) { + contracts.push(contract) + record(contract.id, lastActivityTime(contract)) + } + } + + // Add every contract that had a recent comment, too + const contractsById = new Map(allContracts.map((c) => [c.id, c])) + for (const comment of recentComments) { + const contract = contractsById.get(comment.contractId) + if (contract) { + contracts.push(contract) + record(contract.id, comment.createdTime) + } + } + + contracts = _.uniqBy(contracts, (c) => c.id) + contracts = _.sortBy(contracts, (c) => -(idToActivityTime.get(c.id) ?? 0)) + return contracts +} + +export function ActivityFeed() { + const contracts = useContracts() || [] + const recentComments = useRecentComments() || [] + // TODO: Handle static props correctly? + const activeContracts = findActiveContracts(contracts, recentComments) + return contracts ? ( + <> + + <Row className="gap-4"> + <div> + {activeContracts.map((contract) => ( + <FeedCard contract={contract} /> + ))} + </div> + </Row> + </> + ) : ( + <></> + ) +} + +export default function ActivityPage() { + return ( + <Page> + <ActivityFeed /> + </Page> + ) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index f431a37e..7e00191b 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -14,6 +14,7 @@ import { AdvancedPanel } from '../components/advanced-panel' import { createContract } from '../lib/firebase/api-call' import { Row } from '../components/layout/row' import { AmountInput } from '../components/amount-input' +import { ActivityFeed } from './activity' // Allow user to create a new contract export default function NewContract() { @@ -210,11 +211,9 @@ export default function NewContract() { </form> </div> - <Spacer h={10} /> + <Spacer h={6} /> - <Title text="Your markets" /> - - {creator && <CreatorContractsList creator={creator} />} + <ActivityFeed /> </Page> ) }