diff --git a/.gitignore b/.gitignore index 6cb1e610..10f5d982 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea/ .vercel node_modules +yarn-error.log diff --git a/common/categories.ts b/common/categories.ts new file mode 100644 index 00000000..12788f53 --- /dev/null +++ b/common/categories.ts @@ -0,0 +1,25 @@ +export const CATEGORIES = { + politics: 'Politics', + technology: 'Technology', + sports: 'Sports', + gaming: 'Gaming', + manifold: 'Manifold', + science: 'Science', + world: 'World', + fun: 'Fun', + personal: 'Personal', + economics: 'Economics', + crypto: 'Crypto', + health: 'Health', + // entertainment: 'Entertainment', + // society: 'Society', + // friends: 'Friends / Community', + // business: 'Business', + // charity: 'Charities / Non-profits', +} as { [category: string]: string } + +export const TO_CATEGORY = Object.fromEntries( + Object.entries(CATEGORIES).map(([k, v]) => [v, k]) +) + +export const CATEGORY_LIST = Object.keys(CATEGORIES) diff --git a/common/comment.ts b/common/comment.ts index 15cfbcb5..1f420b64 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -5,6 +5,7 @@ export type Comment = { contractId: string betId?: string answerOutcome?: string + replyToCommentId?: string userId: string text: string diff --git a/common/feed.ts b/common/feed.ts new file mode 100644 index 00000000..a8783c7f --- /dev/null +++ b/common/feed.ts @@ -0,0 +1,9 @@ +import { Bet } from './bet' +import { Comment } from './comment' +import { Contract } from './contract' + +export type feed = { + contract: Contract + recentBets: Bet[] + recentComments: Comment[] +}[] diff --git a/common/user.ts b/common/user.ts index dcbe28e9..8713717d 100644 --- a/common/user.ts +++ b/common/user.ts @@ -17,6 +17,8 @@ export type User = { totalDeposits: number totalPnLCached: number creatorVolumeCached: number + + followedCategories?: string[] } export const STARTING_BALANCE = 1000 diff --git a/firestore.rules b/firestore.rules index 28e03e64..24ab0941 100644 --- a/firestore.rules +++ b/firestore.rules @@ -16,7 +16,7 @@ service cloud.firestore { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); } match /private-users/{userId} { @@ -35,7 +35,7 @@ service cloud.firestore { allow create: if userId == request.auth.uid; } - match /private-users/{userId}/cache/feed { + match /private-users/{userId}/cache/{docId} { allow read: if userId == request.auth.uid || isAdmin(); } diff --git a/functions/package.json b/functions/package.json index 2eeec6f2..d51a3481 100644 --- a/functions/package.json +++ b/functions/package.json @@ -19,11 +19,13 @@ }, "main": "lib/functions/src/index.js", "dependencies": { + "@react-query-firebase/firestore": "0.4.2", "fetch": "1.1.0", "firebase-admin": "10.0.0", "firebase-functions": "3.16.0", "lodash": "4.17.21", "mailgun-js": "0.22.0", + "react-query": "3.39.0", "module-alias": "2.2.2", "stripe": "8.194.0" }, diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 21eceba8..45f42291 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -7,8 +7,8 @@ import { getNewMultiBetInfo } from 'common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from 'common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' -import { Bet } from 'common/bet' -import { hasUserHitManaLimit } from 'common/calculate' +import { Bet } from '../../common/bet' +import { hasUserHitManaLimit } from '../../common/calculate' export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( async ( diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 03b50274..379428fa 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -1,6 +1,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import * as _ from 'lodash' + import { chargeUser, getUser } from './utils' import { Binary, diff --git a/functions/src/get-feed-data.ts b/functions/src/get-feed-data.ts new file mode 100644 index 00000000..75782fed --- /dev/null +++ b/functions/src/get-feed-data.ts @@ -0,0 +1,71 @@ +import * as admin from 'firebase-admin' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' +import { DAY_MS } from '../../common/util/time' +import { getValues } from './utils' + +const firestore = admin.firestore() + +export async function getFeedContracts() { + // Get contracts bet on or created in last week. + const [activeContracts, inactiveContracts] = await Promise.all([ + getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('volume7Days', '>', 0) + ), + + getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('createdTime', '>', Date.now() - DAY_MS * 7) + .where('volume7Days', '==', 0) + ), + ]) + + const combined = [...activeContracts, ...inactiveContracts] + // Remove closed contracts. + return combined.filter((c) => (c.closeTime ?? Infinity) > Date.now()) +} + +export async function getTaggedContracts(tag: string) { + const taggedContracts = await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('lowercaseTags', 'array-contains', tag.toLowerCase()) + ) + + // Remove closed contracts. + return taggedContracts.filter((c) => (c.closeTime ?? Infinity) > Date.now()) +} + +export async function getRecentBetsAndComments(contract: Contract) { + const contractDoc = firestore.collection('contracts').doc(contract.id) + + const [recentBets, recentComments] = await Promise.all([ + getValues( + contractDoc + .collection('bets') + .where('createdTime', '>', Date.now() - DAY_MS) + .orderBy('createdTime', 'desc') + .limit(1) + ), + + getValues( + contractDoc + .collection('comments') + .where('createdTime', '>', Date.now() - 3 * DAY_MS) + .orderBy('createdTime', 'desc') + .limit(3) + ), + ]) + + return { + contract, + recentBets, + recentComments, + } +} diff --git a/functions/src/scripts/update-feed.ts b/functions/src/scripts/update-feed.ts index f98631dd..d698a529 100644 --- a/functions/src/scripts/update-feed.ts +++ b/functions/src/scripts/update-feed.ts @@ -9,7 +9,9 @@ import { User } from 'common/user' import { batchedWaitAll } from 'common/util/promise' import { Contract } from 'common/contract' import { updateWordScores } from '../update-recommendations' -import { getFeedContracts, doUserFeedUpdate } from '../update-feed' +import { computeFeed } from '../update-feed' +import { getFeedContracts, getTaggedContracts } from '../get-feed-data' +import { CATEGORY_LIST } from '../../../common/categories' const firestore = admin.firestore() @@ -19,8 +21,7 @@ async function updateFeed() { const contracts = await getValues(firestore.collection('contracts')) const feedContracts = await getFeedContracts() const users = await getValues( - firestore.collection('users') - // .where('username', '==', 'JamesGrugett') + firestore.collection('users').where('username', '==', 'JamesGrugett') ) await batchedWaitAll( @@ -28,7 +29,22 @@ async function updateFeed() { console.log('Updating recs for', user.username) await updateWordScores(user, contracts) console.log('Updating feed for', user.username) - await doUserFeedUpdate(user, feedContracts) + await computeFeed(user, feedContracts) + }) + ) + + console.log('Updating feed categories!') + + await batchedWaitAll( + users.map((user) => async () => { + for (const category of CATEGORY_LIST) { + const contracts = await getTaggedContracts(category) + const feed = await computeFeed(user, contracts) + await firestore + .collection(`private-users/${user.id}/cache`) + .doc(`feed-${category}`) + .set({ feed }) + } }) ) } diff --git a/functions/src/update-feed.ts b/functions/src/update-feed.ts index 3bfa7949..c9d2def8 100644 --- a/functions/src/update-feed.ts +++ b/functions/src/update-feed.ts @@ -10,15 +10,19 @@ import { getProbability, getOutcomeProbability, getTopAnswer, -} from 'common/calculate' -import { Bet } from 'common/bet' -import { Comment } from 'common/comment' -import { User } from 'common/user' +} from '../../common/calculate' +import { User } from '../../common/user' import { getContractScore, MAX_FEED_CONTRACTS, } from 'common/recommended-contracts' import { callCloudFunction } from './call-cloud-function' +import { + getFeedContracts, + getRecentBetsAndComments, + getTaggedContracts, +} from './get-feed-data' +import { CATEGORY_LIST } from '../../common/categories' const firestore = admin.firestore() @@ -28,16 +32,28 @@ export const updateFeed = functions.pubsub const users = await getValues(firestore.collection('users')) const batchSize = 100 - const userBatches: User[][] = [] + let userBatches: User[][] = [] for (let i = 0; i < users.length; i += batchSize) { userBatches.push(users.slice(i, i + batchSize)) } + console.log('updating feed batch') + await Promise.all( - userBatches.map(async (users) => + userBatches.map((users) => callCloudFunction('updateFeedBatch', { users }) ) ) + + console.log('updating category feed') + + await Promise.all( + CATEGORY_LIST.map((category) => + callCloudFunction('updateCategoryFeed', { + category, + }) + ) + ) }) export const updateFeedBatch = functions.https.onCall( @@ -45,40 +61,56 @@ export const updateFeedBatch = functions.https.onCall( const { users } = data const contracts = await getFeedContracts() - await Promise.all(users.map((user) => doUserFeedUpdate(user, contracts))) + await Promise.all( + users.map(async (user) => { + const feed = await computeFeed(user, contracts) + await getUserCacheCollection(user).doc('feed').set({ feed }) + }) + ) + } +) +export const updateCategoryFeed = functions.https.onCall( + async (data: { category: string }) => { + const { category } = data + const users = await getValues(firestore.collection('users')) + + const batchSize = 100 + const userBatches: User[][] = [] + for (let i = 0; i < users.length; i += batchSize) { + userBatches.push(users.slice(i, i + batchSize)) + } + + await Promise.all( + userBatches.map(async (users) => { + await callCloudFunction('updateCategoryFeedBatch', { + users, + category, + }) + }) + ) } ) -export async function getFeedContracts() { - // Get contracts bet on or created in last week. - const contracts = await Promise.all([ - getValues( - firestore - .collection('contracts') - .where('isResolved', '==', false) - .where('volume7Days', '>', 0) - ), +export const updateCategoryFeedBatch = functions.https.onCall( + async (data: { users: User[]; category: string }) => { + const { users, category } = data + const contracts = await getTaggedContracts(category) - getValues( - firestore - .collection('contracts') - .where('isResolved', '==', false) - .where('createdTime', '>', Date.now() - DAY_MS * 7) - .where('volume7Days', '==', 0) - ), - ]).then(([activeContracts, inactiveContracts]) => { - const combined = [...activeContracts, ...inactiveContracts] - // Remove closed contracts. - return combined.filter((c) => (c.closeTime ?? Infinity) > Date.now()) - }) + await Promise.all( + users.map(async (user) => { + const feed = await computeFeed(user, contracts) + await getUserCacheCollection(user).doc(`feed-${category}`).set({ feed }) + }) + ) + } +) - return contracts -} +const getUserCacheCollection = (user: User) => + firestore.collection(`private-users/${user.id}/cache`) + +export const computeFeed = async (user: User, contracts: Contract[]) => { + const userCacheCollection = getUserCacheCollection(user) -export const doUserFeedUpdate = async (user: User, contracts: Contract[]) => { - const userCacheCollection = firestore.collection( - `private-users/${user.id}/cache` - ) const [wordScores, lastViewedTime] = await Promise.all([ getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')), getValue<{ [contractId: string]: number }>( @@ -109,8 +141,7 @@ export const doUserFeedUpdate = async (user: User, contracts: Contract[]) => { const feed = await Promise.all( feedContracts.map((contract) => getRecentBetsAndComments(contract)) ) - - await userCacheCollection.doc('feed').set({ feed }) + return feed } function scoreContract( @@ -180,31 +211,3 @@ function getLastViewedScore(viewTime: number | undefined) { const frac = logInterpolation(0.5, 14, daysAgo) return 0.75 + 0.25 * frac } - -async function getRecentBetsAndComments(contract: Contract) { - const contractDoc = firestore.collection('contracts').doc(contract.id) - - const [recentBets, recentComments] = await Promise.all([ - getValues( - contractDoc - .collection('bets') - .where('createdTime', '>', Date.now() - DAY_MS) - .orderBy('createdTime', 'desc') - .limit(1) - ), - - getValues( - contractDoc - .collection('comments') - .where('createdTime', '>', Date.now() - 3 * DAY_MS) - .orderBy('createdTime', 'desc') - .limit(3) - ), - ]) - - return { - contract, - recentBets, - recentComments, - } -} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 679af415..8fba3bf7 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -303,6 +303,7 @@ function BuyPanel(props: { <> onBetChoice(choice)} /> diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index b53a57fd..29493473 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -15,9 +15,9 @@ import { useSaveShares } from './use-save-shares' export default function BetRow(props: { contract: FullContract className?: string - labelClassName?: string + btnClassName?: string }) { - const { className, labelClassName, contract } = props + const { className, btnClassName, contract } = props const [open, setOpen] = useState(false) const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>( undefined @@ -31,38 +31,34 @@ export default function BetRow(props: { return ( <> - - {/*
- Place a trade -
*/} - { - setOpen(true) - setBetChoice(choice) - }} - replaceNoButton={ - yesFloorShares > 0 ? ( - - ) : undefined - } - replaceYesButton={ - noFloorShares > 0 ? ( - - ) : undefined - } - /> -
+ { + setOpen(true) + setBetChoice(choice) + }} + replaceNoButton={ + yesFloorShares > 0 ? ( + + ) : undefined + } + replaceYesButton={ + noFloorShares > 0 ? ( + + ) : undefined + } + /> `#${tag}`).join(' ')}` ) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) + await updateContract(contract.id, { description: newDescription, tags, @@ -35,6 +38,9 @@ export function ContractDescription(props: { if (!isCreator && !contract.description.trim()) return null + const { tags } = contract + const category = tags.find((tag) => CATEGORY_LIST.includes(tag.toLowerCase())) + return (
+ + {category && ( +
+ +
+ )} +
+ {isCreator && ( - {tradingAllowed(contract) && ( - - )} + {tradingAllowed(contract) && } ) : ( outcomeType === 'FREE_RESPONSE' && diff --git a/web/components/feed/activity-feed.tsx b/web/components/feed/activity-feed.tsx index 9e0b23c4..15c7c662 100644 --- a/web/components/feed/activity-feed.tsx +++ b/web/components/feed/activity-feed.tsx @@ -20,11 +20,11 @@ export function ActivityFeed(props: { const user = useUser() return ( - + {feed.map((item) => ( } export type BetGroupItem = BaseActivityItem & { @@ -68,6 +75,8 @@ export type AnswerGroupItem = BaseActivityItem & { type: 'answergroup' | 'answer' answer: Answer items: ActivityItem[] + betsByCurrentUser?: Bet[] + comments?: Comment[] } export type CloseItem = BaseActivityItem & { @@ -131,7 +140,6 @@ function groupBets( comment, betsBySameUser: [bet], contract, - hideOutcome, truncate: abbreviated, smallAvatar, } @@ -273,41 +281,21 @@ function getAnswerAndCommentInputGroups( getOutcomeProbability(contract, outcome) ) - function collateCommentsSectionForOutcome(outcome: string) { - const answerBets = bets.filter((bet) => bet.outcome === outcome) - const answerComments = comments.filter( - (comment) => - comment.answerOutcome === outcome || - answerBets.some((bet) => bet.id === comment.betId) - ) - let items = [] - items.push({ - type: 'commentInput' as const, - id: 'commentInputFor' + outcome, - contract, - betsByCurrentUser: user - ? bets.filter((bet) => bet.userId === user.id) - : [], - comments: comments, - answerOutcome: outcome, - }) - items.push( - ...getCommentsWithPositions( - answerBets, - answerComments, - contract - ).reverse() - ) - return items - } - const answerGroups = outcomes .map((outcome) => { const answer = contract.answers?.find( (answer) => answer.id === outcome ) as Answer - const items = collateCommentsSectionForOutcome(outcome) + const answerBets = bets.filter((bet) => bet.outcome === outcome) + const answerComments = comments.filter( + (comment) => + comment.answerOutcome === outcome || + answerBets.some((bet) => bet.id === comment.betId) + ) + const items = getCommentThreads(answerBets, answerComments, contract) + + if (outcome === GENERAL_COMMENTS_OUTCOME_ID) items.reverse() return { id: outcome, @@ -316,6 +304,8 @@ function getAnswerAndCommentInputGroups( answer, items, user, + betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id), + comments: answerComments, } }) .filter((group) => group.answer) as ActivityItem[] @@ -344,7 +334,6 @@ function groupBetsAndComments( comment, betsBySameUser: [], truncate: abbreviated, - hideOutcome: true, smallAvatar, })) @@ -370,22 +359,21 @@ function groupBetsAndComments( return abbrItems } -function getCommentsWithPositions( +function getCommentThreads( bets: Bet[], comments: Comment[], contract: Contract ) { const betsByUserId = _.groupBy(bets, (bet) => bet.userId) + const parentComments = comments.filter((comment) => !comment.replyToCommentId) - const items = comments.map((comment) => ({ - type: 'comment' as const, + const items = parentComments.map((comment) => ({ + type: 'commentThread' as const, id: comment.id, contract: contract, - comment, - betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [], - truncate: false, - hideOutcome: false, - smallAvatar: false, + comments: comments, + parentComment: comment, + betsByUserId: betsByUserId, })) return items @@ -546,6 +534,8 @@ export function getSpecificContractActivityItems( 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, @@ -566,7 +556,7 @@ export function getSpecificContractActivityItems( const nonFreeResponseBets = contract.outcomeType === 'FREE_RESPONSE' ? [] : bets items.push( - ...getCommentsWithPositions( + ...getCommentThreads( nonFreeResponseBets, nonFreeResponseComments, contract diff --git a/web/components/feed/category-selector.tsx b/web/components/feed/category-selector.tsx new file mode 100644 index 00000000..6896f98b --- /dev/null +++ b/web/components/feed/category-selector.tsx @@ -0,0 +1,75 @@ +import clsx from 'clsx' +import _ from 'lodash' + +import { User } from '../../../common/user' +import { Row } from '../layout/row' +import { CATEGORIES, CATEGORY_LIST } from '../../../common/categories' +import { updateUser } from '../../lib/firebase/users' + +export function CategorySelector(props: { + user: User | null | undefined + className?: string +}) { + const { className, user } = props + + const followedCategories = user?.followedCategories ?? [] + + return ( + +
+ { + if (!user?.id) return + + await updateUser(user.id, { + followedCategories: [], + }) + }} + /> + + {CATEGORY_LIST.map((cat) => ( + { + if (!user?.id) return + + await updateUser(user.id, { + followedCategories: [cat], + }) + }} + /> + ))} + + ) +} + +function CategoryButton(props: { + category: string + isFollowed: boolean + toggle: () => void +}) { + const { toggle, category, isFollowed } = props + + return ( +
+ {category} +
+ ) +} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 40b2dd09..e061f475 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -10,6 +10,7 @@ import { } from './activity-items' import { FeedItems } from './feed-items' import { User } from 'common/user' +import { useContract } from 'web/hooks/use-contract' export function ContractActivity(props: { contract: Contract @@ -27,8 +28,9 @@ export function ContractActivity(props: { className?: string betRowClassName?: string }) { - const { contract, user, mode, contractPath, className, betRowClassName } = - props + const { user, mode, contractPath, className, betRowClassName } = props + + const contract = useContract(props.contract.id) ?? props.contract const updatedComments = // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 0a690d1b..21d4b568 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -1,6 +1,7 @@ // From https://tailwindui.com/components/application-ui/lists/feeds -import React, { Fragment, useRef, useState } from 'react' +import React, { Fragment, useEffect, useRef, useState } from 'react' import * as _ from 'lodash' +import { Dictionary } from 'lodash' import { BanIcon, CheckIcon, @@ -15,8 +16,8 @@ import Textarea from 'react-expanding-textarea' import { OutcomeLabel } from '../outcome-label' import { - contractMetrics, Contract, + contractMetrics, contractPath, tradingAllowed, } from 'web/lib/firebase/contracts' @@ -30,15 +31,13 @@ import { BinaryResolutionOrChance } from '../contract/contract-card' import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { UserLink } from '../user-page' -import { DateTimeTooltip } from '../datetime-tooltip' import { Bet } from 'web/lib/firebase/bets' import { JoinSpans } from '../join-spans' -import { fromNow } from 'web/lib/util/time' import BetRow from '../bet-row' import { Avatar } from '../avatar' import { Answer } from 'common/answer' import { ActivityItem, GENERAL_COMMENTS_OUTCOME_ID } from './activity-items' -import { Binary, CPMM, DPM, FreeResponse, FullContract } from 'common/contract' +import { Binary, CPMM, FreeResponse, FullContract } from 'common/contract' import { BuyButton } from '../yes-no-selector' import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { AnswerBetPanel } from '../answers/answer-bet-panel' @@ -118,24 +117,97 @@ function FeedItem(props: { item: ActivityItem }) { return case 'commentInput': return + case 'commentThread': + return } } +export function FeedCommentThread(props: { + contract: Contract + comments: Comment[] + parentComment: Comment + betsByUserId: Dictionary<[Bet, ...Bet[]]> + truncate?: boolean + smallAvatar?: boolean +}) { + const { + contract, + comments, + betsByUserId, + truncate, + smallAvatar, + parentComment, + } = props + const [showReply, setShowReply] = useState(false) + const [replyToUsername, setReplyToUsername] = useState('') + const user = useUser() + const commentsList = comments.filter( + (comment) => comment.replyToCommentId === parentComment.id + ) + commentsList.unshift(parentComment) + const [inputRef, setInputRef] = useState(null) + function scrollAndOpenReplyInput(comment: Comment) { + setReplyToUsername(comment.userUsername) + setShowReply(true) + inputRef?.focus() + } + useEffect(() => { + if (showReply && inputRef) inputRef.focus() + }, [inputRef, showReply]) + return ( +
+ {commentsList.map((comment, commentIdx) => ( +
+ +
+ ))} + {showReply && ( +
+ +
+ )} +
+ ) +} + export function FeedComment(props: { contract: Contract comment: Comment betsBySameUser: Bet[] - hideOutcome: boolean - truncate: boolean - smallAvatar: boolean + truncate?: boolean + smallAvatar?: boolean + onReplyClick?: (comment: Comment) => void }) { const { contract, comment, betsBySameUser, - hideOutcome, truncate, smallAvatar, + onReplyClick, } = props const { text, userUsername, userName, userAvatarUrl, createdTime } = comment let outcome: string | undefined, @@ -187,7 +259,7 @@ export function FeedComment(props: { )} <> {bought} {money} - {outcome && !hideOutcome && ( + {contract.outcomeType !== 'FREE_RESPONSE' && outcome && ( <> {' '} of{' '} @@ -206,6 +278,14 @@ export function FeedComment(props: { moreHref={contractPath(contract)} shouldTruncate={truncate} /> + {onReplyClick && ( + + )}
) @@ -215,133 +295,159 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] comments: Comment[] - // Only for free response comment inputs + // Tie a comment to an free response answer outcome answerOutcome?: string + // Tie a comment to another comment + parentComment?: Comment + replyToUsername?: string + setRef?: (ref: HTMLTextAreaElement) => void }) { - const { contract, betsByCurrentUser, comments, answerOutcome } = props + const { + contract, + betsByCurrentUser, + comments, + answerOutcome, + parentComment, + replyToUsername, + setRef, + } = props const user = useUser() const [comment, setComment] = useState('') const [focused, setFocused] = useState(false) - // Should this be oldest bet or most recent bet? - const mostRecentCommentableBet = betsByCurrentUser - .filter((bet) => { - if ( - canCommentOnBet(bet, user) && - // The bet doesn't already have a comment - !comments.some((comment) => comment.betId == bet.id) - ) { - if (!answerOutcome) return true - // If we're in free response, don't allow commenting on ante bet - return ( - bet.outcome !== GENERAL_COMMENTS_OUTCOME_ID && - answerOutcome === bet.outcome - ) - } - return false - }) - .sort((b1, b2) => b1.createdTime - b2.createdTime) - .pop() - + const mostRecentCommentableBet = getMostRecentCommentableBet( + betsByCurrentUser, + comments, + user, + answerOutcome + ) const { id } = mostRecentCommentableBet || { id: undefined } + useEffect(() => { + if (!replyToUsername || !user || replyToUsername === user.username) return + const replacement = `@${replyToUsername} ` + setComment(replacement + comment.replace(replacement, '')) + }, [user, replyToUsername]) + async function submitComment(betId: string | undefined) { if (!user) { return await firebaseLogin() } if (!comment) return - await createComment(contract.id, comment, user, betId, answerOutcome) + + // Update state asap to avoid double submission. + const commentValue = comment.toString() setComment('') + await createComment( + contract.id, + commentValue, + user, + betId, + answerOutcome, + parentComment?.id + ) } const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } = getBettorsPosition(contract, Date.now(), betsByCurrentUser) + const shouldCollapseAfterClickOutside = false + return ( <> - - + +
+ +
- {mostRecentCommentableBet && ( - - )} - {!mostRecentCommentableBet && user && userPosition > 0 && ( - <> - {'You have ' + userPositionMoney + ' '} - <> - {' of '} - noFloorShares ? 'YES' : 'NO'} - contract={contract} - truncate="short" - /> - - - )} - {(answerOutcome === undefined || focused) && ( -
-