Show all recent activity on a central feed (#24)
* Tracks all market activity on a single page * Support both global and per-contract feeds * UI tweaks * Include contract description in activity feed * Show activity feed on Create page
This commit is contained in:
parent
dc7460f209
commit
5b431226d4
|
@ -16,13 +16,21 @@ import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import { OutcomeLabel } from './outcome-label'
|
import { OutcomeLabel } from './outcome-label'
|
||||||
import { Contract, updateContract } from '../lib/firebase/contracts'
|
import {
|
||||||
|
compute,
|
||||||
|
Contract,
|
||||||
|
path,
|
||||||
|
updateContract,
|
||||||
|
} 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 } from '../lib/firebase/comments'
|
import { createComment } from '../lib/firebase/comments'
|
||||||
import { useComments } from '../hooks/use-comments'
|
import { useComments } from '../hooks/use-comments'
|
||||||
import { formatMoney } from '../lib/util/format'
|
import { formatMoney } from '../lib/util/format'
|
||||||
|
import { ResolutionOrChance } from './contract-card'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { SiteLink } from './site-link'
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
function FeedComment(props: { activityItem: any }) {
|
function FeedComment(props: { activityItem: any }) {
|
||||||
|
@ -95,10 +103,8 @@ function FeedBet(props: { activityItem: any }) {
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<span className="text-gray-900">
|
<span>{isCreator ? 'You' : 'A trader'}</span> placed{' '}
|
||||||
{isCreator ? 'You' : 'A trader'}
|
{formatMoney(amount)} on <OutcomeLabel outcome={outcome} />{' '}
|
||||||
</span>{' '}
|
|
||||||
placed {formatMoney(amount)} on <OutcomeLabel outcome={outcome} />{' '}
|
|
||||||
<Timestamp time={createdTime} />
|
<Timestamp time={createdTime} />
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
// Allow user to comment in an textarea if they are the creator
|
// Allow user to comment in an textarea if they are the creator
|
||||||
|
@ -193,7 +199,41 @@ export function ContractDescription(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedStart(props: { contract: Contract }) {
|
function FeedQuestion(props: { contract: Contract }) {
|
||||||
|
const { contract } = props
|
||||||
|
const { probPercent } = compute(contract)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="relative px-1">
|
||||||
|
<div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center">
|
||||||
|
<StarIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 py-1.5">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<span className="text-gray-900">{contract.creatorName}</span> asked{' '}
|
||||||
|
<Timestamp time={contract.createdTime} />
|
||||||
|
</div>
|
||||||
|
<Row className="justify-between gap-4 mb-2">
|
||||||
|
<SiteLink href={path(contract)} className="text-xl text-indigo-700">
|
||||||
|
{contract.question}
|
||||||
|
</SiteLink>
|
||||||
|
<ResolutionOrChance
|
||||||
|
className="items-center"
|
||||||
|
resolution={contract.resolution}
|
||||||
|
probPercent={probPercent}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<ContractDescription contract={contract} isCreator={false} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedDescription(props: { contract: Contract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isCreator = user?.id === contract.creatorId
|
const isCreator = user?.id === contract.creatorId
|
||||||
|
@ -314,12 +354,19 @@ function toFeedComment(bet: Bet, comment: Comment) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
// Group together bets that are:
|
// Group together bets that are:
|
||||||
// - Within 24h of the first in the group
|
// - Within `windowMs` of the first in the group
|
||||||
// - Do not have a comment
|
// - Do not have a comment
|
||||||
// - Were not created by this user
|
// - Were not created by this user
|
||||||
// Return a list of ActivityItems
|
// Return a list of ActivityItems
|
||||||
function group(bets: Bet[], comments: Comment[], userId?: string) {
|
function groupBets(
|
||||||
|
bets: Bet[],
|
||||||
|
comments: Comment[],
|
||||||
|
windowMs: number,
|
||||||
|
userId?: string
|
||||||
|
) {
|
||||||
const commentsMap = mapCommentsByBetId(comments)
|
const commentsMap = mapCommentsByBetId(comments)
|
||||||
const items: any[] = []
|
const items: any[] = []
|
||||||
let group: Bet[] = []
|
let group: Bet[] = []
|
||||||
|
@ -349,9 +396,9 @@ function group(bets: Bet[], comments: Comment[], userId?: string) {
|
||||||
} else {
|
} else {
|
||||||
if (
|
if (
|
||||||
group.length > 0 &&
|
group.length > 0 &&
|
||||||
dayjs(bet.createdTime).diff(dayjs(group[0].createdTime), 'hour') > 24
|
bet.createdTime - group[0].createdTime > windowMs
|
||||||
) {
|
) {
|
||||||
// More than 24h has passed; start a new group
|
// More than `windowMs` has passed; start a new group
|
||||||
pushGroup()
|
pushGroup()
|
||||||
}
|
}
|
||||||
group.push(bet)
|
group.push(bet)
|
||||||
|
@ -398,8 +445,7 @@ function FeedBetGroup(props: { activityItem: any }) {
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<span className="text-gray-900">{traderCount} traders</span> placed{' '}
|
<span>{traderCount} traders</span> placed {yesSpan}
|
||||||
{yesSpan}
|
|
||||||
{yesAmount && noAmount ? ' and ' : ''}
|
{yesAmount && noAmount ? ' and ' : ''}
|
||||||
{noSpan} <Timestamp time={createdTime} />
|
{noSpan} <Timestamp time={createdTime} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -415,8 +461,12 @@ type ActivityItem = {
|
||||||
type: 'bet' | 'comment' | 'start' | 'betgroup' | 'close' | 'resolve'
|
type: 'bet' | 'comment' | 'start' | 'betgroup' | 'close' | 'resolve'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContractFeed(props: { contract: Contract }) {
|
export function ContractFeed(props: {
|
||||||
const { contract } = props
|
contract: Contract
|
||||||
|
// Feed types: 'activity' = Activity feed, 'market' = Comments feed on a market
|
||||||
|
feedType: 'activity' | 'market'
|
||||||
|
}) {
|
||||||
|
const { contract, feedType } = props
|
||||||
const { id } = contract
|
const { id } = contract
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
|
||||||
|
@ -426,9 +476,11 @@ export function ContractFeed(props: { contract: Contract }) {
|
||||||
let comments = useComments(id)
|
let comments = useComments(id)
|
||||||
if (comments === 'loading') comments = []
|
if (comments === 'loading') comments = []
|
||||||
|
|
||||||
|
const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS
|
||||||
|
|
||||||
const allItems = [
|
const allItems = [
|
||||||
{ type: 'start', id: 0 },
|
{ type: 'start', id: 0 },
|
||||||
...group(bets, comments, user?.id),
|
...groupBets(bets, comments, groupWindow, user?.id),
|
||||||
]
|
]
|
||||||
if (contract.closeTime && contract.closeTime <= Date.now()) {
|
if (contract.closeTime && contract.closeTime <= Date.now()) {
|
||||||
allItems.push({ type: 'close', id: `${contract.closeTime}` })
|
allItems.push({ type: 'close', id: `${contract.closeTime}` })
|
||||||
|
@ -451,7 +503,11 @@ export function ContractFeed(props: { contract: Contract }) {
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative flex items-start space-x-3">
|
<div className="relative flex items-start space-x-3">
|
||||||
{activityItem.type === 'start' ? (
|
{activityItem.type === 'start' ? (
|
||||||
<FeedStart contract={contract} />
|
feedType == 'activity' ? (
|
||||||
|
<FeedQuestion contract={contract} />
|
||||||
|
) : (
|
||||||
|
<FeedDescription contract={contract} />
|
||||||
|
)
|
||||||
) : activityItem.type === 'comment' ? (
|
) : activityItem.type === 'comment' ? (
|
||||||
<FeedComment activityItem={activityItem} />
|
<FeedComment activityItem={activityItem} />
|
||||||
) : activityItem.type === 'bet' ? (
|
) : activityItem.type === 'bet' ? (
|
||||||
|
|
|
@ -91,7 +91,7 @@ export const ContractOverview = (props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContractFeed contract={contract} />
|
<ContractFeed contract={contract} feedType="market" />
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Comment, listenForComments } from '../lib/firebase/comments'
|
import {
|
||||||
|
Comment,
|
||||||
|
listenForComments,
|
||||||
|
listenForRecentComments,
|
||||||
|
} from '../lib/firebase/comments'
|
||||||
|
|
||||||
export const useComments = (contractId: string) => {
|
export const useComments = (contractId: string) => {
|
||||||
const [comments, setComments] = useState<Comment[] | 'loading'>('loading')
|
const [comments, setComments] = useState<Comment[] | 'loading'>('loading')
|
||||||
|
@ -10,3 +14,9 @@ export const useComments = (contractId: string) => {
|
||||||
|
|
||||||
return comments
|
return comments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useRecentComments = () => {
|
||||||
|
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
|
||||||
|
useEffect(() => listenForRecentComments(setRecentComments), [])
|
||||||
|
return recentComments
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,15 @@
|
||||||
import { doc, collection, onSnapshot, setDoc } from 'firebase/firestore'
|
import {
|
||||||
|
doc,
|
||||||
|
collection,
|
||||||
|
onSnapshot,
|
||||||
|
setDoc,
|
||||||
|
query,
|
||||||
|
collectionGroup,
|
||||||
|
getDocs,
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
} from 'firebase/firestore'
|
||||||
|
import { listenForValues } from './utils'
|
||||||
import { db } from './init'
|
import { db } from './init'
|
||||||
import { User } from '../../../common/user'
|
import { User } from '../../../common/user'
|
||||||
import { Comment } from '../../../common/comment'
|
import { Comment } from '../../../common/comment'
|
||||||
|
@ -48,3 +58,24 @@ export function mapCommentsByBetId(comments: Comment[]) {
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// Define "recent" as "<3 days ago" for now
|
||||||
|
const recentCommentsQuery = query(
|
||||||
|
collectionGroup(db, 'comments'),
|
||||||
|
where('createdTime', '>', Date.now() - 3 * DAY_IN_MS),
|
||||||
|
orderBy('createdTime', 'desc')
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function getRecentComments() {
|
||||||
|
const snapshot = await getDocs(recentCommentsQuery)
|
||||||
|
const comments = snapshot.docs.map((doc) => doc.data() as Comment)
|
||||||
|
return comments
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenForRecentComments(
|
||||||
|
setComments: (comments: Comment[]) => void
|
||||||
|
) {
|
||||||
|
return listenForValues<Comment>(recentCommentsQuery, setComments)
|
||||||
|
}
|
||||||
|
|
99
web/pages/activity.tsx
Normal file
99
web/pages/activity.tsx
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { ContractFeed } from '../components/contract-feed'
|
||||||
|
import { Row } from '../components/layout/row'
|
||||||
|
import { Page } from '../components/page'
|
||||||
|
import { Title } from '../components/title'
|
||||||
|
import { useRecentComments } from '../hooks/use-comments'
|
||||||
|
import { useContracts } from '../hooks/use-contracts'
|
||||||
|
import { Contract } from '../lib/firebase/contracts'
|
||||||
|
import { Comment } from '../lib/firebase/comments'
|
||||||
|
|
||||||
|
function FeedCard(props: { contract: Contract }) {
|
||||||
|
const { contract } = props
|
||||||
|
return (
|
||||||
|
<div className="card bg-white shadow-md rounded-lg divide-y divide-gray-200 py-6 px-4 mb-4">
|
||||||
|
<ContractFeed contract={contract} feedType="activity" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
function findActiveContracts(
|
||||||
|
allContracts: Contract[],
|
||||||
|
recentComments: Comment[]
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
let contracts: Contract[] = []
|
||||||
|
|
||||||
|
// Find contracts with activity in the last 3 days
|
||||||
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||||
|
for (const contract of allContracts || []) {
|
||||||
|
if (lastActivityTime(contract) > Date.now() - 3 * DAY_IN_MS) {
|
||||||
|
contracts.push(contract)
|
||||||
|
record(contract.id, lastActivityTime(contract))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add every contract that had a recent comment, too
|
||||||
|
const contractsById = new Map(allContracts.map((c) => [c.id, c]))
|
||||||
|
for (const comment of recentComments) {
|
||||||
|
const contract = contractsById.get(comment.contractId)
|
||||||
|
if (contract) {
|
||||||
|
contracts.push(contract)
|
||||||
|
record(contract.id, comment.createdTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contracts = _.uniqBy(contracts, (c) => c.id)
|
||||||
|
contracts = _.sortBy(contracts, (c) => -(idToActivityTime.get(c.id) ?? 0))
|
||||||
|
return contracts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeed() {
|
||||||
|
const contracts = useContracts() || []
|
||||||
|
const recentComments = useRecentComments() || []
|
||||||
|
// TODO: Handle static props correctly?
|
||||||
|
const activeContracts = findActiveContracts(contracts, recentComments)
|
||||||
|
return contracts ? (
|
||||||
|
<>
|
||||||
|
<Title text="Recent Activity" />
|
||||||
|
<Row className="gap-4">
|
||||||
|
<div>
|
||||||
|
{activeContracts.map((contract) => (
|
||||||
|
<FeedCard contract={contract} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityPage() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<ActivityFeed />
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import { AdvancedPanel } from '../components/advanced-panel'
|
||||||
import { createContract } from '../lib/firebase/api-call'
|
import { createContract } from '../lib/firebase/api-call'
|
||||||
import { Row } from '../components/layout/row'
|
import { Row } from '../components/layout/row'
|
||||||
import { AmountInput } from '../components/amount-input'
|
import { AmountInput } from '../components/amount-input'
|
||||||
|
import { ActivityFeed } from './activity'
|
||||||
|
|
||||||
// Allow user to create a new contract
|
// Allow user to create a new contract
|
||||||
export default function NewContract() {
|
export default function NewContract() {
|
||||||
|
@ -210,11 +211,9 @@ export default function NewContract() {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spacer h={10} />
|
<Spacer h={6} />
|
||||||
|
|
||||||
<Title text="Your markets" />
|
<ActivityFeed />
|
||||||
|
|
||||||
{creator && <CreatorContractsList creator={creator} />}
|
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user