diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx
new file mode 100644
index 00000000..49671d4c
--- /dev/null
+++ b/web/components/contract-feed.tsx
@@ -0,0 +1,472 @@
+// From https://tailwindui.com/components/application-ui/lists/feeds
+import { useState } from 'react'
+import {
+ BanIcon,
+ ChatAltIcon,
+ CheckIcon,
+ LockClosedIcon,
+ StarIcon,
+ ThumbDownIcon,
+ ThumbUpIcon,
+ UserIcon,
+ UsersIcon,
+ XIcon,
+} from '@heroicons/react/solid'
+import { useBets } from '../hooks/use-bets'
+import { Bet } from '../lib/firebase/bets'
+import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+import { OutcomeLabel } from './outcome-label'
+import { Contract, setContract } from '../lib/firebase/contracts'
+import { useUser } from '../hooks/use-user'
+import { Linkify } from './linkify'
+import { Row } from './layout/row'
+import { createComment } from '../lib/firebase/comments'
+import { useComments } from '../hooks/use-comments'
+dayjs.extend(relativeTime)
+
+function FeedComment(props: { activityItem: any }) {
+ const { activityItem } = props
+ const { person, text, amount, outcome, createdTime } = activityItem
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+function Timestamp(props: { time: number }) {
+ const { time } = props
+ return (
+
+ {dayjs(time).fromNow()}
+
+ )
+}
+
+function FeedBet(props: { activityItem: any }) {
+ const { activityItem } = props
+ const { id, contractId, amount, outcome, createdTime } = activityItem
+ const user = useUser()
+ const isCreator = user?.id == activityItem.userId
+
+ const [comment, setComment] = useState('')
+ async function submitComment() {
+ if (!user || !comment) return
+ await createComment(contractId, id, comment, user)
+ }
+ return (
+ <>
+
+
+
+
+ {isCreator ? 'You' : 'A trader'}
+ {' '}
+ placed M$ {amount} on
{' '}
+
+ {isCreator && (
+ // Allow user to comment in an textarea if they are the creator
+
+
+ )}
+
+
+ >
+ )
+}
+
+export function ContractDescription(props: {
+ contract: Contract
+ isCreator: boolean
+}) {
+ const { contract, isCreator } = props
+ const [editing, setEditing] = useState(false)
+ const editStatement = () => `${dayjs().format('MMM D, h:mma')}: `
+ const [description, setDescription] = useState(editStatement())
+
+ // Append the new description (after a newline)
+ async function saveDescription(e: any) {
+ e.preventDefault()
+ setEditing(false)
+ contract.description = `${contract.description}\n${description}`.trim()
+ await setContract(contract)
+ setDescription(editStatement())
+ }
+
+ return (
+
+
+
+ {isCreator &&
+ !contract.resolution &&
+ (editing ? (
+
+ ) : (
+
+ setEditing(true)}
+ >
+ Add to description
+
+
+ ))}
+
+ )
+}
+
+function FeedStart(props: { contract: Contract }) {
+ const { contract } = props
+ const user = useUser()
+ const isCreator = user?.id === contract.creatorId
+
+ return (
+ <>
+
+
+
+ {contract.creatorName} created
+ this market
+
+
+
+ >
+ )
+}
+
+function OutcomeIcon(props: { outcome?: 'YES' | 'NO' | 'CANCEL' }) {
+ const { outcome } = props
+ switch (outcome) {
+ case 'YES':
+ return
+ case 'NO':
+ return
+ case 'CANCEL':
+ default:
+ return
+ }
+}
+
+function FeedResolve(props: { contract: Contract }) {
+ const { contract } = props
+ const resolution = contract.resolution || 'CANCEL'
+
+ return (
+ <>
+
+
+
+ {contract.creatorName} resolved
+ this market to {' '}
+
+
+
+ >
+ )
+}
+
+function FeedClose(props: { contract: Contract }) {
+ const { contract } = props
+
+ return (
+ <>
+
+
+
+ Trading closed in this market{' '}
+
+
+
+ >
+ )
+}
+
+function toFeedBet(bet: Bet) {
+ return {
+ id: bet.id,
+ contractId: bet.contractId,
+ userId: bet.userId,
+ type: 'bet',
+ amount: bet.amount,
+ outcome: bet.outcome,
+ createdTime: bet.createdTime,
+ date: dayjs(bet.createdTime).fromNow(),
+ }
+}
+
+function toFeedComment(bet: Bet, comment: Comment) {
+ return {
+ id: bet.id,
+ contractId: bet.contractId,
+ userId: bet.userId,
+ type: 'comment',
+ amount: bet.amount,
+ outcome: bet.outcome,
+ createdTime: bet.createdTime,
+ date: dayjs(bet.createdTime).fromNow(),
+
+ // Invariant: bet.comment exists
+ text: comment.text,
+ person: {
+ href: `/${comment.userUsername}`,
+ name: comment.userName,
+ avatarUrl: comment.userAvatarUrl,
+ },
+ }
+}
+
+// Group together bets that are:
+// - Within 24h of the first in the group
+// - Do not have a comment
+// - Were not created by this user
+// Return a list of ActivityItems
+function group(bets: Bet[], comments: Comment[], userId?: string) {
+ 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]))
+ } else if (group.length > 1) {
+ items.push({ type: 'betgroup', bets: [...group], id: group[0].id })
+ }
+ group = []
+ }
+
+ function toActivityItem(bet: Bet) {
+ const comment = commentsMap[bet.id]
+ return comment ? toFeedComment(bet, comment) : toFeedBet(bet)
+ }
+
+ 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 &&
+ dayjs(bet.createdTime).diff(dayjs(group[0].createdTime), 'hour') > 24
+ ) {
+ // More than 24h has passed; start a new group
+ pushGroup()
+ }
+ group.push(bet)
+ }
+ }
+ if (group.length > 0) {
+ pushGroup()
+ }
+ return items as ActivityItem[]
+}
+
+// TODO: Make this expandable to show all grouped bets?
+function FeedBetGroup(props: { activityItem: any }) {
+ const { activityItem } = props
+ const bets: Bet[] = activityItem.bets
+
+ const yesAmount = bets
+ .filter((b) => b.outcome == 'YES')
+ .reduce((acc, bet) => acc + bet.amount, 0)
+ const yesSpan = yesAmount ? (
+
+ M$ {yesAmount} on
+
+ ) : null
+ const noAmount = bets
+ .filter((b) => b.outcome == 'NO')
+ .reduce((acc, bet) => acc + bet.amount, 0)
+ const noSpan = noAmount ? (
+
+ M$ {noAmount} on
+
+ ) : null
+ const traderCount = bets.length
+ const createdTime = bets[0].createdTime
+
+ return (
+ <>
+
+
+
+ {traderCount} traders placed{' '}
+ {yesSpan}
+ {yesAmount && noAmount ? ' and ' : ''}
+ {noSpan}
+
+
+ >
+ )
+}
+
+// Missing feed items:
+// - Bet sold?
+type ActivityItem = {
+ id: string
+ type: 'bet' | 'comment' | 'start' | 'betgroup' | 'close' | 'resolve'
+}
+
+export function ContractFeed(props: { contract: Contract }) {
+ const { contract } = props
+ const { id } = contract
+ const user = useUser()
+
+ let bets = useBets(id)
+ if (bets === 'loading') bets = []
+
+ let comments = useComments(id)
+ if (comments === 'loading') comments = []
+
+ const allItems = [
+ { type: 'start', id: 0 },
+ ...group(bets, comments, user?.id),
+ ]
+ if (contract.closeTime) {
+ allItems.push({ type: 'close', id: `${contract.closeTime}` })
+ }
+ if (contract.resolution) {
+ allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` })
+ }
+
+ return (
+
+
+ {allItems.map((activityItem, activityItemIdx) => (
+
+
+ {activityItemIdx !== allItems.length - 1 ? (
+
+ ) : null}
+
+ {activityItem.type === 'start' ? (
+
+ ) : activityItem.type === 'comment' ? (
+
+ ) : activityItem.type === 'bet' ? (
+
+ ) : activityItem.type === 'betgroup' ? (
+
+ ) : activityItem.type === 'close' ? (
+
+ ) : activityItem.type === 'resolve' ? (
+
+ ) : null}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx
index 114ba9b9..85dd97df 100644
--- a/web/components/contract-overview.tsx
+++ b/web/components/contract-overview.tsx
@@ -1,10 +1,4 @@
-import { useState } from 'react'
-import {
- compute,
- Contract,
- deleteContract,
- setContract,
-} from '../lib/firebase/contracts'
+import { compute, Contract, deleteContract } from '../lib/firebase/contracts'
import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
import { ContractProbGraph } from './contract-prob-graph'
@@ -15,6 +9,7 @@ import dayjs from 'dayjs'
import { Linkify } from './linkify'
import clsx from 'clsx'
import { ContractDetails, ResolutionOrChance } from './contract-card'
+import { ContractFeed } from './contract-feed'
function ContractCloseTime(props: { contract: Contract }) {
const closeTime = props.contract.closeTime
@@ -29,74 +24,6 @@ function ContractCloseTime(props: { contract: Contract }) {
)
}
-function ContractDescription(props: {
- contract: Contract
- isCreator: boolean
-}) {
- const { contract, isCreator } = props
- const [editing, setEditing] = useState(false)
- const editStatement = () => `${dayjs().format('MMM D, h:mma')}: `
- const [description, setDescription] = useState(editStatement())
-
- // Append the new description (after a newline)
- async function saveDescription(e: any) {
- e.preventDefault()
- setEditing(false)
- contract.description = `${contract.description}\n${description}`.trim()
- await setContract(contract)
- setDescription(editStatement())
- }
-
- return (
-
-
-
- {isCreator &&
- !contract.resolution &&
- (editing ? (
-
- setDescription(e.target.value || '')}
- autoFocus
- onFocus={(e) =>
- // Focus starts at end of description.
- e.target.setSelectionRange(
- description.length,
- description.length
- )
- }
- />
-
- setEditing(false)}
- >
- Cancel
-
-
- Save
-
-
-
- ) : (
-
- setEditing(true)}
- >
- Add to description
-
-
- ))}
-
- )
-}
-
export const ContractOverview = (props: {
contract: Contract
className?: string
@@ -142,14 +69,6 @@ export const ContractOverview = (props: {
-
-
- {((isCreator && !contract.resolution) || contract.description) && (
- Description
- )}
-
-
-
{/* Show a delete button for contracts without any trading */}
{isCreator && truePool === 0 && (
<>
@@ -166,6 +85,10 @@ export const ContractOverview = (props: {
>
)}
+
+
+
+
)
}
diff --git a/web/hooks/use-comments.ts b/web/hooks/use-comments.ts
new file mode 100644
index 00000000..0a9dead3
--- /dev/null
+++ b/web/hooks/use-comments.ts
@@ -0,0 +1,12 @@
+import { useEffect, useState } from 'react'
+import { Comment, listenForComments } from '../lib/firebase/comments'
+
+export const useComments = (contractId: string) => {
+ const [comments, setComments] = useState('loading')
+
+ useEffect(() => {
+ if (contractId) return listenForComments(contractId, setComments)
+ }, [contractId])
+
+ return comments
+}
diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts
index b09d8f8b..49d92e0e 100644
--- a/web/lib/firebase/bets.ts
+++ b/web/lib/firebase/bets.ts
@@ -22,6 +22,7 @@ export type Bet = {
sale?: {
amount: number // amount user makes from sale
betId: string // id of bet being sold
+ // TODO: add sale time?
}
isSold?: boolean // true if this BUY bet has been sold
diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts
new file mode 100644
index 00000000..d46c6ee9
--- /dev/null
+++ b/web/lib/firebase/comments.ts
@@ -0,0 +1,60 @@
+import { doc, collection, onSnapshot, setDoc } from 'firebase/firestore'
+import { db } from './init'
+import { User } from './users'
+
+// Currently, comments are created after the bet, not atomically with the bet.
+// They're uniquely identified by the pair contractId/betId.
+export type Comment = {
+ contractId: string
+ betId: string
+ text: string
+ createdTime: number
+ // Denormalized, for rendering comments
+ userName?: string
+ userUsername?: string
+ userAvatarUrl?: string
+}
+
+export async function createComment(
+ contractId: string,
+ betId: string,
+ text: string,
+ commenter: User
+) {
+ const ref = doc(getCommentsCollection(contractId), betId)
+ return await setDoc(ref, {
+ contractId,
+ betId,
+ text,
+ createdTime: Date.now(),
+ userName: commenter.name,
+ userUsername: commenter.username,
+ userAvatarUrl: commenter.avatarUrl,
+ })
+}
+
+function getCommentsCollection(contractId: string) {
+ return collection(db, 'contracts', contractId, 'comments')
+}
+
+export function listenForComments(
+ contractId: string,
+ setComments: (comments: Comment[]) => void
+) {
+ return onSnapshot(getCommentsCollection(contractId), (snap) => {
+ const comments = snap.docs.map((doc) => doc.data() as Comment)
+
+ comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
+
+ setComments(comments)
+ })
+}
+
+// Return a map of betId -> comment
+export function mapCommentsByBetId(comments: Comment[]) {
+ const map: Record = {}
+ for (const comment of comments) {
+ map[comment.betId] = comment
+ }
+ return map
+}