Answers in feed (#63)
* Compute answer group feed items * Refactor feed code into 5 files under feed directory. * Convert to typed ActivityItems * Use feed "mode". Clean up cases. * Implement feed answer groups! * FR: Use nested comments/bets under answers for contract page. filter more items out of FR feed. * Linkify answer text on activity feed * Default feed excluded tags are case insensitive * Show followed folds first * Allow filtering your trades * Store users's last sort in localstorage * Use avatar of user that submitted answer and name instead of pencil icon. Spacing. Show up to 3 comments in asnwer group. * Don't reveal market creator's bets * Fix communites feed to be abbreviated * Remove complicated answer group logic Co-authored-by: Austin Chen <akrolsmir@gmail.com>
This commit is contained in:
parent
d053fb6cb7
commit
93287f8dc1
|
@ -10,7 +10,7 @@ export type Comment = {
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
|
||||||
// Denormalized, for rendering comments
|
// Denormalized, for rendering comments
|
||||||
userName?: string
|
userName: string
|
||||||
userUsername?: string
|
userUsername: string
|
||||||
userAvatarUrl?: string
|
userAvatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<string, number>()
|
|
||||||
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 (
|
|
||||||
<Col className="items-center">
|
|
||||||
<Col className="w-full">
|
|
||||||
<Col className="w-full divide-y divide-gray-300 self-center bg-white">
|
|
||||||
{contracts.map((contract) => (
|
|
||||||
<div key={contract.id} className="py-6 px-2 sm:px-4">
|
|
||||||
{loadBetAndCommentHistory ? (
|
|
||||||
<ContractFeed
|
|
||||||
contract={contract}
|
|
||||||
bets={groupedBets[contract.id] ?? []}
|
|
||||||
comments={groupedComments[contract.id] ?? []}
|
|
||||||
feedType="activity"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ContractActivityFeed
|
|
||||||
contract={contract}
|
|
||||||
bets={groupedBets[contract.id] ?? []}
|
|
||||||
comments={groupedComments[contract.id] ?? []}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SummaryActivityFeed(props: { contracts: Contract[] }) {
|
|
||||||
const { contracts } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col className="items-center">
|
|
||||||
<Col className="w-full max-w-3xl">
|
|
||||||
<Col className="w-full divide-y divide-gray-300 self-center bg-white">
|
|
||||||
{contracts.map((contract) => (
|
|
||||||
<div key={contract.id} className="py-6 px-2 sm:px-4">
|
|
||||||
<ContractSummaryFeed contract={contract} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -13,12 +13,14 @@ import { formatPercent } from '../../../common/util/format'
|
||||||
import { getOutcomeProbability } from '../../../common/calculate'
|
import { getOutcomeProbability } from '../../../common/calculate'
|
||||||
import { tradingAllowed } from '../../lib/firebase/contracts'
|
import { tradingAllowed } from '../../lib/firebase/contracts'
|
||||||
import { AnswerBetPanel } from './answer-bet-panel'
|
import { AnswerBetPanel } from './answer-bet-panel'
|
||||||
import { ContractFeed } from '../contract-feed'
|
|
||||||
import { Linkify } from '../linkify'
|
import { Linkify } from '../linkify'
|
||||||
|
import { User } from '../../../common/user'
|
||||||
|
import { ContractActivity } from '../feed/contract-activity'
|
||||||
|
|
||||||
export function AnswerItem(props: {
|
export function AnswerItem(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
user: User | null | undefined
|
||||||
showChoice: 'radio' | 'checkbox' | undefined
|
showChoice: 'radio' | 'checkbox' | undefined
|
||||||
chosenProb: number | undefined
|
chosenProb: number | undefined
|
||||||
totalChosenProb?: number
|
totalChosenProb?: number
|
||||||
|
@ -28,6 +30,7 @@ export function AnswerItem(props: {
|
||||||
const {
|
const {
|
||||||
answer,
|
answer,
|
||||||
contract,
|
contract,
|
||||||
|
user,
|
||||||
showChoice,
|
showChoice,
|
||||||
chosenProb,
|
chosenProb,
|
||||||
totalChosenProb,
|
totalChosenProb,
|
||||||
|
@ -82,12 +85,13 @@ export function AnswerItem(props: {
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{isBetting && (
|
{isBetting && (
|
||||||
<ContractFeed
|
<ContractActivity
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={[]}
|
bets={[]}
|
||||||
comments={[]}
|
comments={[]}
|
||||||
feedType="multi"
|
user={user}
|
||||||
outcome={answer.id}
|
filterToOutcome={answer.id}
|
||||||
|
mode="all"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -86,6 +86,7 @@ export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
|
||||||
key={answer.id}
|
key={answer.id}
|
||||||
answer={answer}
|
answer={answer}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
user={user}
|
||||||
showChoice={showChoice}
|
showChoice={showChoice}
|
||||||
chosenProb={chosenAnswers[answer.id]}
|
chosenProb={chosenAnswers[answer.id]}
|
||||||
totalChosenProb={chosenTotal}
|
totalChosenProb={chosenTotal}
|
||||||
|
|
|
@ -12,13 +12,13 @@ import { Row } from './layout/row'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ContractDetails, ResolutionOrChance } from './contract-card'
|
import { ContractDetails, ResolutionOrChance } from './contract-card'
|
||||||
import { ContractFeed } from './contract-feed'
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { RevealableTagsInput, TagsInput } from './tags-input'
|
import { RevealableTagsInput, TagsInput } from './tags-input'
|
||||||
import BetRow from './bet-row'
|
import BetRow from './bet-row'
|
||||||
import { Fold } from '../../common/fold'
|
import { Fold } from '../../common/fold'
|
||||||
import { FoldTagList } from './tags-list'
|
import { FoldTagList } from './tags-list'
|
||||||
|
import { ContractActivity } from './feed/contract-activity'
|
||||||
|
|
||||||
export const ContractOverview = (props: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -119,11 +119,12 @@ export const ContractOverview = (props: {
|
||||||
|
|
||||||
<Spacer h={12} />
|
<Spacer h={12} />
|
||||||
|
|
||||||
<ContractFeed
|
<ContractActivity
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
feedType="market"
|
user={user}
|
||||||
|
mode="all"
|
||||||
betRowClassName="!mt-0"
|
betRowClassName="!mt-0"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
99
web/components/feed/activity-feed.tsx
Normal file
99
web/components/feed/activity-feed.tsx
Normal file
|
@ -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 (
|
||||||
|
<FeedContainer
|
||||||
|
contracts={contracts}
|
||||||
|
renderContract={(contract) => (
|
||||||
|
<ContractActivity
|
||||||
|
user={user}
|
||||||
|
contract={contract}
|
||||||
|
bets={groupedBets[contract.id] ?? []}
|
||||||
|
comments={groupedComments[contract.id] ?? []}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryActivityFeed(props: { contracts: Contract[] }) {
|
||||||
|
const { contracts } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeedContainer
|
||||||
|
contracts={contracts}
|
||||||
|
renderContract={(contract) => <ContractSummary contract={contract} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedContainer(props: {
|
||||||
|
contracts: Contract[]
|
||||||
|
renderContract: (contract: Contract) => any
|
||||||
|
}) {
|
||||||
|
const { contracts, renderContract } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className="items-center">
|
||||||
|
<Col className="w-full max-w-3xl">
|
||||||
|
<Col className="w-full divide-y divide-gray-300 self-center bg-white">
|
||||||
|
{contracts.map((contract) => (
|
||||||
|
<div key={contract.id} className="py-6 px-2 sm:px-4">
|
||||||
|
{renderContract(contract)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContractSummary(props: {
|
||||||
|
contract: Contract
|
||||||
|
betRowClassName?: string
|
||||||
|
}) {
|
||||||
|
const { contract, betRowClassName } = props
|
||||||
|
const { outcomeType } = contract
|
||||||
|
const isBinary = outcomeType === 'BINARY'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow-root pr-2 md:pr-0">
|
||||||
|
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}>
|
||||||
|
<div className="relative pb-8">
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
<FeedQuestion contract={contract} showDescription />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isBinary && tradingAllowed(contract) && (
|
||||||
|
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
298
web/components/feed/activity-items.ts
Normal file
298
web/components/feed/activity-items.ts
Normal file
|
@ -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]
|
||||||
|
}
|
54
web/components/feed/contract-activity.tsx
Normal file
54
web/components/feed/contract-activity.tsx
Normal file
|
@ -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 (
|
||||||
|
<FeedItems
|
||||||
|
contract={contract}
|
||||||
|
items={items}
|
||||||
|
betRowClassName={betRowClassName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,78 +14,143 @@ import dayjs from 'dayjs'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Textarea from 'react-expanding-textarea'
|
import Textarea from 'react-expanding-textarea'
|
||||||
|
|
||||||
import { OutcomeLabel } from './outcome-label'
|
import { OutcomeLabel } from '../outcome-label'
|
||||||
import {
|
import {
|
||||||
contractMetrics,
|
contractMetrics,
|
||||||
Contract,
|
Contract,
|
||||||
contractPath,
|
contractPath,
|
||||||
updateContract,
|
updateContract,
|
||||||
tradingAllowed,
|
tradingAllowed,
|
||||||
} from '../lib/firebase/contracts'
|
} from '../../lib/firebase/contracts'
|
||||||
import { useUser } from '../hooks/use-user'
|
import { useUser } from '../../hooks/use-user'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from '../linkify'
|
||||||
import { Row } from './layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { createComment, MAX_COMMENT_LENGTH } from '../lib/firebase/comments'
|
import {
|
||||||
import { useComments } from '../hooks/use-comments'
|
canAddComment,
|
||||||
import { formatMoney } from '../../common/util/format'
|
createComment,
|
||||||
import { ResolutionOrChance } from './contract-card'
|
MAX_COMMENT_LENGTH,
|
||||||
import { SiteLink } from './site-link'
|
} from '../../lib/firebase/comments'
|
||||||
import { Col } from './layout/col'
|
import { formatMoney } from '../../../common/util/format'
|
||||||
import { UserLink } from './user-page'
|
import { Comment } from '../../../common/comment'
|
||||||
import { DateTimeTooltip } from './datetime-tooltip'
|
import { ResolutionOrChance } from '../contract-card'
|
||||||
import { useBets } from '../hooks/use-bets'
|
import { SiteLink } from '../site-link'
|
||||||
import { Bet } from '../lib/firebase/bets'
|
import { Col } from '../layout/col'
|
||||||
import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
|
import { UserLink } from '../user-page'
|
||||||
import { JoinSpans } from './join-spans'
|
import { DateTimeTooltip } from '../datetime-tooltip'
|
||||||
import { fromNow } from '../lib/util/time'
|
import { Bet } from '../../lib/firebase/bets'
|
||||||
import BetRow from './bet-row'
|
import { JoinSpans } from '../join-spans'
|
||||||
import { parseTags } from '../../common/util/parse'
|
import { fromNow } from '../../lib/util/time'
|
||||||
import { Avatar } from './avatar'
|
import BetRow from '../bet-row'
|
||||||
import { useAdmin } from '../hooks/use-admin'
|
import { parseTags } from '../../../common/util/parse'
|
||||||
import { Answer } from '../../common/answer'
|
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) => {
|
export function FeedItems(props: {
|
||||||
return isSelf && Date.now() - createdTime < 60 * 60 * 1000
|
contract: Contract
|
||||||
|
items: ActivityItem[]
|
||||||
|
betRowClassName?: string
|
||||||
|
}) {
|
||||||
|
const { contract, items, betRowClassName } = props
|
||||||
|
const { outcomeType } = contract
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow-root pr-2 md:pr-0">
|
||||||
|
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
|
||||||
|
{items.map((item, activityItemIdx) => (
|
||||||
|
<div key={item.id} className="relative pb-6">
|
||||||
|
{activityItemIdx !== items.length - 1 ||
|
||||||
|
item.type === 'answergroup' ? (
|
||||||
|
<span
|
||||||
|
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
<FeedItem item={item} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{outcomeType === 'BINARY' && tradingAllowed(contract) && (
|
||||||
|
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedItem(props: { item: ActivityItem }) {
|
||||||
|
const { item } = props
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case 'question':
|
||||||
|
return <FeedQuestion {...item} />
|
||||||
|
case 'description':
|
||||||
|
return <FeedDescription {...item} />
|
||||||
|
case 'comment':
|
||||||
|
return <FeedComment {...item} />
|
||||||
|
case 'bet':
|
||||||
|
return <FeedBet {...item} />
|
||||||
|
case 'createanswer':
|
||||||
|
return <FeedCreateAnswer {...item} />
|
||||||
|
case 'betgroup':
|
||||||
|
return <FeedBetGroup {...item} />
|
||||||
|
case 'answergroup':
|
||||||
|
return <FeedAnswerGroup {...item} />
|
||||||
|
case 'close':
|
||||||
|
return <FeedClose {...item} />
|
||||||
|
case 'resolve':
|
||||||
|
return <FeedResolve {...item} />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedComment(props: {
|
function FeedComment(props: {
|
||||||
activityItem: any
|
contract: Contract
|
||||||
moreHref: string
|
comment: Comment
|
||||||
feedType: FeedType
|
bet: Bet
|
||||||
|
hideOutcome: boolean
|
||||||
|
truncate: boolean
|
||||||
}) {
|
}) {
|
||||||
const { activityItem, moreHref, feedType } = props
|
const { contract, comment, bet, hideOutcome, truncate } = props
|
||||||
const { person, text, amount, outcome, createdTime } = activityItem
|
const { amount, outcome } = bet
|
||||||
|
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||||
|
|
||||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||||
const money = formatMoney(Math.abs(amount))
|
const money = formatMoney(Math.abs(amount))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Avatar username={person.username} avatarUrl={person.avatarUrl} />
|
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="mt-0.5 text-sm text-gray-500">
|
<p className="mt-0.5 text-sm text-gray-500">
|
||||||
<UserLink
|
<UserLink
|
||||||
className="text-gray-500"
|
className="text-gray-500"
|
||||||
username={person.username}
|
username={userUsername}
|
||||||
name={person.name}
|
name={userName}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
{bought} {money}
|
{bought} {money}
|
||||||
<MaybeOutcomeLabel outcome={outcome} feedType={feedType} />
|
{!hideOutcome && (
|
||||||
<Timestamp time={createdTime} />
|
<>
|
||||||
|
{' '}
|
||||||
|
of <OutcomeLabel outcome={outcome} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<RelativeTimestamp time={createdTime} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<TruncatedComment
|
<TruncatedComment
|
||||||
comment={text}
|
comment={text}
|
||||||
moreHref={moreHref}
|
moreHref={contractPath(contract)}
|
||||||
shouldTruncate={feedType == 'activity'}
|
shouldTruncate={truncate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Timestamp(props: { time: number }) {
|
function RelativeTimestamp(props: { time: number }) {
|
||||||
const { time } = props
|
const { time } = props
|
||||||
return (
|
return (
|
||||||
<DateTimeTooltip time={time}>
|
<DateTimeTooltip time={time}>
|
||||||
|
@ -96,27 +161,31 @@ function Timestamp(props: { time: number }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedBet(props: { activityItem: any; feedType: FeedType }) {
|
function FeedBet(props: {
|
||||||
const { activityItem, feedType } = props
|
contract: Contract
|
||||||
const { id, contractId, amount, outcome, createdTime, contract } =
|
bet: Bet
|
||||||
activityItem
|
hideOutcome: boolean
|
||||||
|
}) {
|
||||||
|
const { contract, bet, hideOutcome } = props
|
||||||
|
const { id, amount, outcome, createdTime, userId } = bet
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isSelf = user?.id == activityItem.userId
|
const isSelf = user?.id === userId
|
||||||
const isCreator = contract.creatorId == activityItem.userId
|
const isCreator = contract.creatorId === userId
|
||||||
|
|
||||||
// You can comment if your bet was posted in the last hour
|
// You can comment if your bet was posted in the last hour
|
||||||
const canComment = canAddComment(createdTime, isSelf)
|
const canComment = canAddComment(createdTime, isSelf)
|
||||||
|
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
async function submitComment() {
|
async function submitComment() {
|
||||||
if (!user || !comment) return
|
if (!user || !comment) return
|
||||||
await createComment(contractId, id, comment, user)
|
await createComment(contract.id, id, comment, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||||
const money = formatMoney(Math.abs(amount))
|
const money = formatMoney(Math.abs(amount))
|
||||||
|
|
||||||
const answer =
|
const answer =
|
||||||
feedType !== 'multi' &&
|
!hideOutcome &&
|
||||||
(contract.answers?.find((answer: Answer) => answer?.id === outcome) as
|
(contract.answers?.find((answer: Answer) => answer?.id === outcome) as
|
||||||
| Answer
|
| Answer
|
||||||
| undefined)
|
| undefined)
|
||||||
|
@ -125,9 +194,12 @@ function FeedBet(props: { activityItem: any; feedType: FeedType }) {
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<Avatar avatarUrl={user?.avatarUrl} />
|
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
||||||
) : isCreator ? (
|
) : isCreator ? (
|
||||||
<Avatar avatarUrl={contract.creatorAvatarUrl} />
|
<Avatar
|
||||||
|
avatarUrl={contract.creatorAvatarUrl}
|
||||||
|
username={contract.creatorUsername}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative px-1">
|
<div className="relative px-1">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
||||||
|
@ -147,10 +219,13 @@ function FeedBet(props: { activityItem: any; feedType: FeedType }) {
|
||||||
{isSelf ? 'You' : isCreator ? contract.creatorName : 'A trader'}
|
{isSelf ? 'You' : isCreator ? contract.creatorName : 'A trader'}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
{bought} {money}
|
{bought} {money}
|
||||||
{!answer && (
|
{!answer && !hideOutcome && (
|
||||||
<MaybeOutcomeLabel outcome={outcome} feedType={feedType} />
|
<>
|
||||||
|
{' '}
|
||||||
|
of <OutcomeLabel outcome={outcome} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Timestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
{canComment && (
|
{canComment && (
|
||||||
// Allow user to comment in an textarea if they are the creator
|
// Allow user to comment in an textarea if they are the creator
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
@ -329,7 +404,7 @@ function TruncatedComment(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedQuestion(props: {
|
export function FeedQuestion(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
showDescription?: boolean
|
showDescription?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
@ -344,7 +419,7 @@ function FeedQuestion(props: {
|
||||||
<>
|
<>
|
||||||
<span className="mx-2">•</span>
|
<span className="mx-2">•</span>
|
||||||
{contract.closeTime > Date.now() ? 'Closes' : 'Closed'}
|
{contract.closeTime > Date.now() ? 'Closes' : 'Closed'}
|
||||||
<Timestamp time={contract.closeTime || 0} />
|
<RelativeTimestamp time={contract.closeTime || 0} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -420,7 +495,7 @@ function FeedDescription(props: { contract: Contract }) {
|
||||||
name={creatorName}
|
name={creatorName}
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
created this market <Timestamp time={contract.createdTime} />
|
created this market <RelativeTimestamp time={contract.createdTime} />
|
||||||
</div>
|
</div>
|
||||||
<ContractDescription contract={contract} isCreator={isCreator} />
|
<ContractDescription contract={contract} isCreator={isCreator} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -428,10 +503,8 @@ function FeedDescription(props: { contract: Contract }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedAnswer(props: { contract: Contract; outcome: string }) {
|
function FeedCreateAnswer(props: { contract: Contract; answer: Answer }) {
|
||||||
const { contract, outcome } = props
|
const { contract, answer } = props
|
||||||
const answer = contract?.answers?.[Number(outcome) - 1]
|
|
||||||
if (!answer) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -443,8 +516,7 @@ function FeedAnswer(props: { contract: Contract; outcome: string }) {
|
||||||
name={answer.name}
|
name={answer.name}
|
||||||
username={answer.username}
|
username={answer.username}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
submitted answer <OutcomeLabel outcome={outcome} />{' '}
|
submitted this answer <RelativeTimestamp time={answer.createdTime} />
|
||||||
<Timestamp time={contract.createdTime} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -487,7 +559,7 @@ function FeedResolve(props: { contract: Contract }) {
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
resolved this market to <OutcomeLabel outcome={resolution} />{' '}
|
resolved this market to <OutcomeLabel outcome={resolution} />{' '}
|
||||||
<Timestamp time={contract.resolutionTime || 0} />
|
<RelativeTimestamp time={contract.resolutionTime || 0} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -512,111 +584,15 @@ function FeedClose(props: { contract: Contract }) {
|
||||||
<div className="min-w-0 flex-1 py-1.5">
|
<div className="min-w-0 flex-1 py-1.5">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
Trading closed in this market{' '}
|
Trading closed in this market{' '}
|
||||||
<Timestamp time={contract.closeTime || 0} />
|
<RelativeTimestamp time={contract.closeTime || 0} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFeedBet(bet: Bet, contract: Contract) {
|
function BetGroupSpan(props: { bets: Bet[]; outcome?: string }) {
|
||||||
return {
|
const { bets, outcome } = props
|
||||||
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
|
|
||||||
|
|
||||||
const numberTraders = _.uniqBy(bets, (b) => b.userId).length
|
const numberTraders = _.uniqBy(bets, (b) => b.userId).length
|
||||||
|
|
||||||
|
@ -631,15 +607,22 @@ function BetGroupSpan(props: {
|
||||||
{buyTotal > 0 && <>bought {formatMoney(buyTotal)} </>}
|
{buyTotal > 0 && <>bought {formatMoney(buyTotal)} </>}
|
||||||
{sellTotal > 0 && <>sold {formatMoney(sellTotal)} </>}
|
{sellTotal > 0 && <>sold {formatMoney(sellTotal)} </>}
|
||||||
</JoinSpans>
|
</JoinSpans>
|
||||||
<MaybeOutcomeLabel outcome={outcome} feedType={feedType} />{' '}
|
{outcome && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
of <OutcomeLabel outcome={outcome} />
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Make this expandable to show all grouped bets?
|
function FeedBetGroup(props: {
|
||||||
function FeedBetGroup(props: { activityItem: any; feedType: FeedType }) {
|
contract: Contract
|
||||||
const { activityItem, feedType } = props
|
bets: Bet[]
|
||||||
const bets: Bet[] = activityItem.bets
|
hideOutcome: boolean
|
||||||
|
}) {
|
||||||
|
const { bets, hideOutcome } = props
|
||||||
|
|
||||||
const betGroups = _.groupBy(bets, (bet) => bet.outcome)
|
const betGroups = _.groupBy(bets, (bet) => bet.outcome)
|
||||||
const outcomes = Object.keys(betGroups)
|
const outcomes = Object.keys(betGroups)
|
||||||
|
@ -661,20 +644,66 @@ function FeedBetGroup(props: { activityItem: any; feedType: FeedType }) {
|
||||||
{outcomes.map((outcome, index) => (
|
{outcomes.map((outcome, index) => (
|
||||||
<Fragment key={outcome}>
|
<Fragment key={outcome}>
|
||||||
<BetGroupSpan
|
<BetGroupSpan
|
||||||
outcome={outcome}
|
outcome={hideOutcome ? undefined : outcome}
|
||||||
bets={betGroups[outcome]}
|
bets={betGroups[outcome]}
|
||||||
feedType={feedType}
|
|
||||||
/>
|
/>
|
||||||
{index !== outcomes.length - 1 && <br />}
|
{index !== outcomes.length - 1 && <br />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
<Timestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FeedAnswerGroup(props: {
|
||||||
|
contract: Contract
|
||||||
|
answer: Answer
|
||||||
|
items: ActivityItem[]
|
||||||
|
}) {
|
||||||
|
const { answer, items } = props
|
||||||
|
const { username, avatarUrl, userId, name, text } = answer
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className="gap-2 flex-1">
|
||||||
|
<Row className="gap-3 mb-4">
|
||||||
|
<div className="px-1">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
||||||
|
<Avatar username={username} avatarUrl={avatarUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Col className="min-w-0 flex-1 gap-2">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<UserLink username={userId} name={name} /> answered
|
||||||
|
</div>
|
||||||
|
<Linkify text={text} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={clsx(
|
||||||
|
'relative ml-8',
|
||||||
|
index !== items.length - 1 && 'pb-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{index !== items.length - 1 ? (
|
||||||
|
<span
|
||||||
|
className="absolute top-5 left-5 -ml-px h-[calc(100%-1rem)] w-0.5 bg-gray-200"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
<FeedItem item={item} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Should highlight the entire Feed segment
|
// TODO: Should highlight the entire Feed segment
|
||||||
function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) {
|
function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) {
|
||||||
const { setExpanded } = props
|
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 : (
|
|
||||||
<span>
|
|
||||||
{' '}
|
|
||||||
of <OutcomeLabel outcome={outcome} />
|
|
||||||
{/* TODO: Link to the correct e.g. #23 */}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<div className="flow-root pr-2 md:pr-0">
|
|
||||||
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
|
|
||||||
{items.map((activityItem, activityItemIdx) => (
|
|
||||||
<div key={activityItem.id} className="relative pb-6">
|
|
||||||
{activityItemIdx !== items.length - 1 ? (
|
|
||||||
<span
|
|
||||||
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="relative flex items-start space-x-3">
|
|
||||||
{activityItem.type === 'start' ? (
|
|
||||||
feedType === 'activity' ? (
|
|
||||||
<FeedQuestion contract={contract} />
|
|
||||||
) : feedType === 'market' ? (
|
|
||||||
<FeedDescription contract={contract} />
|
|
||||||
) : feedType === 'multi' ? (
|
|
||||||
<FeedAnswer contract={contract} outcome={outcome || '0'} />
|
|
||||||
) : null
|
|
||||||
) : activityItem.type === 'comment' ? (
|
|
||||||
<FeedComment
|
|
||||||
activityItem={activityItem}
|
|
||||||
moreHref={contractPath(contract)}
|
|
||||||
feedType={feedType}
|
|
||||||
/>
|
|
||||||
) : activityItem.type === 'bet' ? (
|
|
||||||
<FeedBet activityItem={activityItem} feedType={feedType} />
|
|
||||||
) : activityItem.type === 'betgroup' ? (
|
|
||||||
<FeedBetGroup activityItem={activityItem} feedType={feedType} />
|
|
||||||
) : activityItem.type === 'close' ? (
|
|
||||||
<FeedClose contract={contract} />
|
|
||||||
) : activityItem.type === 'resolve' ? (
|
|
||||||
<FeedResolve contract={contract} />
|
|
||||||
) : activityItem.type === 'expand' ? (
|
|
||||||
<FeedExpand setExpanded={setExpanded} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{isBinary && tradingAllowed(contract) && (
|
|
||||||
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<FeedItems
|
|
||||||
contract={contract}
|
|
||||||
items={items}
|
|
||||||
feedType={feedType}
|
|
||||||
setExpanded={setExpanded}
|
|
||||||
betRowClassName={betRowClassName}
|
|
||||||
outcome={outcome}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<FeedItems
|
|
||||||
contract={contract}
|
|
||||||
items={items}
|
|
||||||
feedType="activity"
|
|
||||||
setExpanded={() => {}}
|
|
||||||
betRowClassName={betRowClassName}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContractSummaryFeed(props: {
|
|
||||||
contract: Contract
|
|
||||||
betRowClassName?: string
|
|
||||||
}) {
|
|
||||||
const { contract, betRowClassName } = props
|
|
||||||
const { outcomeType } = contract
|
|
||||||
const isBinary = outcomeType === 'BINARY'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flow-root pr-2 md:pr-0">
|
|
||||||
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}>
|
|
||||||
<div className="relative pb-8">
|
|
||||||
<div className="relative flex items-start space-x-3">
|
|
||||||
<FeedQuestion contract={contract} showDescription />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isBinary && tradingAllowed(contract) && (
|
|
||||||
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
68
web/components/feed/find-active-contracts.ts
Normal file
68
web/components/feed/find-active-contracts.ts
Normal file
|
@ -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<string, number>()
|
||||||
|
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)
|
||||||
|
}
|
|
@ -4,11 +4,11 @@ import { useMemo, useRef } from 'react'
|
||||||
import { Fold } from '../../common/fold'
|
import { Fold } from '../../common/fold'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { filterDefined } from '../../common/util/array'
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
import { findActiveContracts } from '../components/feed/find-active-contracts'
|
||||||
import { Bet } from '../lib/firebase/bets'
|
import { Bet } from '../lib/firebase/bets'
|
||||||
import { Comment, getRecentComments } from '../lib/firebase/comments'
|
import { Comment, getRecentComments } from '../lib/firebase/comments'
|
||||||
import { Contract, getActiveContracts } from '../lib/firebase/contracts'
|
import { Contract, getActiveContracts } from '../lib/firebase/contracts'
|
||||||
import { listAllFolds } from '../lib/firebase/folds'
|
import { listAllFolds } from '../lib/firebase/folds'
|
||||||
import { findActiveContracts } from '../components/activity-feed'
|
|
||||||
import { useInactiveContracts } from './use-contracts'
|
import { useInactiveContracts } from './use-contracts'
|
||||||
import { useFollowedFolds } from './use-fold'
|
import { useFollowedFolds } from './use-fold'
|
||||||
import { useUserBetContracts } from './use-user-bets'
|
import { useUserBetContracts } from './use-user-bets'
|
||||||
|
|
|
@ -40,6 +40,10 @@ export async function createComment(
|
||||||
return await setDoc(ref, comment)
|
return await setDoc(ref, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const canAddComment = (createdTime: number, isSelf: boolean) => {
|
||||||
|
return isSelf && Date.now() - createdTime < 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
function getCommentsCollection(contractId: string) {
|
function getCommentsCollection(contractId: string) {
|
||||||
return collection(db, 'contracts', contractId, 'comments')
|
return collection(db, 'contracts', contractId, 'comments')
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,10 +12,7 @@ import {
|
||||||
getFoldBySlug,
|
getFoldBySlug,
|
||||||
getFoldContracts,
|
getFoldContracts,
|
||||||
} from '../../../lib/firebase/folds'
|
} from '../../../lib/firebase/folds'
|
||||||
import {
|
import { ActivityFeed } from '../../../components/feed/activity-feed'
|
||||||
ActivityFeed,
|
|
||||||
findActiveContracts,
|
|
||||||
} from '../../../components/activity-feed'
|
|
||||||
import { TagsList } from '../../../components/tags-list'
|
import { TagsList } from '../../../components/tags-list'
|
||||||
import { Row } from '../../../components/layout/row'
|
import { Row } from '../../../components/layout/row'
|
||||||
import { UserLink } from '../../../components/user-page'
|
import { UserLink } from '../../../components/user-page'
|
||||||
|
@ -43,6 +40,7 @@ import { filterDefined } from '../../../../common/util/array'
|
||||||
import { useRecentBets } from '../../../hooks/use-bets'
|
import { useRecentBets } from '../../../hooks/use-bets'
|
||||||
import { useRecentComments } from '../../../hooks/use-comments'
|
import { useRecentComments } from '../../../hooks/use-comments'
|
||||||
import { LoadingIndicator } from '../../../components/loading-indicator'
|
import { LoadingIndicator } from '../../../components/loading-indicator'
|
||||||
|
import { findActiveContracts } from '../../../components/feed/find-active-contracts'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
|
@ -248,7 +246,7 @@ export default function FoldPage(props: {
|
||||||
contracts={activeContracts}
|
contracts={activeContracts}
|
||||||
recentBets={recentBets ?? []}
|
recentBets={recentBets ?? []}
|
||||||
recentComments={recentComments ?? []}
|
recentComments={recentComments ?? []}
|
||||||
loadBetAndCommentHistory
|
mode="abbreviated"
|
||||||
/>
|
/>
|
||||||
{activeContracts.length === 0 && (
|
{activeContracts.length === 0 && (
|
||||||
<div className="mx-2 mt-4 text-gray-500 lg:mx-0">
|
<div className="mx-2 mt-4 text-gray-500 lg:mx-0">
|
||||||
|
|
|
@ -6,7 +6,10 @@ import _ from 'lodash'
|
||||||
|
|
||||||
import { Contract } from '../lib/firebase/contracts'
|
import { Contract } from '../lib/firebase/contracts'
|
||||||
import { Page } from '../components/page'
|
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 { Comment } from '../lib/firebase/comments'
|
||||||
import FeedCreate from '../components/feed-create'
|
import FeedCreate from '../components/feed-create'
|
||||||
import { Spacer } from '../components/layout/spacer'
|
import { Spacer } from '../components/layout/spacer'
|
||||||
|
@ -128,6 +131,7 @@ const Home = (props: {
|
||||||
contracts={activeContracts}
|
contracts={activeContracts}
|
||||||
recentBets={recentBets}
|
recentBets={recentBets}
|
||||||
recentComments={recentComments}
|
recentComments={recentComments}
|
||||||
|
mode="only-recent"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LoadingIndicator className="mt-4" />
|
<LoadingIndicator className="mt-4" />
|
||||||
|
|
Loading…
Reference in New Issue
Block a user