diff --git a/common/comment.ts b/common/comment.ts index fe18346b..cf78da4b 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -10,7 +10,7 @@ export type Comment = { createdTime: number // Denormalized, for rendering comments - userName?: string - userUsername?: string + userName: string + userUsername: string userAvatarUrl?: string } diff --git a/web/components/activity-feed.tsx b/web/components/activity-feed.tsx deleted file mode 100644 index bfd4cc1c..00000000 --- a/web/components/activity-feed.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import _ from 'lodash' -import { - ContractActivityFeed, - ContractFeed, - ContractSummaryFeed, -} from './contract-feed' -import { Contract } from '../lib/firebase/contracts' -import { Comment } from '../lib/firebase/comments' -import { Col } from './layout/col' -import { Bet } from '../../common/bet' - -const MAX_ACTIVE_CONTRACTS = 75 - -// 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 -// - Bet on market -export function findActiveContracts( - allContracts: Contract[], - recentComments: Comment[], - recentBets: Bet[] -) { - 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)) - } - - const contractsById = new Map(allContracts.map((c) => [c.id, c])) - - // Record contract activity. - for (const contract of allContracts) { - record(contract.id, lastActivityTime(contract)) - } - - // Add every contract that had a recent comment, too - for (const comment of recentComments) { - const contract = contractsById.get(comment.contractId) - if (contract) record(contract.id, comment.createdTime) - } - - // Add contracts by last bet time. - const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) - const contractMostRecentBet = _.mapValues( - contractBets, - (bets) => _.maxBy(bets, (bet) => bet.createdTime) as Bet - ) - for (const bet of Object.values(contractMostRecentBet)) { - const contract = contractsById.get(bet.contractId) - if (contract) record(contract.id, bet.createdTime) - } - - let activeContracts = allContracts.filter( - (contract) => contract.visibility === 'public' && !contract.isResolved - ) - activeContracts = _.sortBy( - activeContracts, - (c) => -(idToActivityTime.get(c.id) ?? 0) - ) - return activeContracts.slice(0, MAX_ACTIVE_CONTRACTS) -} - -export function ActivityFeed(props: { - contracts: Contract[] - recentBets: Bet[] - recentComments: Comment[] - loadBetAndCommentHistory?: boolean -}) { - const { contracts, recentBets, recentComments, loadBetAndCommentHistory } = - props - - const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId) - const groupedComments = _.groupBy( - recentComments, - (comment) => comment.contractId - ) - - return ( - - - - {contracts.map((contract) => ( -
- {loadBetAndCommentHistory ? ( - - ) : ( - - )} -
- ))} - - - - ) -} - -export function SummaryActivityFeed(props: { contracts: Contract[] }) { - const { contracts } = props - - return ( - - - - {contracts.map((contract) => ( -
- -
- ))} - - - - ) -} diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index 46fd0ead..b4ac8f44 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -13,12 +13,14 @@ import { formatPercent } from '../../../common/util/format' import { getOutcomeProbability } from '../../../common/calculate' import { tradingAllowed } from '../../lib/firebase/contracts' import { AnswerBetPanel } from './answer-bet-panel' -import { ContractFeed } from '../contract-feed' import { Linkify } from '../linkify' +import { User } from '../../../common/user' +import { ContractActivity } from '../feed/contract-activity' export function AnswerItem(props: { answer: Answer contract: Contract + user: User | null | undefined showChoice: 'radio' | 'checkbox' | undefined chosenProb: number | undefined totalChosenProb?: number @@ -28,6 +30,7 @@ export function AnswerItem(props: { const { answer, contract, + user, showChoice, chosenProb, totalChosenProb, @@ -82,12 +85,13 @@ export function AnswerItem(props: { {isBetting && ( - )} diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 9f42efb9..45b47ef4 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -86,6 +86,7 @@ export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) { key={answer.id} answer={answer} contract={contract} + user={user} showChoice={showChoice} chosenProb={chosenAnswers[answer.id]} totalChosenProb={chosenTotal} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index fcdf3372..d5a14573 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -12,13 +12,13 @@ import { Row } from './layout/row' import { Linkify } from './linkify' import clsx from 'clsx' import { ContractDetails, ResolutionOrChance } from './contract-card' -import { ContractFeed } from './contract-feed' import { Bet } from '../../common/bet' import { Comment } from '../../common/comment' import { RevealableTagsInput, TagsInput } from './tags-input' import BetRow from './bet-row' import { Fold } from '../../common/fold' import { FoldTagList } from './tags-list' +import { ContractActivity } from './feed/contract-activity' export const ContractOverview = (props: { contract: Contract @@ -119,11 +119,12 @@ export const ContractOverview = (props: { - diff --git a/web/components/feed/activity-feed.tsx b/web/components/feed/activity-feed.tsx new file mode 100644 index 00000000..92d1c5dc --- /dev/null +++ b/web/components/feed/activity-feed.tsx @@ -0,0 +1,99 @@ +import _ from 'lodash' +import clsx from 'clsx' + +import { Contract, tradingAllowed } from '../../lib/firebase/contracts' +import { Comment } from '../../lib/firebase/comments' +import { Col } from '../layout/col' +import { Bet } from '../../../common/bet' +import { useUser } from '../../hooks/use-user' +import BetRow from '../bet-row' +import { FeedQuestion } from './feed-items' +import { ContractActivity } from './contract-activity' + +export function ActivityFeed(props: { + contracts: Contract[] + recentBets: Bet[] + recentComments: Comment[] + mode: 'only-recent' | 'abbreviated' | 'all' +}) { + const { contracts, recentBets, recentComments, mode } = props + + const user = useUser() + + const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId) + const groupedComments = _.groupBy( + recentComments, + (comment) => comment.contractId + ) + + return ( + ( + + )} + /> + ) +} + +export function SummaryActivityFeed(props: { contracts: Contract[] }) { + const { contracts } = props + + return ( + } + /> + ) +} + +function FeedContainer(props: { + contracts: Contract[] + renderContract: (contract: Contract) => any +}) { + const { contracts, renderContract } = props + + return ( + + + + {contracts.map((contract) => ( +
+ {renderContract(contract)} +
+ ))} + + + + ) +} + +function ContractSummary(props: { + contract: Contract + betRowClassName?: string +}) { + const { contract, betRowClassName } = props + const { outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + + return ( +
+
+
+
+ +
+
+
+ {isBinary && tradingAllowed(contract) && ( + + )} +
+ ) +} diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts new file mode 100644 index 00000000..7136cba6 --- /dev/null +++ b/web/components/feed/activity-items.ts @@ -0,0 +1,298 @@ +import _ from 'lodash' + +import { Answer } from '../../../common/answer' +import { Bet } from '../../../common/bet' +import { getOutcomeProbability } from '../../../common/calculate' +import { Comment } from '../../../common/comment' +import { Contract } from '../../../common/contract' +import { User } from '../../../common/user' +import { filterDefined } from '../../../common/util/array' +import { mapCommentsByBetId } from '../../lib/firebase/comments' + +export type ActivityItem = + | DescriptionItem + | QuestionItem + | BetItem + | CommentItem + | CreateAnswerItem + | BetGroupItem + | AnswerGroupItem + | CloseItem + | ResolveItem + +type BaseActivityItem = { + id: string + contract: Contract +} + +export type DescriptionItem = BaseActivityItem & { + type: 'description' +} + +export type QuestionItem = BaseActivityItem & { + type: 'question' + showDescription: boolean +} + +export type BetItem = BaseActivityItem & { + type: 'bet' + bet: Bet + hideOutcome: boolean +} + +export type CommentItem = BaseActivityItem & { + type: 'comment' + comment: Comment + bet: Bet + hideOutcome: boolean + truncate: boolean +} + +export type CreateAnswerItem = BaseActivityItem & { + type: 'createanswer' + answer: Answer +} + +export type BetGroupItem = BaseActivityItem & { + type: 'betgroup' + bets: Bet[] + hideOutcome: boolean +} + +export type AnswerGroupItem = BaseActivityItem & { + type: 'answergroup' + answer: Answer + items: ActivityItem[] +} + +export type CloseItem = BaseActivityItem & { + type: 'close' +} + +export type ResolveItem = BaseActivityItem & { + type: 'resolve' +} + +const DAY_IN_MS = 24 * 60 * 60 * 1000 + +// Group together bets that are: +// - Within `windowMs` of the first in the group +// - Do not have a comment +// - Were not created by this user or the contract creator +// Return a list of ActivityItems +function groupBets( + bets: Bet[], + comments: Comment[], + windowMs: number, + contract: Contract, + userId: string | undefined, + options: { + hideOutcome: boolean + abbreviated: boolean + } +) { + const { hideOutcome, abbreviated } = options + + const commentsMap = mapCommentsByBetId(comments) + const items: ActivityItem[] = [] + let group: Bet[] = [] + + // Turn the current group into an ActivityItem + function pushGroup() { + if (group.length == 1) { + items.push(toActivityItem(group[0])) + } else if (group.length > 1) { + items.push({ + type: 'betgroup', + bets: [...group], + id: group[0].id, + contract, + hideOutcome, + }) + } + group = [] + } + + function toActivityItem(bet: Bet): ActivityItem { + const comment = commentsMap[bet.id] + return comment + ? { + type: 'comment' as const, + id: bet.id, + comment, + bet, + contract, + hideOutcome, + truncate: abbreviated, + } + : { + type: 'bet' as const, + id: bet.id, + bet, + contract, + hideOutcome, + } + } + + for (const bet of bets) { + const isCreator = userId === bet.userId + + if (commentsMap[bet.id] || isCreator) { + pushGroup() + // Create a single item for this + items.push(toActivityItem(bet)) + } else { + if ( + group.length > 0 && + bet.createdTime - group[0].createdTime > windowMs + ) { + // More than `windowMs` has passed; start a new group + pushGroup() + } + group.push(bet) + } + } + if (group.length > 0) { + pushGroup() + } + return abbreviated ? items.slice(-3) : items +} + +function getAnswerGroups( + contract: Contract, + bets: Bet[], + comments: Comment[], + user: User | undefined | null, + options: { + sortByProb: boolean + abbreviated: boolean + } +) { + const { sortByProb, abbreviated } = options + + let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter( + (outcome) => getOutcomeProbability(contract.totalShares, outcome) > 0.01 + ) + if (abbreviated) outcomes = outcomes.slice(-2) + if (sortByProb) { + outcomes = _.sortBy( + outcomes, + (outcome) => -1 * getOutcomeProbability(contract.totalShares, outcome) + ) + } + + const answerGroups = outcomes.map((outcome) => { + const answerBets = bets.filter((bet) => bet.outcome === outcome) + const answerComments = comments.filter((comment) => + answerBets.some((bet) => bet.id === comment.betId) + ) + const answer = contract.answers?.find( + (answer) => answer.id === outcome + ) as Answer + + let items = groupBets( + answerBets, + answerComments, + DAY_IN_MS, + contract, + user?.id, + { hideOutcome: true, abbreviated } + ) + + if (abbreviated) items = items.slice(-2) + + return { + id: outcome, + type: 'answergroup' as const, + contract, + answer, + items, + user, + } + }) + + return answerGroups +} + +export function getAllContractActivityItems( + contract: Contract, + bets: Bet[], + comments: Comment[], + user: User | null | undefined, + filterToOutcome: string | undefined, + options: { + abbreviated: boolean + } +) { + const { abbreviated } = options + const { outcomeType } = contract + + bets = + outcomeType === 'BINARY' + ? bets.filter((bet) => !bet.isAnte) + : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) + + let answer: Answer | undefined + if (filterToOutcome) { + bets = bets.filter((bet) => bet.outcome === filterToOutcome) + answer = contract.answers?.find((answer) => answer.id === filterToOutcome) + } + + const items: ActivityItem[] = + filterToOutcome && answer + ? [{ type: 'createanswer', id: answer.id, contract, answer }] + : abbreviated + ? [{ type: 'question', id: '0', contract, showDescription: false }] + : [{ type: 'description', id: '0', contract }] + + items.push( + ...(outcomeType === 'FREE_RESPONSE' && !filterToOutcome + ? getAnswerGroups(contract, bets, comments, user, { + sortByProb: true, + abbreviated, + }) + : groupBets(bets, comments, DAY_IN_MS, contract, user?.id, { + hideOutcome: !!filterToOutcome, + abbreviated, + })) + ) + + if (contract.closeTime && contract.closeTime <= Date.now()) { + items.push({ type: 'close', id: `${contract.closeTime}`, contract }) + } + if (contract.resolution) { + items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract }) + } + + return items +} + +export function getRecentContractActivityItems( + contract: Contract, + bets: Bet[], + comments: Comment[], + user: User | null | undefined +) { + bets = bets.sort((b1, b2) => b1.createdTime - b2.createdTime) + comments = comments.sort((c1, c2) => c1.createdTime - c2.createdTime) + + const questionItem: QuestionItem = { + type: 'question', + id: '0', + contract, + showDescription: false, + } + + const answerItems = + contract.outcomeType === 'FREE_RESPONSE' + ? getAnswerGroups(contract, bets, comments, user, { + sortByProb: false, + abbreviated: true, + }) + : groupBets(bets, comments, DAY_IN_MS, contract, user?.id, { + hideOutcome: false, + abbreviated: true, + }) + + return [questionItem, ...answerItems] +} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx new file mode 100644 index 00000000..9c60e26b --- /dev/null +++ b/web/components/feed/contract-activity.tsx @@ -0,0 +1,54 @@ +import _ from 'lodash' + +import { Contract } from '../../lib/firebase/contracts' +import { Comment } from '../../lib/firebase/comments' +import { Bet } from '../../../common/bet' +import { useBets } from '../../hooks/use-bets' +import { useComments } from '../../hooks/use-comments' +import { + getAllContractActivityItems, + getRecentContractActivityItems, +} from './activity-items' +import { FeedItems } from './feed-items' +import { User } from '../../../common/user' + +export function ContractActivity(props: { + contract: Contract + bets: Bet[] + comments: Comment[] + user: User | null | undefined + mode: 'only-recent' | 'abbreviated' | 'all' + filterToOutcome?: string // Which multi-category outcome to filter + betRowClassName?: string +}) { + const { contract, user, filterToOutcome, mode, betRowClassName } = props + + const updatedComments = + // eslint-disable-next-line react-hooks/rules-of-hooks + mode === 'only-recent' ? undefined : useComments(contract.id) + const comments = updatedComments ?? props.comments + + // eslint-disable-next-line react-hooks/rules-of-hooks + const updatedBets = mode === 'only-recent' ? undefined : useBets(contract.id) + const bets = updatedBets ?? props.bets + + const items = + mode === 'only-recent' + ? getRecentContractActivityItems(contract, bets, comments, user) + : getAllContractActivityItems( + contract, + bets, + comments, + user, + filterToOutcome, + { abbreviated: mode === 'abbreviated' } + ) + + return ( + + ) +} diff --git a/web/components/contract-feed.tsx b/web/components/feed/feed-items.tsx similarity index 54% rename from web/components/contract-feed.tsx rename to web/components/feed/feed-items.tsx index d9fb2bcc..2c86779a 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/feed/feed-items.tsx @@ -14,78 +14,143 @@ import dayjs from 'dayjs' import clsx from 'clsx' import Textarea from 'react-expanding-textarea' -import { OutcomeLabel } from './outcome-label' +import { OutcomeLabel } from '../outcome-label' import { contractMetrics, Contract, contractPath, updateContract, tradingAllowed, -} from '../lib/firebase/contracts' -import { useUser } from '../hooks/use-user' -import { Linkify } from './linkify' -import { Row } from './layout/row' -import { createComment, MAX_COMMENT_LENGTH } from '../lib/firebase/comments' -import { useComments } from '../hooks/use-comments' -import { formatMoney } from '../../common/util/format' -import { ResolutionOrChance } from './contract-card' -import { SiteLink } from './site-link' -import { Col } from './layout/col' -import { UserLink } from './user-page' -import { DateTimeTooltip } from './datetime-tooltip' -import { useBets } from '../hooks/use-bets' -import { Bet } from '../lib/firebase/bets' -import { Comment, mapCommentsByBetId } from '../lib/firebase/comments' -import { JoinSpans } from './join-spans' -import { fromNow } from '../lib/util/time' -import BetRow from './bet-row' -import { parseTags } from '../../common/util/parse' -import { Avatar } from './avatar' -import { useAdmin } from '../hooks/use-admin' -import { Answer } from '../../common/answer' +} from '../../lib/firebase/contracts' +import { useUser } from '../../hooks/use-user' +import { Linkify } from '../linkify' +import { Row } from '../layout/row' +import { + canAddComment, + createComment, + MAX_COMMENT_LENGTH, +} from '../../lib/firebase/comments' +import { formatMoney } from '../../../common/util/format' +import { Comment } from '../../../common/comment' +import { ResolutionOrChance } from '../contract-card' +import { SiteLink } from '../site-link' +import { Col } from '../layout/col' +import { UserLink } from '../user-page' +import { DateTimeTooltip } from '../datetime-tooltip' +import { Bet } from '../../lib/firebase/bets' +import { JoinSpans } from '../join-spans' +import { fromNow } from '../../lib/util/time' +import BetRow from '../bet-row' +import { parseTags } from '../../../common/util/parse' +import { Avatar } from '../avatar' +import { useAdmin } from '../../hooks/use-admin' +import { Answer } from '../../../common/answer' +import { ActivityItem } from './activity-items' -const canAddComment = (createdTime: number, isSelf: boolean) => { - return isSelf && Date.now() - createdTime < 60 * 60 * 1000 +export function FeedItems(props: { + contract: Contract + items: ActivityItem[] + betRowClassName?: string +}) { + const { contract, items, betRowClassName } = props + const { outcomeType } = contract + + return ( +
+
+ {items.map((item, activityItemIdx) => ( +
+ {activityItemIdx !== items.length - 1 || + item.type === 'answergroup' ? ( +
+ ))} +
+ {outcomeType === 'BINARY' && tradingAllowed(contract) && ( + + )} +
+ ) +} + +function FeedItem(props: { item: ActivityItem }) { + const { item } = props + + switch (item.type) { + case 'question': + return + case 'description': + return + case 'comment': + return + case 'bet': + return + case 'createanswer': + return + case 'betgroup': + return + case 'answergroup': + return + case 'close': + return + case 'resolve': + return + } } function FeedComment(props: { - activityItem: any - moreHref: string - feedType: FeedType + contract: Contract + comment: Comment + bet: Bet + hideOutcome: boolean + truncate: boolean }) { - const { activityItem, moreHref, feedType } = props - const { person, text, amount, outcome, createdTime } = activityItem + const { contract, comment, bet, hideOutcome, truncate } = props + const { amount, outcome } = bet + const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const bought = amount >= 0 ? 'bought' : 'sold' const money = formatMoney(Math.abs(amount)) return ( <> - +

{' '} {bought} {money} - - + {!hideOutcome && ( + <> + {' '} + of + + )} +

) } -function Timestamp(props: { time: number }) { +function RelativeTimestamp(props: { time: number }) { const { time } = props return ( @@ -96,27 +161,31 @@ function Timestamp(props: { time: number }) { ) } -function FeedBet(props: { activityItem: any; feedType: FeedType }) { - const { activityItem, feedType } = props - const { id, contractId, amount, outcome, createdTime, contract } = - activityItem +function FeedBet(props: { + contract: Contract + bet: Bet + hideOutcome: boolean +}) { + const { contract, bet, hideOutcome } = props + const { id, amount, outcome, createdTime, userId } = bet const user = useUser() - const isSelf = user?.id == activityItem.userId - const isCreator = contract.creatorId == activityItem.userId + const isSelf = user?.id === userId + const isCreator = contract.creatorId === userId + // You can comment if your bet was posted in the last hour const canComment = canAddComment(createdTime, isSelf) const [comment, setComment] = useState('') async function submitComment() { if (!user || !comment) return - await createComment(contractId, id, comment, user) + await createComment(contract.id, id, comment, user) } const bought = amount >= 0 ? 'bought' : 'sold' const money = formatMoney(Math.abs(amount)) const answer = - feedType !== 'multi' && + !hideOutcome && (contract.answers?.find((answer: Answer) => answer?.id === outcome) as | Answer | undefined) @@ -125,9 +194,12 @@ function FeedBet(props: { activityItem: any; feedType: FeedType }) { <>
{isSelf ? ( - + ) : isCreator ? ( - + ) : (
@@ -147,10 +219,13 @@ function FeedBet(props: { activityItem: any; feedType: FeedType }) { {isSelf ? 'You' : isCreator ? contract.creatorName : 'A trader'} {' '} {bought} {money} - {!answer && ( - + {!answer && !hideOutcome && ( + <> + {' '} + of + )} - + {canComment && ( // Allow user to comment in an textarea if they are the creator
@@ -329,7 +404,7 @@ function TruncatedComment(props: { ) } -function FeedQuestion(props: { +export function FeedQuestion(props: { contract: Contract showDescription?: boolean }) { @@ -344,7 +419,7 @@ function FeedQuestion(props: { <> {contract.closeTime > Date.now() ? 'Closes' : 'Closed'} - + ) @@ -420,7 +495,7 @@ function FeedDescription(props: { contract: Contract }) { name={creatorName} username={creatorUsername} />{' '} - created this market + created this market
@@ -428,10 +503,8 @@ function FeedDescription(props: { contract: Contract }) { ) } -function FeedAnswer(props: { contract: Contract; outcome: string }) { - const { contract, outcome } = props - const answer = contract?.answers?.[Number(outcome) - 1] - if (!answer) return null +function FeedCreateAnswer(props: { contract: Contract; answer: Answer }) { + const { contract, answer } = props return ( <> @@ -443,8 +516,7 @@ function FeedAnswer(props: { contract: Contract; outcome: string }) { name={answer.name} username={answer.username} />{' '} - submitted answer {' '} - + submitted this answer
@@ -487,7 +559,7 @@ function FeedResolve(props: { contract: Contract }) { username={creatorUsername} />{' '} resolved this market to {' '} - + @@ -512,111 +584,15 @@ function FeedClose(props: { contract: Contract }) {
Trading closed in this market{' '} - +
) } -function toFeedBet(bet: Bet, contract: Contract) { - return { - id: bet.id, - contractId: bet.contractId, - userId: bet.userId, - type: 'bet', - amount: bet.sale ? -bet.sale.amount : bet.amount, - outcome: bet.outcome, - createdTime: bet.createdTime, - date: fromNow(bet.createdTime), - contract, - } -} - -function toFeedComment(bet: Bet, comment: Comment) { - return { - id: bet.id, - contractId: bet.contractId, - userId: bet.userId, - type: 'comment', - amount: bet.sale ? -bet.sale.amount : bet.amount, - outcome: bet.outcome, - createdTime: bet.createdTime, - date: fromNow(bet.createdTime), - - // Invariant: bet.comment exists - text: comment.text, - person: { - username: comment.userUsername, - name: comment.userName, - avatarUrl: comment.userAvatarUrl, - }, - } -} - -const DAY_IN_MS = 24 * 60 * 60 * 1000 - -// Group together bets that are: -// - Within `windowMs` of the first in the group -// - Do not have a comment -// - Were not created by this user or the contract creator -// Return a list of ActivityItems -function groupBets( - bets: Bet[], - comments: Comment[], - windowMs: number, - contract: Contract, - userId?: string -) { - const commentsMap = mapCommentsByBetId(comments) - const items: any[] = [] - let group: Bet[] = [] - - // Turn the current group into an ActivityItem - function pushGroup() { - if (group.length == 1) { - items.push(toActivityItem(group[0], false)) - } else if (group.length > 1) { - items.push({ type: 'betgroup', bets: [...group], id: group[0].id }) - } - group = [] - } - - function toActivityItem(bet: Bet, isPublic: boolean) { - const comment = commentsMap[bet.id] - return comment ? toFeedComment(bet, comment) : toFeedBet(bet, contract) - } - - for (const bet of bets) { - const isCreator = userId === bet.userId || contract.creatorId === bet.userId - - if (commentsMap[bet.id] || isCreator) { - pushGroup() - // Create a single item for this - items.push(toActivityItem(bet, true)) - } else { - if ( - group.length > 0 && - bet.createdTime - group[0].createdTime > windowMs - ) { - // More than `windowMs` has passed; start a new group - pushGroup() - } - group.push(bet) - } - } - if (group.length > 0) { - pushGroup() - } - return items as ActivityItem[] -} - -function BetGroupSpan(props: { - bets: Bet[] - outcome: string - feedType: FeedType -}) { - const { bets, outcome, feedType } = props +function BetGroupSpan(props: { bets: Bet[]; outcome?: string }) { + const { bets, outcome } = props const numberTraders = _.uniqBy(bets, (b) => b.userId).length @@ -631,15 +607,22 @@ function BetGroupSpan(props: { {buyTotal > 0 && <>bought {formatMoney(buyTotal)} } {sellTotal > 0 && <>sold {formatMoney(sellTotal)} } - {' '} + {outcome && ( + <> + {' '} + of + + )}{' '} ) } -// TODO: Make this expandable to show all grouped bets? -function FeedBetGroup(props: { activityItem: any; feedType: FeedType }) { - const { activityItem, feedType } = props - const bets: Bet[] = activityItem.bets +function FeedBetGroup(props: { + contract: Contract + bets: Bet[] + hideOutcome: boolean +}) { + const { bets, hideOutcome } = props const betGroups = _.groupBy(bets, (bet) => bet.outcome) const outcomes = Object.keys(betGroups) @@ -661,20 +644,66 @@ function FeedBetGroup(props: { activityItem: any; feedType: FeedType }) { {outcomes.map((outcome, index) => ( {index !== outcomes.length - 1 &&
}
))} - + ) } +function FeedAnswerGroup(props: { + contract: Contract + answer: Answer + items: ActivityItem[] +}) { + const { answer, items } = props + const { username, avatarUrl, userId, name, text } = answer + + return ( + + +
+
+ +
+
+ +
+ answered +
+ + +
+ + {items.map((item, index) => ( +
+ {index !== items.length - 1 ? ( +
+ ))} + + ) +} + // TODO: Should highlight the entire Feed segment function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) { const { setExpanded } = props @@ -701,253 +730,3 @@ function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) { ) } - -// On 'multi' feeds, the outcome is redundant, so we hide it -function MaybeOutcomeLabel(props: { outcome: string; feedType: FeedType }) { - const { outcome, feedType } = props - return feedType === 'multi' ? null : ( - - {' '} - of - {/* TODO: Link to the correct e.g. #23 */} - - ) -} - -// Missing feed items: -// - Bet sold? -type ActivityItem = { - id: string - type: - | 'bet' - | 'comment' - | 'start' - | 'betgroup' - | 'close' - | 'resolve' - | 'expand' - | undefined -} - -type FeedType = - // Main homepage/fold feed, - | 'activity' - // Comments feed on a market - | 'market' - // Grouped for a multi-category outcome - | 'multi' - -function FeedItems(props: { - contract: Contract - items: ActivityItem[] - feedType: FeedType - setExpanded: (expanded: boolean) => void - outcome?: string // Which multi-category outcome to filter - betRowClassName?: string -}) { - const { contract, items, feedType, outcome, setExpanded, betRowClassName } = - props - const { outcomeType } = contract - const isBinary = outcomeType === 'BINARY' - - return ( -
-
- {items.map((activityItem, activityItemIdx) => ( -
- {activityItemIdx !== items.length - 1 ? ( -
- ))} -
- {isBinary && tradingAllowed(contract) && ( - - )} -
- ) -} - -export function ContractFeed(props: { - contract: Contract - bets: Bet[] - comments: Comment[] - feedType: FeedType - outcome?: string // Which multi-category outcome to filter - betRowClassName?: string -}) { - const { contract, feedType, outcome, betRowClassName } = props - const { id, outcomeType } = contract - const isBinary = outcomeType === 'BINARY' - - const [expanded, setExpanded] = useState(false) - const user = useUser() - - const comments = useComments(id) ?? props.comments - - let bets = useBets(contract.id) ?? props.bets - bets = isBinary - ? bets.filter((bet) => !bet.isAnte) - : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) - - if (feedType === 'multi') { - bets = bets.filter((bet) => bet.outcome === outcome) - } else if (outcomeType === 'FREE_RESPONSE') { - // Keep bets on comments or your bets where you can comment. - const commentBetIds = new Set(comments.map((comment) => comment.betId)) - bets = bets.filter( - (bet) => - commentBetIds.has(bet.id) || - canAddComment(bet.createdTime, user?.id === bet.userId) - ) - } - - const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS - - const allItems: ActivityItem[] = [ - { type: 'start', id: '0' }, - ...groupBets(bets, comments, groupWindow, contract, user?.id), - ] - if (contract.closeTime && contract.closeTime <= Date.now()) { - allItems.push({ type: 'close', id: `${contract.closeTime}` }) - } - if (contract.resolution) { - allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) - } - if (feedType === 'multi') { - // Hack to add some more padding above the 'multi' feedType, by adding a null item - allItems.unshift({ type: undefined, id: '-1' }) - } - - // If there are more than 5 items, only show the first, an expand item, and last 3 - let items = allItems - if (!expanded && allItems.length > 5 && feedType == 'activity') { - items = [ - allItems[0], - { type: 'expand', id: 'expand' }, - ...allItems.slice(-3), - ] - } - - return ( - - ) -} - -export function ContractActivityFeed(props: { - contract: Contract - bets: Bet[] - comments: Comment[] - betRowClassName?: string -}) { - const { contract, betRowClassName, comments } = props - - const user = useUser() - - let bets = props.bets.sort((b1, b2) => b1.createdTime - b2.createdTime) - comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - - if (contract.outcomeType === 'FREE_RESPONSE') { - // Keep bets on comments, and the last non-comment bet. - const commentBetIds = new Set(comments.map((comment) => comment.betId)) - const [commentBets, nonCommentBets] = _.partition(bets, (bet) => - commentBetIds.has(bet.id) - ) - bets = [...commentBets, ...nonCommentBets.slice(-1)].sort( - (b1, b2) => b1.createdTime - b2.createdTime - ) - } - - const allItems: ActivityItem[] = [ - { type: 'start', id: '0' }, - ...groupBets(bets, comments, DAY_IN_MS, contract, user?.id), - ] - if (contract.closeTime && contract.closeTime <= Date.now()) { - allItems.push({ type: 'close', id: `${contract.closeTime}` }) - } - if (contract.resolution) { - allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) - } - - // Remove all but last bet group. - const betGroups = allItems.filter((item) => item.type === 'betgroup') - const lastBetGroup = betGroups[betGroups.length - 1] - const filtered = allItems.filter( - (item) => item.type !== 'betgroup' || item.id === lastBetGroup?.id - ) - - // Only show the first item plus the last three items. - const items = - filtered.length > 3 ? [filtered[0], ...filtered.slice(-3)] : filtered - - return ( - {}} - betRowClassName={betRowClassName} - /> - ) -} - -export function ContractSummaryFeed(props: { - contract: Contract - betRowClassName?: string -}) { - const { contract, betRowClassName } = props - const { outcomeType } = contract - const isBinary = outcomeType === 'BINARY' - - return ( -
-
-
-
- -
-
-
- {isBinary && tradingAllowed(contract) && ( - - )} -
- ) -} diff --git a/web/components/feed/find-active-contracts.ts b/web/components/feed/find-active-contracts.ts new file mode 100644 index 00000000..92e9a88a --- /dev/null +++ b/web/components/feed/find-active-contracts.ts @@ -0,0 +1,68 @@ +import _ from 'lodash' +import { Contract } from '../../lib/firebase/contracts' +import { Comment } from '../../lib/firebase/comments' +import { Bet } from '../../../common/bet' + +const MAX_ACTIVE_CONTRACTS = 75 + +// 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 +// - Bet on market +export function findActiveContracts( + allContracts: Contract[], + recentComments: Comment[], + recentBets: Bet[] +) { + 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)) + } + + const contractsById = new Map(allContracts.map((c) => [c.id, c])) + + // Record contract activity. + for (const contract of allContracts) { + record(contract.id, lastActivityTime(contract)) + } + + // Add every contract that had a recent comment, too + for (const comment of recentComments) { + const contract = contractsById.get(comment.contractId) + if (contract) record(contract.id, comment.createdTime) + } + + // Add contracts by last bet time. + const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) + const contractMostRecentBet = _.mapValues( + contractBets, + (bets) => _.maxBy(bets, (bet) => bet.createdTime) as Bet + ) + for (const bet of Object.values(contractMostRecentBet)) { + const contract = contractsById.get(bet.contractId) + if (contract) record(contract.id, bet.createdTime) + } + + let activeContracts = allContracts.filter( + (contract) => contract.visibility === 'public' && !contract.isResolved + ) + activeContracts = _.sortBy( + activeContracts, + (c) => -(idToActivityTime.get(c.id) ?? 0) + ) + return activeContracts.slice(0, MAX_ACTIVE_CONTRACTS) +} diff --git a/web/hooks/use-find-active-contracts.ts b/web/hooks/use-find-active-contracts.ts index bb7f9bd7..dd3068ed 100644 --- a/web/hooks/use-find-active-contracts.ts +++ b/web/hooks/use-find-active-contracts.ts @@ -4,11 +4,11 @@ import { useMemo, useRef } from 'react' import { Fold } from '../../common/fold' import { User } from '../../common/user' import { filterDefined } from '../../common/util/array' +import { findActiveContracts } from '../components/feed/find-active-contracts' import { Bet } from '../lib/firebase/bets' import { Comment, getRecentComments } from '../lib/firebase/comments' import { Contract, getActiveContracts } from '../lib/firebase/contracts' import { listAllFolds } from '../lib/firebase/folds' -import { findActiveContracts } from '../components/activity-feed' import { useInactiveContracts } from './use-contracts' import { useFollowedFolds } from './use-fold' import { useUserBetContracts } from './use-user-bets' diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index a6b9d9ea..df8d1fd9 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -40,6 +40,10 @@ export async function createComment( return await setDoc(ref, comment) } +export const canAddComment = (createdTime: number, isSelf: boolean) => { + return isSelf && Date.now() - createdTime < 60 * 60 * 1000 +} + function getCommentsCollection(contractId: string) { return collection(db, 'contracts', contractId, 'comments') } diff --git a/web/pages/fold/[...slugs]/index.tsx b/web/pages/fold/[...slugs]/index.tsx index 03808d39..55e2de4c 100644 --- a/web/pages/fold/[...slugs]/index.tsx +++ b/web/pages/fold/[...slugs]/index.tsx @@ -12,10 +12,7 @@ import { getFoldBySlug, getFoldContracts, } from '../../../lib/firebase/folds' -import { - ActivityFeed, - findActiveContracts, -} from '../../../components/activity-feed' +import { ActivityFeed } from '../../../components/feed/activity-feed' import { TagsList } from '../../../components/tags-list' import { Row } from '../../../components/layout/row' import { UserLink } from '../../../components/user-page' @@ -43,6 +40,7 @@ import { filterDefined } from '../../../../common/util/array' import { useRecentBets } from '../../../hooks/use-bets' import { useRecentComments } from '../../../hooks/use-comments' import { LoadingIndicator } from '../../../components/loading-indicator' +import { findActiveContracts } from '../../../components/feed/find-active-contracts' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -248,7 +246,7 @@ export default function FoldPage(props: { contracts={activeContracts} recentBets={recentBets ?? []} recentComments={recentComments ?? []} - loadBetAndCommentHistory + mode="abbreviated" /> {activeContracts.length === 0 && (
diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 3416f085..7cf2126a 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -6,7 +6,10 @@ import _ from 'lodash' import { Contract } from '../lib/firebase/contracts' import { Page } from '../components/page' -import { ActivityFeed, SummaryActivityFeed } from '../components/activity-feed' +import { + ActivityFeed, + SummaryActivityFeed, +} from '../components/feed/activity-feed' import { Comment } from '../lib/firebase/comments' import FeedCreate from '../components/feed-create' import { Spacer } from '../components/layout/spacer' @@ -128,6 +131,7 @@ const Home = (props: { contracts={activeContracts} recentBets={recentBets} recentComments={recentComments} + mode="only-recent" /> ) : (