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..8424c3ef 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,11 +85,11 @@ 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..c012bd9b 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,11 @@ 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..784f6317 --- /dev/null +++ b/web/components/feed/activity-feed.tsx @@ -0,0 +1,109 @@ +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, RecentContractActivity } from './contract-activity' + +export function ActivityFeed(props: { + contracts: Contract[] + recentBets: Bet[] + recentComments: Comment[] + loadBetAndCommentHistory?: boolean +}) { + const { contracts, recentBets, recentComments, loadBetAndCommentHistory } = + props + + const user = useUser() + + const groupedBets = _.groupBy(recentBets, (bet) => bet.contractId) + const groupedComments = _.groupBy( + recentComments, + (comment) => comment.contractId + ) + + return ( + + loadBetAndCommentHistory ? ( + + ) : ( + + ) + } + /> + ) +} + +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..0fd89dbc --- /dev/null +++ b/web/components/feed/activity-items.ts @@ -0,0 +1,249 @@ +import _ from 'lodash' + +import { Answer } from '../../../common/answer' +import { Bet } from '../../../common/bet' +import { Comment } from '../../../common/comment' +import { Contract } from '../../../common/contract' +import { User } from '../../../common/user' +import { filterDefined } from '../../../common/util/array' +import { canAddComment, mapCommentsByBetId } from '../../lib/firebase/comments' +import { fromNow } from '../../lib/util/time' + +export type ActivityItem = { + id: string + type: + | 'bet' + | 'comment' + | 'start' + | 'betgroup' + | 'answergroup' + | 'close' + | 'resolve' + | 'expand' + | undefined +} + +export type FeedAnswerGroupItem = ActivityItem & { + type: 'answergroup' + contract: Contract + bets: Bet[] + comments: Comment[] + answer: Answer + user: User | null | undefined +} + +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 getAnswerGroups( + contract: Contract, + bets: Bet[], + comments: Comment[], + user: User | undefined | null +) { + // Keep last two comments. + comments = comments.slice(-2) + const lastBet = bets[bets.length - 1] + + // Include up to 2 outcomes from comments and last bet. + const outcomes = filterDefined( + _.uniq([ + ...comments.map( + (comment) => bets.find((bet) => bet.id === comment.betId)?.outcome + ), + lastBet?.outcome, + ]) + ).slice(0, 2) + + // Keep bets on selected outcomes. + bets = bets.filter((bet) => outcomes.includes(bet.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 + + return { + id: outcome, + type: 'answergroup' as const, + contract, + answer, + bets: answerBets, + comments: answerComments, + user, + } + }) + + return answerGroups +} + +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, + }, + } +} + +export function getAllContractActivityItems( + contract: Contract, + bets: Bet[], + comments: Comment[], + user: User | null | undefined, + outcome?: string +) { + const { outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + + bets = isBinary + ? bets.filter((bet) => !bet.isAnte) + : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) + + if (outcome) { + 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 items: ActivityItem[] = outcome ? [] : [{ type: 'start', id: '0' }] + + items.push(...groupBets(bets, comments, DAY_IN_MS, contract, user?.id)) + + if (contract.closeTime && contract.closeTime <= Date.now()) { + items.push({ type: 'close', id: `${contract.closeTime}` }) + } + if (contract.resolution) { + items.push({ type: 'resolve', id: `${contract.resolutionTime}` }) + } + if (outcome) { + // Hack to add some more padding above the 'multi' feedType, by adding a null item. + items.unshift({ type: undefined, id: '-1' }) + } + + 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 items: ActivityItem[] = [{ type: 'start', id: '0' }] + items.push( + ...(contract.outcomeType === 'FREE_RESPONSE' + ? getAnswerGroups(contract, bets, comments, user) + : groupBets(bets, comments, DAY_IN_MS, contract, user?.id)) + ) + + if (contract.closeTime && contract.closeTime <= Date.now()) { + items.push({ type: 'close', id: `${contract.closeTime}` }) + } + if (contract.resolution) { + items.push({ type: 'resolve', id: `${contract.resolutionTime}` }) + } + + // Remove all but last bet group. + const betGroups = items.filter((item) => item.type === 'betgroup') + const lastBetGroup = betGroups[betGroups.length - 1] + const filtered = items.filter( + (item) => item.type !== 'betgroup' || item.id === lastBetGroup?.id + ) + + // Only show the first item plus the last three items. + return filtered.length > 3 ? [filtered[0], ...filtered.slice(-3)] : filtered +} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx new file mode 100644 index 00000000..df55b17b --- /dev/null +++ b/web/components/feed/contract-activity.tsx @@ -0,0 +1,71 @@ +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 + outcome?: string // Which multi-category outcome to filter + abbreviated?: boolean + betRowClassName?: string +}) { + const { contract, user, outcome, abbreviated, betRowClassName } = props + + const comments = useComments(contract.id) ?? props.comments + const bets = useBets(contract.id) ?? props.bets + + let items = getAllContractActivityItems( + contract, + bets, + comments, + user, + outcome + ) + + if (abbreviated) { + items = [items[0], ...items.slice(-3)] + } + + return ( + + ) +} + +export function RecentContractActivity(props: { + contract: Contract + bets: Bet[] + comments: Comment[] + user: User | null | undefined + betRowClassName?: string +}) { + const { contract, bets, comments, user, betRowClassName } = props + + const items = getRecentContractActivityItems(contract, bets, comments, user) + + return ( + + ) +} diff --git a/web/components/contract-feed.tsx b/web/components/feed/feed-items.tsx similarity index 67% rename from web/components/contract-feed.tsx rename to web/components/feed/feed-items.tsx index dd870a30..39b9ddf3 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/feed/feed-items.tsx @@ -14,39 +14,105 @@ 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' -import { filterDefined } from '../../common/util/array' +} 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 { 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, FeedAnswerGroupItem } from './activity-items' -const canAddComment = (createdTime: number, isSelf: boolean) => { - return isSelf && Date.now() - createdTime < 60 * 60 * 1000 +export type FeedType = + // Main homepage/fold feed, + | 'activity' + // Comments feed on a market + | 'market' + // Grouped for a multi-category outcome + | 'multi' + +export function FeedItems(props: { + contract: Contract + items: ActivityItem[] + feedType: FeedType + outcome?: string // Which multi-category outcome to filter + betRowClassName?: string +}) { + const { contract, items, feedType, outcome, betRowClassName } = props + const { outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + + return ( +
+
+ {items.map((activityItem, activityItemIdx) => ( +
+ {activityItemIdx !== items.length - 1 ? ( +
+ ))} +
+ {isBinary && tradingAllowed(contract) && ( + + )} +
+ ) } function FeedComment(props: { @@ -60,21 +126,21 @@ function FeedComment(props: { const bought = amount >= 0 ? 'bought' : 'sold' const money = formatMoney(Math.abs(amount)) - const answer = - feedType !== 'multi' && - (contract.answers?.find((answer: Answer) => answer?.id === outcome) as - | Answer - | undefined) + // const answer = + // feedType !== 'multi' && + // (contract.answers?.find((answer: Answer) => answer?.id === outcome) as + // | Answer + // | undefined) return ( <>
- {answer && ( + {/* {answer && (
{answer.text}
- )} + )} */}

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 @@ -687,6 +661,34 @@ function FeedBetGroup(props: { activityItem: any; feedType: FeedType }) { ) } +function FeedAnswerGroup(props: { activityItem: FeedAnswerGroupItem }) { + const { activityItem } = props + const { contract, answer, bets, comments, user } = activityItem + + const betGroups = _.groupBy(bets, (bet) => bet.outcome) + const outcomes = Object.keys(betGroups) + + // Use the time of the last bet for the entire group + const createdTime = bets[bets.length - 1].createdTime + + return ( + <> +

+
+
+
+
+
+
+
+ {answer.text} +
+
+ + ) +} + // TODO: Should highlight the entire Feed segment function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) { const { setExpanded } = props @@ -725,272 +727,3 @@ function MaybeOutcomeLabel(props: { outcome: string; feedType: FeedType }) { ) } - -// Missing feed items: -// - Bet sold? -type ActivityItem = { - id: string - type: - | 'bet' - | 'comment' - | 'start' - | 'betgroup' - | 'answergroup' - | '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 } = props - - const user = useUser() - - let bets = props.bets.sort((b1, b2) => b1.createdTime - b2.createdTime) - let comments = props.comments.sort( - (c1, c2) => c1.createdTime - c2.createdTime - ) - - if (contract.outcomeType === 'FREE_RESPONSE') { - // Keep last two comments. - comments = comments.slice(-2) - const lastBet = bets[bets.length - 1] - - // Include up to 2 outcomes from comments and last bet. - const outcomes = filterDefined( - _.uniq([ - ...comments.map( - (comment) => bets.find((bet) => bet.id === comment.betId)?.outcome - ), - lastBet?.outcome, - ]) - ).slice(0, 2) - - // Keep bets on selected outcomes. - bets = bets.filter((bet) => outcomes.includes(bet.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 - - return { - contract, - answer, - bets: answerBets, - comments: answerComments, - user, - } - }) - - console.log('comments', comments, 'outcomes', outcomes, 'bets', bets) - } - - 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 e6f849f6..cff0658d 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..81226006 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[] } }) { diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 3416f085..85cd5b5f 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'