// From https://tailwindui.com/components/application-ui/lists/feeds import { useState } from 'react' import _ from 'lodash' import { BanIcon, CheckIcon, DotsVerticalIcon, LockClosedIcon, StarIcon, UserIcon, UsersIcon, XIcon, } from '@heroicons/react/solid' import dayjs from 'dayjs' import { OutcomeLabel } from './outcome-label' import { contractMetrics, Contract, contractPath, updateContract, tradingAllowed, } 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' import { formatMoney } from '../lib/util/format' import { ResolutionOrChance } from './contract-card' import { SiteLink } from './site-link' import { Col } from './layout/col' import { UserLink } from './user-page' import { DateTimeTooltip } from './datetime-tooltip' import { useBets } from '../hooks/use-bets' import { Bet, withoutAnteBets } from '../lib/firebase/bets' import { Comment, mapCommentsByBetId } from '../lib/firebase/comments' import { JoinSpans } from './join-spans' import Textarea from 'react-expanding-textarea' import { outcome } from '../../common/contract' import { fromNow } from '../lib/util/time' import BetRow from './bet-row' import clsx from 'clsx' import { parseTags } from '../../common/util/parse' export function AvatarWithIcon(props: { username: string avatarUrl: string noLink?: boolean }) { const { username, avatarUrl, noLink } = props const image = ( <img className="rounded-full bg-gray-400 flex items-center justify-center" src={avatarUrl} width={40} height={40} alt="" /> ) if (noLink) return image return ( <SiteLink className="relative" href={`/${username}`}> {image} </SiteLink> ) } export function AvatarPlaceholder() { return <div className="rounded-full bg-gray-400 w-10 h-10" /> } function FeedComment(props: { activityItem: any moreHref: string feedType: 'activity' | 'market' }) { const { activityItem, moreHref, feedType } = props const { person, text, amount, outcome, createdTime } = activityItem const bought = amount >= 0 ? 'bought' : 'sold' const money = formatMoney(Math.abs(amount)) return ( <> <AvatarWithIcon username={person.username} avatarUrl={person.avatarUrl} /> <div className="min-w-0 flex-1"> <div> <p className="mt-0.5 text-sm text-gray-500"> <UserLink className="text-gray-500" username={person.username} name={person.name} />{' '} {bought} {money} of <OutcomeLabel outcome={outcome} />{' '} <Timestamp time={createdTime} /> </p> </div> <TruncatedComment comment={text} moreHref={moreHref} shouldTruncate={feedType == 'activity'} /> </div> </> ) } function Timestamp(props: { time: number }) { const { time } = props return ( <DateTimeTooltip time={time}> <span className="whitespace-nowrap text-gray-400 ml-1"> {fromNow(time)} </span> </DateTimeTooltip> ) } function FeedBet(props: { activityItem: any }) { const { activityItem } = props const { id, contractId, amount, outcome, createdTime } = activityItem const user = useUser() const isCreator = user?.id == activityItem.userId // The creator can comment if the bet was posted in the last hour const canComment = isCreator && Date.now() - createdTime < 60 * 60 * 1000 const [comment, setComment] = useState('') async function submitComment() { if (!user || !comment) return await createComment(contractId, id, comment, user) } const bought = amount >= 0 ? 'bought' : 'sold' const money = formatMoney(Math.abs(amount)) return ( <> <div> <div className="relative px-1"> <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> <UserIcon 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>{isCreator ? 'You' : 'A trader'}</span> {bought} {money} of{' '} <OutcomeLabel outcome={outcome} /> <Timestamp time={createdTime} /> {canComment && ( // Allow user to comment in an textarea if they are the creator <div className="mt-2"> <Textarea value={comment} onChange={(e) => setComment(e.target.value)} className="textarea textarea-bordered w-full" placeholder="Add a comment..." rows={3} /> <button className="btn btn-outline btn-sm mt-1" onClick={submitComment} > Comment </button> </div> )} </div> </div> </> ) } 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) const newDescription = `${contract.description}\n\n${description}`.trim() const tags = parseTags(`${contract.tags.join(' ')} ${newDescription}`) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) await updateContract(contract.id, { description: newDescription, tags, lowercaseTags, }) setDescription(editStatement()) } if (!isCreator && !contract.description.trim()) return null return ( <div className="whitespace-pre-line break-words mt-2 text-gray-700"> <Linkify text={contract.description} /> <br /> {isCreator && (editing ? ( <form className="mt-4"> <Textarea className="textarea h-24 textarea-bordered w-full mb-1" rows={3} value={description} onChange={(e) => setDescription(e.target.value || '')} autoFocus onFocus={(e) => // Focus starts at end of description. e.target.setSelectionRange( description.length, description.length ) } /> <Row className="gap-2"> <button className="btn btn-neutral btn-outline btn-sm" onClick={saveDescription} > Save </button> <button className="btn btn-error btn-outline btn-sm" onClick={() => setEditing(false)} > Cancel </button> </Row> </form> ) : ( <Row> <button className="btn btn-neutral btn-outline btn-sm mt-4" onClick={() => setEditing(true)} > Add to description </button> </Row> ))} </div> ) } function TruncatedComment(props: { comment: string moreHref: string shouldTruncate?: boolean }) { const { comment, moreHref, shouldTruncate } = props let truncated = comment // Keep descriptions to at most 400 characters const MAX_CHARS = 400 if (shouldTruncate && truncated.length > MAX_CHARS) { truncated = truncated.slice(0, MAX_CHARS) // Make sure to end on a space const i = truncated.lastIndexOf(' ') truncated = truncated.slice(0, i) } return ( <div className="whitespace-pre-line break-words mt-2 text-gray-700"> <Linkify text={truncated} /> {truncated != comment && ( <SiteLink href={moreHref} className="text-indigo-700"> ... (show more) </SiteLink> )} </div> ) } function FeedQuestion(props: { contract: Contract }) { const { contract } = props const { creatorName, creatorUsername, createdTime, question, resolution } = contract const { probPercent, truePool } = contractMetrics(contract) // Currently hidden on mobile; ideally we'd fit this in somewhere. const closeMessage = contract.isResolved || !contract.closeTime ? null : ( <span className="float-right text-gray-400 hidden sm:inline"> {formatMoney(truePool)} pool <span className="mx-2">•</span> {contract.closeTime > Date.now() ? 'Closes' : 'Closed'} <Timestamp time={contract.closeTime || 0} /> </span> ) return ( <> {contract.creatorAvatarUrl ? ( <AvatarWithIcon username={contract.creatorUsername} avatarUrl={contract.creatorAvatarUrl} /> ) : ( // TODO: After 2022-03-01, can just assume that all contracts have an avatarUrl <div className="relative px-1"> <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> <StarIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> </div> </div> )} <div className="min-w-0 flex-1 py-1.5"> <div className="text-sm text-gray-500 mb-2"> <UserLink className="text-gray-900" name={creatorName} username={creatorUsername} />{' '} asked {closeMessage} </div> <Col className="items-start sm:flex-row justify-between gap-2 sm:gap-4 mb-4"> <SiteLink href={contractPath(contract)} className="text-lg sm:text-xl text-indigo-700" > {question} </SiteLink> <ResolutionOrChance className="items-center" resolution={resolution} probPercent={probPercent} /> </Col> <TruncatedComment comment={contract.description} moreHref={contractPath(contract)} shouldTruncate /> </div> </> ) } function FeedDescription(props: { contract: Contract }) { const { contract } = props const { creatorName, creatorUsername } = contract const user = useUser() const isCreator = user?.id === contract.creatorId return ( <> {contract.creatorAvatarUrl ? ( <AvatarWithIcon username={contract.creatorUsername} avatarUrl={contract.creatorAvatarUrl} /> ) : ( // TODO: After 2022-03-01, can just assume that all contracts have an avatarUrl <div className="relative px-1"> <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> <StarIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> </div> </div> )} <div className="min-w-0 flex-1 py-1.5"> <div className="text-sm text-gray-500"> <UserLink className="text-gray-900" name={creatorName} username={creatorUsername} />{' '} created this market <Timestamp time={contract.createdTime} /> </div> <ContractDescription contract={contract} isCreator={isCreator} /> </div> </> ) } function OutcomeIcon(props: { outcome?: outcome }) { const { outcome } = props switch (outcome) { case 'YES': return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> case 'NO': return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> case 'CANCEL': default: return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> } } function FeedResolve(props: { contract: Contract }) { const { contract } = props const { creatorName, creatorUsername } = contract const resolution = contract.resolution || 'CANCEL' return ( <> <div> <div className="relative px-1"> <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> <OutcomeIcon outcome={resolution} /> </div> </div> </div> <div className="min-w-0 flex-1 py-1.5"> <div className="text-sm text-gray-500"> <UserLink className="text-gray-900" name={creatorName} username={creatorUsername} />{' '} resolved this market to <OutcomeLabel outcome={resolution} />{' '} <Timestamp time={contract.resolutionTime || 0} /> </div> </div> </> ) } function FeedClose(props: { contract: Contract }) { const { contract } = props return ( <> <div> <div className="relative px-1"> <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> <LockClosedIcon 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"> Trading closed in this market{' '} <Timestamp time={contract.closeTime || 0} /> </div> </div> </> ) } function toFeedBet(bet: Bet) { return { 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), } } 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 // Return a list of ActivityItems function groupBets( bets: Bet[], comments: Comment[], windowMs: number, 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 && 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: 'YES' | 'NO' }) { const { bets, outcome } = props const numberTraders = _.uniqBy(bets, (b) => b.userId).length const [buys, sells] = _.partition(bets, (bet) => bet.amount >= 0) const buyTotal = _.sumBy(buys, (b) => b.amount) const sellTotal = _.sumBy(sells, (b) => -b.amount) return ( <span> {numberTraders} {numberTraders > 1 ? 'traders' : 'trader'}{' '} <JoinSpans> {buyTotal > 0 && <>bought {formatMoney(buyTotal)} </>} {sellTotal > 0 && <>sold {formatMoney(sellTotal)} </>} </JoinSpans> of <OutcomeLabel outcome={outcome} /> </span> ) } // TODO: Make this expandable to show all grouped bets? function FeedBetGroup(props: { activityItem: any }) { const { activityItem } = props const bets: Bet[] = activityItem.bets const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') const createdTime = bets[0].createdTime return ( <> <div> <div className="relative px-1"> <div className="h-8 w-8 bg-gray-200 rounded-full flex items-center justify-center"> <UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> </div> </div> </div> <div className="min-w-0 flex-1"> <div className="text-sm text-gray-500"> {yesBets.length > 0 && <BetGroupSpan outcome="YES" bets={yesBets} />} {yesBets.length > 0 && noBets.length > 0 && <br />} {noBets.length > 0 && <BetGroupSpan outcome="NO" bets={noBets} />} <Timestamp time={createdTime} /> </div> </div> </> ) } // TODO: Should highlight the entire Feed segment function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) { const { setExpanded } = props return ( <> <button onClick={() => setExpanded(true)}> <div className="relative px-1"> <div className="h-8 w-8 bg-gray-200 hover:bg-gray-300 rounded-full flex items-center justify-center"> <DotsVerticalIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> </div> </div> </button> <button onClick={() => setExpanded(true)}> <div className="min-w-0 flex-1 py-1.5"> <div className="text-sm text-gray-500 hover:text-gray-700"> <span>Show all activity</span> </div> </div> </button> </> ) } // Missing feed items: // - Bet sold? type ActivityItem = { id: string type: | 'bet' | 'comment' | 'start' | 'betgroup' | 'close' | 'resolve' | 'expand' } export function ContractFeed(props: { contract: Contract bets: Bet[] comments: Comment[] // Feed types: 'activity' = Activity feed, 'market' = Comments feed on a market feedType: 'activity' | 'market' betRowClassName?: string }) { const { contract, feedType, betRowClassName } = props const { id } = contract const [expanded, setExpanded] = useState(false) const user = useUser() let bets = useBets(id) ?? props.bets bets = withoutAnteBets(contract, bets) const comments = useComments(id) ?? props.comments const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS const allItems = [ { type: 'start', id: 0 }, ...groupBets(bets, comments, groupWindow, 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 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 ( <div className="flow-root"> <ul role="list" className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}> {items.map((activityItem, activityItemIdx) => ( <li key={activityItem.id}> <div className="relative pb-8"> {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} /> ) : ( <FeedDescription contract={contract} /> ) ) : activityItem.type === 'comment' ? ( <FeedComment activityItem={activityItem} moreHref={contractPath(contract)} feedType={feedType} /> ) : activityItem.type === 'bet' ? ( <FeedBet activityItem={activityItem} /> ) : activityItem.type === 'betgroup' ? ( <FeedBetGroup activityItem={activityItem} /> ) : activityItem.type === 'close' ? ( <FeedClose contract={contract} /> ) : activityItem.type === 'resolve' ? ( <FeedResolve contract={contract} /> ) : activityItem.type === 'expand' ? ( <FeedExpand setExpanded={setExpanded} /> ) : null} </div> </div> </li> ))} </ul> {tradingAllowed(contract) && ( <BetRow contract={contract} className={clsx('-mt-4', betRowClassName)} /> )} </div> ) }