7e37fc776c
* Extend comment input box, only use airplane * Only 1 commentable bet, shrink input, fix feed lines * Pad sign in to comment button * Small changes
549 lines
13 KiB
TypeScript
549 lines
13 KiB
TypeScript
import { last, findLastIndex, uniq, sortBy } from 'lodash'
|
|
|
|
import { Answer } from 'common/answer'
|
|
import { Bet } from 'common/bet'
|
|
import { getOutcomeProbability } from 'common/calculate'
|
|
import { Comment } from 'common/comment'
|
|
import { Contract, FreeResponseContract } from 'common/contract'
|
|
import { User } from 'common/user'
|
|
import { mapCommentsByBetId } from 'web/lib/firebase/comments'
|
|
|
|
export type ActivityItem =
|
|
| DescriptionItem
|
|
| QuestionItem
|
|
| BetItem
|
|
| CommentItem
|
|
| BetGroupItem
|
|
| AnswerGroupItem
|
|
| CloseItem
|
|
| ResolveItem
|
|
| CommentInputItem
|
|
| CommentThreadItem
|
|
|
|
type BaseActivityItem = {
|
|
id: string
|
|
contract: Contract
|
|
}
|
|
|
|
export type CommentInputItem = BaseActivityItem & {
|
|
type: 'commentInput'
|
|
betsByCurrentUser: Bet[]
|
|
commentsByCurrentUser: Comment[]
|
|
}
|
|
|
|
export type DescriptionItem = BaseActivityItem & {
|
|
type: 'description'
|
|
}
|
|
|
|
export type QuestionItem = BaseActivityItem & {
|
|
type: 'question'
|
|
showDescription: boolean
|
|
contractPath?: string
|
|
}
|
|
|
|
export type BetItem = BaseActivityItem & {
|
|
type: 'bet'
|
|
bet: Bet
|
|
hideOutcome: boolean
|
|
smallAvatar: boolean
|
|
hideComment?: boolean
|
|
}
|
|
|
|
export type CommentItem = BaseActivityItem & {
|
|
type: 'comment'
|
|
comment: Comment
|
|
betsBySameUser: Bet[]
|
|
probAtCreatedTime?: number
|
|
truncate?: boolean
|
|
smallAvatar?: boolean
|
|
}
|
|
|
|
export type CommentThreadItem = BaseActivityItem & {
|
|
type: 'commentThread'
|
|
parentComment: Comment
|
|
comments: Comment[]
|
|
bets: Bet[]
|
|
}
|
|
|
|
export type BetGroupItem = BaseActivityItem & {
|
|
type: 'betgroup'
|
|
bets: Bet[]
|
|
hideOutcome: boolean
|
|
}
|
|
|
|
export type AnswerGroupItem = BaseActivityItem & {
|
|
type: 'answergroup'
|
|
user: User | undefined | null
|
|
answer: Answer
|
|
comments: Comment[]
|
|
bets: Bet[]
|
|
}
|
|
|
|
export type CloseItem = BaseActivityItem & {
|
|
type: 'close'
|
|
}
|
|
|
|
export type ResolveItem = BaseActivityItem & {
|
|
type: 'resolve'
|
|
}
|
|
|
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
|
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
|
|
|
|
// Group together bets that are:
|
|
// - Within a day of the first in the group
|
|
// (Unless the bets are older: then are grouped by 7-days.)
|
|
// - Do not have a comment
|
|
// - Were not created by this user
|
|
// Return a list of ActivityItems
|
|
function groupBets(
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
contract: Contract,
|
|
userId: string | undefined,
|
|
options: {
|
|
hideOutcome: boolean
|
|
abbreviated: boolean
|
|
smallAvatar: boolean
|
|
reversed: boolean
|
|
}
|
|
) {
|
|
const { hideOutcome, abbreviated, smallAvatar, reversed } = 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,
|
|
betsBySameUser: [bet],
|
|
contract,
|
|
truncate: abbreviated,
|
|
smallAvatar,
|
|
}
|
|
: {
|
|
type: 'bet' as const,
|
|
id: bet.id,
|
|
bet,
|
|
contract,
|
|
hideOutcome,
|
|
smallAvatar,
|
|
}
|
|
}
|
|
|
|
for (const bet of bets) {
|
|
const isCreator = userId === bet.userId
|
|
|
|
// If first bet in group is older than 3 days, group by 7 days. Otherwise, group by 1 day.
|
|
const windowMs =
|
|
Date.now() - (group[0]?.createdTime ?? bet.createdTime) > DAY_IN_MS * 3
|
|
? DAY_IN_MS * 7
|
|
: DAY_IN_MS
|
|
|
|
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()
|
|
}
|
|
const abbrItems = abbreviated
|
|
? items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
|
|
: items
|
|
if (reversed) abbrItems.reverse()
|
|
return abbrItems
|
|
}
|
|
|
|
function getAnswerGroups(
|
|
contract: FreeResponseContract,
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
user: User | undefined | null,
|
|
options: {
|
|
sortByProb: boolean
|
|
abbreviated: boolean
|
|
reversed: boolean
|
|
}
|
|
) {
|
|
const { sortByProb, abbreviated, reversed } = options
|
|
|
|
let outcomes = uniq(bets.map((bet) => bet.outcome))
|
|
if (abbreviated) {
|
|
const lastComment = last(comments)
|
|
const lastCommentOutcome = bets.find(
|
|
(bet) => bet.id === lastComment?.betId
|
|
)?.outcome
|
|
const lastBetOutcome = last(bets)?.outcome
|
|
if (lastCommentOutcome && lastBetOutcome) {
|
|
outcomes = uniq([
|
|
...outcomes.filter(
|
|
(outcome) =>
|
|
outcome !== lastCommentOutcome && outcome !== lastBetOutcome
|
|
),
|
|
lastCommentOutcome,
|
|
lastBetOutcome,
|
|
])
|
|
}
|
|
outcomes = outcomes.slice(-2)
|
|
}
|
|
if (sortByProb) {
|
|
outcomes = sortBy(outcomes, (outcome) =>
|
|
getOutcomeProbability(contract, outcome)
|
|
)
|
|
} else {
|
|
// Sort by recent bet.
|
|
outcomes = sortBy(outcomes, (outcome) =>
|
|
findLastIndex(bets, (bet) => bet.outcome === outcome)
|
|
)
|
|
}
|
|
|
|
const answerGroups = outcomes
|
|
.map((outcome) => {
|
|
const answer = contract.answers?.find(
|
|
(answer) => answer.id === outcome
|
|
) as Answer
|
|
|
|
// TODO: this doesn't abbreviate these groups for activity feed anymore
|
|
return {
|
|
id: outcome,
|
|
type: 'answergroup' as const,
|
|
contract,
|
|
user,
|
|
answer,
|
|
comments,
|
|
bets,
|
|
}
|
|
})
|
|
.filter((group) => group.answer)
|
|
|
|
if (reversed) answerGroups.reverse()
|
|
|
|
return answerGroups
|
|
}
|
|
|
|
function getAnswerAndCommentInputGroups(
|
|
contract: FreeResponseContract,
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
user: User | undefined | null
|
|
) {
|
|
let outcomes = uniq(bets.map((bet) => bet.outcome))
|
|
outcomes = sortBy(outcomes, (outcome) =>
|
|
getOutcomeProbability(contract, outcome)
|
|
)
|
|
|
|
const answerGroups = outcomes
|
|
.map((outcome) => {
|
|
const answer = contract.answers?.find(
|
|
(answer) => answer.id === outcome
|
|
) as Answer
|
|
|
|
return {
|
|
id: outcome,
|
|
type: 'answergroup' as const,
|
|
contract,
|
|
user,
|
|
answer,
|
|
comments,
|
|
bets,
|
|
}
|
|
})
|
|
.filter((group) => group.answer) as ActivityItem[]
|
|
return answerGroups
|
|
}
|
|
|
|
function groupBetsAndComments(
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
contract: Contract,
|
|
userId: string | undefined,
|
|
options: {
|
|
hideOutcome: boolean
|
|
abbreviated: boolean
|
|
smallAvatar: boolean
|
|
reversed: boolean
|
|
}
|
|
) {
|
|
const { smallAvatar, abbreviated, reversed } = options
|
|
// Comments in feed don't show user's position?
|
|
const commentsWithoutBets = comments
|
|
.filter((comment) => !comment.betId)
|
|
.map((comment) => ({
|
|
type: 'comment' as const,
|
|
id: comment.id,
|
|
contract: contract,
|
|
comment,
|
|
betsBySameUser: [],
|
|
truncate: abbreviated,
|
|
smallAvatar,
|
|
}))
|
|
|
|
const groupedBets = groupBets(bets, comments, contract, userId, options)
|
|
|
|
// iterate through the bets and comment activity items and add them to the items in order of comment creation time:
|
|
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
|
|
const sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => {
|
|
if (item.type === 'comment') {
|
|
return item.comment.createdTime
|
|
} else if (item.type === 'bet') {
|
|
return item.bet.createdTime
|
|
} else if (item.type === 'betgroup') {
|
|
return item.bets[0].createdTime
|
|
}
|
|
})
|
|
|
|
const abbrItems = abbreviated
|
|
? sortedBetsAndComments.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
|
|
: sortedBetsAndComments
|
|
|
|
if (reversed) abbrItems.reverse()
|
|
return abbrItems
|
|
}
|
|
|
|
function getCommentThreads(
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
contract: Contract
|
|
) {
|
|
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
|
|
|
|
const items = parentComments.map((comment) => ({
|
|
type: 'commentThread' as const,
|
|
id: comment.id,
|
|
contract: contract,
|
|
comments: comments,
|
|
parentComment: comment,
|
|
bets: bets,
|
|
}))
|
|
|
|
return items
|
|
}
|
|
|
|
export function getAllContractActivityItems(
|
|
contract: Contract,
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
user: User | null | undefined,
|
|
options: {
|
|
abbreviated: boolean
|
|
}
|
|
) {
|
|
const { abbreviated } = options
|
|
const { outcomeType } = contract
|
|
const reversed = true
|
|
|
|
bets =
|
|
outcomeType === 'BINARY'
|
|
? bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
|
|
: bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0'))
|
|
|
|
const items: ActivityItem[] = abbreviated
|
|
? [
|
|
{
|
|
type: 'question',
|
|
id: '0',
|
|
contract,
|
|
showDescription: false,
|
|
},
|
|
]
|
|
: [{ type: 'description', id: '0', contract }]
|
|
|
|
if (outcomeType === 'FREE_RESPONSE') {
|
|
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
|
|
comments.some(
|
|
(comment) => comment.betId === bet.id || bet.userId === user?.id
|
|
)
|
|
)
|
|
items.push(
|
|
...groupBetsAndComments(
|
|
onlyUsersBetsOrBetsWithComments,
|
|
comments,
|
|
contract,
|
|
user?.id,
|
|
{
|
|
hideOutcome: false,
|
|
abbreviated,
|
|
smallAvatar: false,
|
|
reversed,
|
|
}
|
|
)
|
|
)
|
|
} else {
|
|
items.push(
|
|
...groupBetsAndComments(bets, comments, contract, user?.id, {
|
|
hideOutcome: false,
|
|
abbreviated,
|
|
smallAvatar: false,
|
|
reversed,
|
|
})
|
|
)
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
if (reversed) items.reverse()
|
|
|
|
return items
|
|
}
|
|
|
|
export function getRecentContractActivityItems(
|
|
contract: Contract,
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
user: User | null | undefined,
|
|
options: {
|
|
contractPath?: string
|
|
}
|
|
) {
|
|
const { contractPath } = options
|
|
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,
|
|
contractPath,
|
|
}
|
|
|
|
const items = []
|
|
if (contract.outcomeType === 'FREE_RESPONSE') {
|
|
items.push(
|
|
...getAnswerGroups(contract, bets, comments, user, {
|
|
sortByProb: false,
|
|
abbreviated: true,
|
|
reversed: true,
|
|
})
|
|
)
|
|
} else {
|
|
items.push(
|
|
...groupBetsAndComments(bets, comments, contract, user?.id, {
|
|
hideOutcome: false,
|
|
abbreviated: true,
|
|
smallAvatar: false,
|
|
reversed: true,
|
|
})
|
|
)
|
|
}
|
|
|
|
return [questionItem, ...items]
|
|
}
|
|
|
|
function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
|
return (
|
|
comment.answerOutcome === undefined &&
|
|
(contract.outcomeType === 'FREE_RESPONSE'
|
|
? comment.betId === undefined
|
|
: true)
|
|
)
|
|
}
|
|
|
|
export function getSpecificContractActivityItems(
|
|
contract: Contract,
|
|
bets: Bet[],
|
|
comments: Comment[],
|
|
user: User | null | undefined,
|
|
options: {
|
|
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
|
}
|
|
) {
|
|
const { mode } = options
|
|
const items = [] as ActivityItem[]
|
|
|
|
switch (mode) {
|
|
case 'bets':
|
|
// Remove first bet (which is the ante):
|
|
if (contract.outcomeType === 'FREE_RESPONSE') bets = bets.slice(1)
|
|
items.push(
|
|
...bets.map((bet) => ({
|
|
type: 'bet' as const,
|
|
id: bet.id,
|
|
bet,
|
|
contract,
|
|
hideOutcome: false,
|
|
smallAvatar: false,
|
|
hideComment: true,
|
|
}))
|
|
)
|
|
break
|
|
|
|
case 'comments': {
|
|
const nonFreeResponseComments = comments.filter((comment) =>
|
|
commentIsGeneralComment(comment, contract)
|
|
)
|
|
const nonFreeResponseBets =
|
|
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
|
|
items.push(
|
|
...getCommentThreads(
|
|
nonFreeResponseBets,
|
|
nonFreeResponseComments,
|
|
contract
|
|
)
|
|
)
|
|
|
|
items.push({
|
|
type: 'commentInput',
|
|
id: 'commentInput',
|
|
contract,
|
|
betsByCurrentUser: nonFreeResponseBets.filter(
|
|
(bet) => bet.userId === user?.id
|
|
),
|
|
commentsByCurrentUser: nonFreeResponseComments.filter(
|
|
(comment) => comment.userId === user?.id
|
|
),
|
|
})
|
|
break
|
|
}
|
|
case 'free-response-comment-answer-groups':
|
|
items.push(
|
|
...getAnswerAndCommentInputGroups(
|
|
contract as FreeResponseContract,
|
|
bets,
|
|
comments,
|
|
user
|
|
)
|
|
)
|
|
break
|
|
}
|
|
|
|
return items.reverse()
|
|
}
|