diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 882ed416..49671d4c 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -13,7 +13,8 @@ import { XIcon, } from '@heroicons/react/solid' import { useBets } from '../hooks/use-bets' -import { Bet, createComment } from '../lib/firebase/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' @@ -21,6 +22,8 @@ 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 }) { @@ -289,7 +292,7 @@ function toFeedBet(bet: Bet) { } } -function toComment(bet: Bet) { +function toFeedComment(bet: Bet, comment: Comment) { return { id: bet.id, contractId: bet.contractId, @@ -301,25 +304,22 @@ function toComment(bet: Bet) { date: dayjs(bet.createdTime).fromNow(), // Invariant: bet.comment exists - text: bet.comment!.text, + text: comment.text, person: { - href: `/${bet.comment!.userUsername}`, - name: bet.comment!.userName, - avatarUrl: bet.comment!.userAvatarUrl, + href: `/${comment.userUsername}`, + name: comment.userName, + avatarUrl: comment.userAvatarUrl, }, } } -function toActivityItem(bet: Bet) { - return bet.comment ? toComment(bet) : toFeedBet(bet) -} - // 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[], userId?: string) { +function group(bets: Bet[], comments: Comment[], userId?: string) { + const commentsMap = mapCommentsByBetId(comments) const items: any[] = [] let group: Bet[] = [] @@ -328,15 +328,20 @@ function group(bets: Bet[], userId?: string) { if (group.length == 1) { items.push(toActivityItem(group[0])) } else if (group.length > 1) { - items.push({ type: 'betgroup', bets: [...group] }) + 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 (bet.comment || isCreator) { + if (commentsMap[bet.id] || isCreator) { pushGroup() // Create a single item for this items.push(toActivityItem(bet)) @@ -417,7 +422,13 @@ export function ContractFeed(props: { contract: Contract }) { let bets = useBets(id) if (bets === 'loading') bets = [] - const allItems = [{ type: 'start', id: 0 }, ...group(bets, user?.id)] + 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}` }) } 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 5df0b543..49d92e0e 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -4,11 +4,8 @@ import { query, onSnapshot, where, - doc, - updateDoc, } from 'firebase/firestore' import { db } from './init' -import { User } from './users' export type Bet = { id: string @@ -31,16 +28,6 @@ export type Bet = { isSold?: boolean // true if this BUY bet has been sold createdTime: number - - // Currently, comments are created after the bet, not atomically with the bet. - comment?: { - text: string - createdTime: number - // Denormalized, for rendering comments - userName?: string - userUsername?: string - userAvatarUrl?: string - } } function getBetsCollection(contractId: string) { @@ -75,21 +62,3 @@ export function listenForUserBets( setBets(bets) }) } - -export async function createComment( - contractId: string, - betId: string, - text: string, - commenter: User -) { - const betRef = doc(getBetsCollection(contractId), betId) - return await updateDoc(betRef, { - comment: { - text: text, - createdTime: Date.now(), - userName: commenter.name, - userUsername: commenter.username, - userAvatarUrl: commenter.avatarUrl, - }, - }) -} 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 +}