Merge branch 'main' into CPM-ui

This commit is contained in:
James Grugett 2022-04-25 15:32:54 -04:00
commit 0224305fac
30 changed files with 584 additions and 7127 deletions

35
common/add-liquidity.ts Normal file
View File

@ -0,0 +1,35 @@
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
import { Binary, CPMM, FullContract } from './contract'
import { LiquidityProvision } from './liquidity-provision'
import { User } from './user'
export const getNewLiquidityProvision = (
user: User,
amount: number,
contract: FullContract<CPMM, Binary>,
newLiquidityProvisionId: string
) => {
const { pool, p, totalLiquidity } = contract
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
const liquidity =
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
const newLiquidityProvision: LiquidityProvision = {
id: newLiquidityProvisionId,
userId: user.id,
contractId: contract.id,
amount,
pool: newPool,
p: newP,
liquidity,
createdTime: Date.now(),
}
const newTotalLiquidity = (totalLiquidity ?? 0) + amount
const newBalance = user.balance - amount
return { newLiquidityProvision, newPool, newP, newBalance, newTotalLiquidity }
}

View File

@ -5,7 +5,7 @@ import { User } from './user'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees' import { noFees } from './fees'
export const FIXED_ANTE = 100 export const FIXED_ANTE = 50
// deprecated // deprecated
export const PHANTOM_ANTE = 0.001 export const PHANTOM_ANTE = 0.001

View File

@ -174,17 +174,19 @@ export function calculateCpmmSale(
throw new Error('Cannot sell non-positive shares') throw new Error('Cannot sell non-positive shares')
} }
const rawSaleValue = calculateCpmmShareValue( const saleValue = calculateCpmmShareValue(
contract, contract,
shares, shares,
outcome as 'YES' | 'NO' outcome as 'YES' | 'NO'
) )
const { fees, remainingBet: saleValue } = getCpmmLiquidityFee( const fees = noFees
contract,
rawSaleValue, // const { fees, remainingBet: saleValue } = getCpmmLiquidityFee(
outcome === 'YES' ? 'NO' : 'YES' // contract,
) // rawSaleValue,
// outcome === 'YES' ? 'NO' : 'YES'
// )
const { pool } = contract const { pool } = contract
const { YES: y, NO: n } = pool const { YES: y, NO: n } = pool

View File

@ -181,7 +181,7 @@ export function getContractBetNullMetrics() {
export function getTopAnswer(contract: FreeResponseContract) { export function getTopAnswer(contract: FreeResponseContract) {
const { answers } = contract const { answers } = contract
const top = _.maxBy( const top = _.maxBy(
answers.map((answer) => ({ answers?.map((answer) => ({
answer, answer,
prob: getOutcomeProbability(contract, answer.id), prob: getOutcomeProbability(contract, answer.id),
})), })),

View File

@ -3,7 +3,7 @@
export type Comment = { export type Comment = {
id: string id: string
contractId: string contractId: string
betId: string betId?: string
userId: string userId: string
text: string text: string

View File

@ -1,5 +1,6 @@
# Secrets # Secrets
.env* .env*
.runtimeconfig.json
# Compiled JavaScript files # Compiled JavaScript files
lib/**/*.js lib/**/*.js
@ -14,3 +15,5 @@ node_modules/
package-lock.json package-lock.json
ui-debug.log ui-debug.log
firebase-debug.log firebase-debug.log
firestore-debug.log
firestore_export/

View File

@ -19,15 +19,33 @@ Adapted from https://firebase.google.com/docs/functions/get-started
2. `$ yarn` to install JS dependencies 2. `$ yarn` to install JS dependencies
3. `$ firebase login` to authenticate the CLI tools to Firebase 3. `$ firebase login` to authenticate the CLI tools to Firebase
4. `$ firebase use dev` to choose the dev project 4. `$ firebase use dev` to choose the dev project
5. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev
### For local development
0. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev
1. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
2. `$ brew install java` to install java if you don't already have it
1. `$ echo 'export PATH="/usr/local/opt/openjdk/bin:$PATH"' >> ~/.zshrc` to add java to your path
3. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
4. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
5. `$ mkdir firestore_export` to create a folder to store the exported database
6. `$ yarn db:update-local-from-remote` to pull the remote db from Firestore to local
1. TODO: this won't work when open source, we'll have to point to the public db
## Developing locally ## Developing locally
0. `$ yarn dev` to spin up the emulators 1. `$ yarn serve` to spin up the emulators
The Emulator UI is at http://localhost:4000; the functions are hosted on :5001. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001.
Note: You have to kill and restart emulators when you change code; no hot reload =( Note: You have to kill and restart emulators when you change code; no hot reload =(
Note2: You may even have to find the process ID of the emulator and kill it manually. 2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend
1. Connect by uncommenting the `connectFunctionsEmulator` in `web/lib/firebase/api-call.ts` 1. Note: emulated database is cleared after every shutdown
## Firestore Commands
- `db:update-local-from-remote` - Pull the remote db from Firestore to local, also calls:
- `db:backup-remote` - Exports the remote dev db to the backup folder on Google Cloud Storage (called on every `db:update-local-from-remote`)
- `db:rename-remote-backup-folder` - Renames the remote backup folder (called on every `db:backup-remote` to preserve the previous db backup)
- `db:backup-local` - Save the local db changes to the disk (overwrites existing)
## Debugging ## Debugging

View File

@ -1,15 +1,21 @@
{ {
"name": "functions", "name": "functions",
"version": "1.0.0", "version": "1.0.0",
"config": {
"firestore": "dev-mantic-markets.appspot.com"
},
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"watch": "tsc -w", "watch": "tsc -w",
"serve": "yarn build && firebase emulators:start --only functions",
"shell": "yarn build && firebase functions:shell", "shell": "yarn build && firebase functions:shell",
"start": "yarn shell", "start": "yarn shell",
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"dev": "yarn serve" "serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/"
}, },
"main": "lib/functions/src/index.js", "main": "lib/functions/src/index.js",
"dependencies": { "dependencies": {

View File

@ -0,0 +1,103 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { redeemShares } from './redeem-shares'
import { getNewLiquidityProvision } from '../../common/add-liquidity'
export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
async (
data: {
amount: number
contractId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const { amount, contractId } = data
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
// run as transaction to prevent race conditions
return await firestore
.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists)
return { status: 'error', message: 'User not found' }
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
if (
contract.mechanism !== 'cpmm-1' ||
contract.outcomeType !== 'BINARY'
)
return { status: 'error', message: 'Invalid contract' }
const { closeTime } = contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
if (user.balance < amount)
return { status: 'error', message: 'Insufficient balance' }
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contractId}/liquidity`)
.doc()
const {
newLiquidityProvision,
newPool,
newP,
newBalance,
newTotalLiquidity,
} = getNewLiquidityProvision(
user,
amount,
contract,
newLiquidityProvisionDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
return {
status: 'error',
message: 'Liquidity injection rejected due to overflow error.',
}
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
return { status: 'success', newLiquidityProvision }
})
.then(async (result) => {
await redeemShares(userId, contractId)
return result
})
}
)
const firestore = admin.firestore()

View File

@ -167,7 +167,7 @@ export const sendNewCommentEmail = async (
commentCreator: User, commentCreator: User,
contract: Contract, contract: Contract,
comment: Comment, comment: Comment,
bet: Bet, bet?: Bet,
answer?: Answer answer?: Answer
) => { ) => {
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
@ -186,8 +186,11 @@ export const sendNewCommentEmail = async (
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { text } = comment const { text } = comment
const { amount, sale, outcome } = bet let betDescription = ''
let betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}` if (bet) {
const { amount, sale } = bet
betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}`
}
const subject = `Comment on ${question}` const subject = `Comment on ${question}`
const from = `${commentorName} <info@manifold.markets>` const from = `${commentorName} <info@manifold.markets>`
@ -213,11 +216,12 @@ export const sendNewCommentEmail = async (
{ from } { from }
) )
} else { } else {
if (bet) {
betDescription = `${betDescription} of ${toDisplayResolution( betDescription = `${betDescription} of ${toDisplayResolution(
contract, contract,
outcome bet.outcome
)}` )}`
}
await sendTemplateEmail( await sendTemplateEmail(
privateUser.email, privateUser.email,
subject, subject,

View File

@ -22,3 +22,4 @@ export * from './update-user-metrics'
export * from './backup-db' export * from './backup-db'
export * from './change-user-info' export * from './change-user-info'
export * from './market-close-emails' export * from './market-close-emails'
export * from './add-liquidity'

View File

@ -6,6 +6,7 @@ import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -24,18 +25,22 @@ export const onCreateComment = functions.firestore
const commentCreator = await getUser(comment.userId) const commentCreator = await getUser(comment.userId)
if (!commentCreator) return if (!commentCreator) return
let bet: Bet | undefined
let answer: Answer | undefined
if (comment.betId) {
const betSnapshot = await firestore const betSnapshot = await firestore
.collection('contracts') .collection('contracts')
.doc(contractId) .doc(contractId)
.collection('bets') .collection('bets')
.doc(comment.betId) .doc(comment.betId)
.get() .get()
const bet = betSnapshot.data() as Bet bet = betSnapshot.data() as Bet
const answer = answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet.outcome) ? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined : undefined
}
const comments = await getValues<Comment>( const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contractId).collection('comments') firestore.collection('contracts').doc(contractId).collection('comments')

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,11 @@
(`yarn dev` will point you to prod database) (`yarn dev` will point you to prod database)
### Running with local emulated database and functions
1. `yarn serve` first in `/functions` and wait for it to start
2. `yarn dev:emulate` will point you to the emulated database
## Formatting ## Formatting
Before committing, run `yarn format` to format your code. Before committing, run `yarn format` to format your code.

View File

@ -0,0 +1,85 @@
import clsx from 'clsx'
import { useState } from 'react'
import { Contract } from '../../common/contract'
import { formatMoney } from '../../common/util/format'
import { useUser } from '../hooks/use-user'
import { addLiquidity } from '../lib/firebase/api-call'
import { AmountInput } from './amount-input'
import { Row } from './layout/row'
export function AddLiquidityPanel(props: { contract: Contract }) {
const { contract } = props
const { id: contractId } = contract
const user = useUser()
const [amount, setAmount] = useState<number | undefined>(undefined)
const [error, setError] = useState<string | undefined>(undefined)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const onAmountChange = (amount: number | undefined) => {
setIsSuccess(false)
setAmount(amount)
// Check for errors.
if (amount !== undefined) {
if (user && user.balance < amount) {
setError('Insufficient balance')
} else if (amount < 1) {
setError('Minimum amount: ' + formatMoney(1))
} else {
setError(undefined)
}
}
}
const submit = () => {
if (!amount) return
setIsLoading(true)
setIsSuccess(false)
addLiquidity({ amount, contractId })
.then((r) => {
if (r.status === 'success') {
setIsSuccess(true)
setError(undefined)
setIsLoading(false)
} else {
setError('Server error')
}
})
.catch((e) => setError('Server error'))
}
return (
<>
<div>Subsidize this market by adding liquidity for traders.</div>
<Row>
<AmountInput
amount={amount}
onChange={onAmountChange}
label="M$"
error={error}
disabled={isLoading}
/>
<button
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
>
Add
</button>
</Row>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
)}
{isLoading && <div>Processing...</div>}
</>
)
}

View File

@ -23,7 +23,10 @@ export default function BetRow(props: {
) )
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
contract,
userBets
)
return ( return (
<> <>
@ -39,13 +42,23 @@ export default function BetRow(props: {
setBetChoice(choice) setBetChoice(choice)
}} }}
replaceNoButton={ replaceNoButton={
yesFloorShares > noFloorShares && yesFloorShares > 0 ? ( yesFloorShares > 0 ? (
<SellButton contract={contract} user={user} /> <SellButton
contract={contract}
user={user}
sharesOutcome={'YES'}
shares={yesShares}
/>
) : undefined ) : undefined
} }
replaceYesButton={ replaceYesButton={
noFloorShares > yesFloorShares && noFloorShares > 0 ? ( noFloorShares > 0 ? (
<SellButton contract={contract} user={user} /> <SellButton
contract={contract}
user={user}
sharesOutcome={'NO'}
shares={noShares}
/>
) : undefined ) : undefined
} }
/> />

View File

@ -113,6 +113,13 @@ export function BetsList(props: { user: User }) {
const displayedContracts = _.sortBy(contracts, SORTS[sort]) const displayedContracts = _.sortBy(contracts, SORTS[sort])
.reverse() .reverse()
.filter(FILTERS[filter]) .filter(FILTERS[filter])
.filter((c) => {
if (sort === 'profit') return true
// Filter out contracts where you don't have shares anymore.
const metrics = contractsMetrics[c.id]
return metrics.payout > 0
})
const [settled, unsettled] = _.partition( const [settled, unsettled] = _.partition(
contracts, contracts,
@ -206,7 +213,7 @@ const NoBets = () => {
return ( return (
<div className="mx-4 text-gray-500"> <div className="mx-4 text-gray-500">
You have not made any bets yet.{' '} You have not made any bets yet.{' '}
<SiteLink href="/" className="underline"> <SiteLink href="/home" className="underline">
Find a prediction market! Find a prediction market!
</SiteLink> </SiteLink>
</div> </div>
@ -226,11 +233,10 @@ function MyContractBets(props: {
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const probPercent = getBinaryProbPercent(contract) const probPercent = getBinaryProbPercent(contract)
const { payout, profit, profitPercent } = getContractBetMetrics( const { payout, profit, profitPercent, invested } = getContractBetMetrics(
contract, contract,
bets bets
) )
return ( return (
<div <div
tabIndex={0} tabIndex={0}

View File

@ -11,6 +11,7 @@ import {
contractPath, contractPath,
getBinaryProbPercent, getBinaryProbPercent,
} from '../../lib/firebase/contracts' } from '../../lib/firebase/contracts'
import { AddLiquidityPanel } from '../add-liquidity-panel'
import { CopyLinkButton } from '../copy-link-button' import { CopyLinkButton } from '../copy-link-button'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
@ -110,8 +111,16 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<div className="text-gray-500">Tags</div> <div className="text-gray-500">Tags</div>
<TagsInput contract={contract} /> <TagsInput contract={contract} />
<div /> <div />
{contract.mechanism === 'cpmm-1' &&
!contract.resolution &&
(!closeTime || closeTime > Date.now()) && (
<>
<div className="text-gray-500">Add liquidity</div>
<AddLiquidityPanel contract={contract} />
</>
)}
</Col> </Col>
</Modal> </Modal>
</> </>

View File

@ -1,6 +1,6 @@
import { SparklesIcon, XIcon } from '@heroicons/react/solid' import { SparklesIcon, XIcon } from '@heroicons/react/solid'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { NewContract } from '../pages/create' import { NewContract } from '../pages/create'
import { firebaseLogin, User } from '../lib/firebase/users' import { firebaseLogin, User } from '../lib/firebase/users'
@ -10,6 +10,7 @@ import { Col } from './layout/col'
import clsx from 'clsx' import clsx from 'clsx'
import { Row } from './layout/row' import { Row } from './layout/row'
import { ENV_CONFIG } from '../../common/envs/constants' import { ENV_CONFIG } from '../../common/envs/constants'
import _ from 'lodash'
export function FeedPromo(props: { hotContracts: Contract[] }) { export function FeedPromo(props: { hotContracts: Contract[] }) {
const { hotContracts } = props const { hotContracts } = props
@ -71,14 +72,19 @@ export default function FeedCreate(props: {
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const inputRef = useRef<HTMLTextAreaElement | null>() const inputRef = useRef<HTMLTextAreaElement | null>()
const placeholders = ENV_CONFIG.newQuestionPlaceholders
// Rotate through a new placeholder each day // Rotate through a new placeholder each day
// Easter egg idea: click your own name to shuffle the placeholder // Easter egg idea: click your own name to shuffle the placeholder
// const daysSinceEpoch = Math.floor(Date.now() / 1000 / 60 / 60 / 24) // const daysSinceEpoch = Math.floor(Date.now() / 1000 / 60 / 60 / 24)
const [randIndex] = useState(
Math.floor(Math.random() * 1e10) % placeholders.length // Take care not to produce a different placeholder on the server and client
const [defaultPlaceholder, setDefaultPlaceholder] = useState('')
useEffect(() => {
setDefaultPlaceholder(
`e.g. ${_.sample(ENV_CONFIG.newQuestionPlaceholders)}`
) )
const placeholder = props.placeholder ?? `e.g. ${placeholders[randIndex]}` }, [])
const placeholder = props.placeholder ?? defaultPlaceholder
return ( return (
<div <div

View File

@ -22,12 +22,19 @@ export type ActivityItem =
| AnswerGroupItem | AnswerGroupItem
| CloseItem | CloseItem
| ResolveItem | ResolveItem
| CommentInputItem
type BaseActivityItem = { type BaseActivityItem = {
id: string id: string
contract: Contract contract: Contract
} }
export type CommentInputItem = BaseActivityItem & {
type: 'commentInput'
bets: Bet[]
commentsByBetId: Record<string, Comment>
}
export type DescriptionItem = BaseActivityItem & { export type DescriptionItem = BaseActivityItem & {
type: 'description' type: 'description'
} }
@ -48,7 +55,7 @@ export type BetItem = BaseActivityItem & {
export type CommentItem = BaseActivityItem & { export type CommentItem = BaseActivityItem & {
type: 'comment' type: 'comment'
comment: Comment comment: Comment
bet: Bet bet: Bet | undefined
hideOutcome: boolean hideOutcome: boolean
truncate: boolean truncate: boolean
smallAvatar: boolean smallAvatar: boolean
@ -249,6 +256,47 @@ function getAnswerGroups(
return answerGroups return answerGroups
} }
function groupBetsAndComments(
bets: Bet[],
comments: Comment[],
contract: Contract,
userId: string | undefined,
options: {
hideOutcome: boolean
abbreviated: boolean
smallAvatar: boolean
reversed: boolean
}
) {
const commentsWithoutBets = comments
.filter((comment) => !comment.betId)
.map((comment) => ({
type: 'comment' as const,
id: comment.id,
contract: contract,
comment,
bet: undefined,
truncate: false,
hideOutcome: true,
smallAvatar: false,
}))
const groupedBets = groupBets(bets, comments, contract, userId, options)
// iterate through the bets and comment activity items and add them to the items in order of comment creation time:
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
const sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => {
if (item.type === 'comment') {
return item.comment.createdTime
} else if (item.type === 'bet') {
return item.bet.createdTime
} else if (item.type === 'betgroup') {
return item.bets[0].createdTime
}
})
return sortedBetsAndComments
}
export function getAllContractActivityItems( export function getAllContractActivityItems(
contract: Contract, contract: Contract,
bets: Bet[], bets: Bet[],
@ -279,9 +327,9 @@ export function getAllContractActivityItems(
] ]
: [{ type: 'description', id: '0', contract }] : [{ type: 'description', id: '0', contract }]
if (outcomeType === 'FREE_RESPONSE') {
items.push( items.push(
...(outcomeType === 'FREE_RESPONSE' ...getAnswerGroups(
? getAnswerGroups(
contract as FullContract<DPM, FreeResponse>, contract as FullContract<DPM, FreeResponse>,
bets, bets,
comments, comments,
@ -292,13 +340,17 @@ export function getAllContractActivityItems(
reversed, reversed,
} }
) )
: groupBets(bets, comments, contract, user?.id, { )
} else {
items.push(
...groupBetsAndComments(bets, comments, contract, user?.id, {
hideOutcome: false, hideOutcome: false,
abbreviated, abbreviated,
smallAvatar: false, smallAvatar: false,
reversed: false, reversed,
})) })
) )
}
if (contract.closeTime && contract.closeTime <= Date.now()) { if (contract.closeTime && contract.closeTime <= Date.now()) {
items.push({ type: 'close', id: `${contract.closeTime}`, contract }) items.push({ type: 'close', id: `${contract.closeTime}`, contract })
@ -307,6 +359,15 @@ export function getAllContractActivityItems(
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract }) items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
} }
const commentsByBetId = mapCommentsByBetId(comments)
items.push({
type: 'commentInput',
id: 'commentInput',
bets,
commentsByBetId,
contract,
})
if (reversed) items.reverse() if (reversed) items.reverse()
return items return items
@ -348,7 +409,7 @@ export function getRecentContractActivityItems(
reversed: false, reversed: false,
} }
) )
: groupBets(bets, comments, contract, user?.id, { : groupBetsAndComments(bets, comments, contract, user?.id, {
hideOutcome: false, hideOutcome: false,
abbreviated: true, abbreviated: true,
smallAvatar: false, smallAvatar: false,

View File

@ -47,6 +47,7 @@ import { useSaveSeenContract } from '../../hooks/use-seen-contracts'
import { User } from '../../../common/user' import { User } from '../../../common/user'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { trackClick } from '../../lib/firebase/tracking' import { trackClick } from '../../lib/firebase/tracking'
import { firebaseLogin } from '../../lib/firebase/users'
import { DAY_MS } from '../../../common/util/time' import { DAY_MS } from '../../../common/util/time'
import NewContractBadge from '../new-contract-badge' import NewContractBadge from '../new-contract-badge'
@ -107,24 +108,30 @@ function FeedItem(props: { item: ActivityItem }) {
return <FeedClose {...item} /> return <FeedClose {...item} />
case 'resolve': case 'resolve':
return <FeedResolve {...item} /> return <FeedResolve {...item} />
case 'commentInput':
return <CommentInput {...item} />
} }
} }
export function FeedComment(props: { export function FeedComment(props: {
contract: Contract contract: Contract
comment: Comment comment: Comment
bet: Bet bet: Bet | undefined
hideOutcome: boolean hideOutcome: boolean
truncate: boolean truncate: boolean
smallAvatar: boolean smallAvatar: boolean
}) { }) {
const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props
const { amount, outcome } = bet let money: string | undefined
let outcome: string | undefined
let bought: string | undefined
if (bet) {
outcome = bet.outcome
bought = bet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(bet.amount))
}
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
const bought = amount >= 0 ? 'bought' : 'sold'
const money = formatMoney(Math.abs(amount))
return ( return (
<> <>
<Avatar <Avatar
@ -147,7 +154,7 @@ export function FeedComment(props: {
{' '} {' '}
of{' '} of{' '}
<OutcomeLabel <OutcomeLabel
outcome={outcome} outcome={outcome ? outcome : ''}
contract={contract} contract={contract}
truncate="short" truncate="short"
/> />
@ -177,6 +184,78 @@ function RelativeTimestamp(props: { time: number }) {
) )
} }
export function CommentInput(props: {
contract: Contract
commentsByBetId: Record<string, Comment>
bets: Bet[]
}) {
// see if we can comment input on any bet:
const { contract, bets, commentsByBetId } = props
const { outcomeType } = contract
const user = useUser()
const [comment, setComment] = useState('')
if (outcomeType === 'FREE_RESPONSE') {
return <div />
}
let canCommentOnABet = false
bets.some((bet) => {
// make sure there is not already a comment with a matching bet id:
const matchingComment = commentsByBetId[bet.id]
if (matchingComment) {
return false
}
const { createdTime, userId } = bet
canCommentOnABet = canCommentOnBet(userId, createdTime, user)
return canCommentOnABet
})
if (canCommentOnABet) return <div />
async function submitComment() {
if (!comment) return
if (!user) {
return await firebaseLogin()
}
await createComment(contract.id, comment, user)
setComment('')
}
return (
<>
<div>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div>
<div className={'min-w-0 flex-1 py-1.5'}>
<div className="text-sm text-gray-500">
<div className="mt-2">
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
placeholder="Add a comment..."
rows={3}
maxLength={MAX_COMMENT_LENGTH}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
submitComment()
}
}}
/>
<button
className="btn btn-outline btn-sm mt-1"
onClick={submitComment}
>
Comment
</button>
</div>
</div>
</div>
</>
)
}
export function FeedBet(props: { export function FeedBet(props: {
contract: Contract contract: Contract
bet: Bet bet: Bet
@ -188,14 +267,12 @@ export function FeedBet(props: {
const { id, amount, outcome, createdTime, userId } = bet const { id, amount, outcome, createdTime, userId } = bet
const user = useUser() const user = useUser()
const isSelf = user?.id === userId const isSelf = user?.id === userId
const canComment = canCommentOnBet(userId, createdTime, user)
// You can comment if your bet was posted in the last hour
const canComment = isSelf && Date.now() - createdTime < 60 * 60 * 1000
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
async function submitComment() { async function submitComment() {
if (!user || !comment || !canComment) return if (!user || !comment || !canComment) return
await createComment(contract.id, id, comment, user) await createComment(contract.id, comment, user, id)
} }
const bought = amount >= 0 ? 'bought' : 'sold' const bought = amount >= 0 ? 'bought' : 'sold'
@ -378,6 +455,16 @@ export function FeedQuestion(props: {
) )
} }
function canCommentOnBet(
userId: string,
createdTime: number,
user?: User | null
) {
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour
return isSelf && Date.now() - createdTime < 60 * 60 * 1000
}
function FeedDescription(props: { contract: Contract }) { function FeedDescription(props: { contract: Contract }) {
const { contract } = props const { contract } = props
const { creatorName, creatorUsername } = contract const { creatorName, creatorUsername } = contract

View File

@ -2,7 +2,6 @@ import { Binary, CPMM, DPM, FullContract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { useUserContractBets } from '../hooks/use-user-bets' import { useUserContractBets } from '../hooks/use-user-bets'
import { useState } from 'react' import { useState } from 'react'
import { useSaveShares } from './use-save-shares'
import { Col } from './layout/col' import { Col } from './layout/col'
import clsx from 'clsx' import clsx from 'clsx'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
@ -10,23 +9,13 @@ import { SellSharesModal } from './sell-modal'
export function SellButton(props: { export function SellButton(props: {
contract: FullContract<DPM | CPMM, Binary> contract: FullContract<DPM | CPMM, Binary>
user: User | null | undefined user: User | null | undefined
sharesOutcome: 'YES' | 'NO' | undefined
shares: number
}) { }) {
const { contract, user } = props const { contract, user, sharesOutcome, shares } = props
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false) const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract const { mechanism } = contract
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
contract,
userBets
)
const floorShares = yesFloorShares || noFloorShares
const sharesOutcome = yesFloorShares
? 'YES'
: noFloorShares
? 'NO'
: undefined
if (sharesOutcome && user && mechanism === 'cpmm-1') { if (sharesOutcome && user && mechanism === 'cpmm-1') {
return ( return (
@ -45,14 +34,14 @@ export function SellButton(props: {
{'Sell ' + sharesOutcome} {'Sell ' + sharesOutcome}
</button> </button>
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{'(' + floorShares + ' shares)'} {'(' + Math.floor(shares) + ' shares)'}
</div> </div>
{showSellModal && ( {showSellModal && (
<SellSharesModal <SellSharesModal
contract={contract as FullContract<CPMM, Binary>} contract={contract as FullContract<CPMM, Binary>}
user={user} user={user}
userBets={userBets ?? []} userBets={userBets ?? []}
shares={yesShares || noShares} shares={shares}
sharesOutcome={sharesOutcome} sharesOutcome={sharesOutcome}
setOpen={setShowSellModal} setOpen={setShowSellModal}
/> />

View File

@ -1,16 +1,9 @@
import { import { httpsCallable } from 'firebase/functions'
getFunctions,
httpsCallable,
connectFunctionsEmulator,
} from 'firebase/functions'
import { Fold } from '../../../common/fold' import { Fold } from '../../../common/fold'
import { User } from '../../../common/user' import { User } from '../../../common/user'
import { randomString } from '../../../common/util/random' import { randomString } from '../../../common/util/random'
import './init' import './init'
import { functions } from './init'
const functions = getFunctions()
// Uncomment to connect to local emulators:
// connectFunctionsEmulator(functions, 'localhost', 5001)
export const cloudFunction = <RequestData, ResponseData>(name: string) => export const cloudFunction = <RequestData, ResponseData>(name: string) =>
httpsCallable<RequestData, ResponseData>(functions, name) httpsCallable<RequestData, ResponseData>(functions, name)
@ -74,3 +67,9 @@ export const changeUserInfo = (data: {
.then((r) => r.data as { status: string; message?: string }) .then((r) => r.data as { status: string; message?: string })
.catch((e) => ({ status: 'error', message: e.message })) .catch((e) => ({ status: 'error', message: e.message }))
} }
export const addLiquidity = (data: { amount: number; contractId: string }) => {
return cloudFunction('addLiquidity')(data)
.then((r) => r.data as { status: string })
.catch((e) => ({ status: 'error', message: e.message }))
}

View File

@ -19,16 +19,16 @@ export const MAX_COMMENT_LENGTH = 10000
export async function createComment( export async function createComment(
contractId: string, contractId: string,
betId: string,
text: string, text: string,
commenter: User commenter: User,
betId?: string
) { ) {
const ref = doc(getCommentsCollection(contractId), betId) const ref = betId
? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId))
const comment: Comment = { const comment: Comment = {
id: ref.id, id: ref.id,
contractId, contractId,
betId,
userId: commenter.id, userId: commenter.id,
text: text.slice(0, MAX_COMMENT_LENGTH), text: text.slice(0, MAX_COMMENT_LENGTH),
createdTime: Date.now(), createdTime: Date.now(),
@ -36,7 +36,9 @@ export async function createComment(
userUsername: commenter.username, userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl, userAvatarUrl: commenter.avatarUrl,
} }
if (betId) {
comment.betId = betId
}
return await setDoc(ref, comment) return await setDoc(ref, comment)
} }
@ -67,8 +69,10 @@ export function listenForComments(
export function mapCommentsByBetId(comments: Comment[]) { export function mapCommentsByBetId(comments: Comment[]) {
const map: Record<string, Comment> = {} const map: Record<string, Comment> = {}
for (const comment of comments) { for (const comment of comments) {
if (comment.betId) {
map[comment.betId] = comment map[comment.betId] = comment
} }
}
return map return map
} }

View File

@ -1,8 +1,26 @@
import { getFirestore } from '@firebase/firestore' import { getFirestore } from '@firebase/firestore'
import { initializeApp, getApps, getApp } from 'firebase/app' import { initializeApp, getApps, getApp } from 'firebase/app'
import { FIREBASE_CONFIG } from '../../../common/envs/constants' import { FIREBASE_CONFIG } from '../../../common/envs/constants'
import { connectFirestoreEmulator } from 'firebase/firestore'
import { connectFunctionsEmulator, getFunctions } from 'firebase/functions'
// Initialize Firebase // Initialize Firebase
export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG)
export const db = getFirestore()
export const functions = getFunctions()
export const db = getFirestore(app) const EMULATORS_STARTED = 'EMULATORS_STARTED'
function startEmulators() {
// I don't like this but this is the only way to reconnect to the emulators without error, see: https://stackoverflow.com/questions/65066963/firebase-firestore-emulator-error-host-has-been-set-in-both-settings-and-usee
// @ts-ignore
if (!global[EMULATORS_STARTED]) {
// @ts-ignore
global[EMULATORS_STARTED] = true
connectFirestoreEmulator(db, 'localhost', 8080)
connectFunctionsEmulator(functions, 'localhost', 5001)
}
}
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
startEmulators()
}

View File

@ -7,6 +7,7 @@
"devdev": "NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=DEV next dev -p 3000\" \"FIREBASE_ENV=DEV yarn ts --watch\" # see https://github.com/vercel/next.js/discussions/33634", "devdev": "NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=DEV next dev -p 3000\" \"FIREBASE_ENV=DEV yarn ts --watch\" # see https://github.com/vercel/next.js/discussions/33634",
"dev:dev": "yarn devdev", "dev:dev": "yarn devdev",
"dev:the": "NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"FIREBASE_ENV=THEOREMONE yarn ts --watch\"", "dev:the": "NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"FIREBASE_ENV=THEOREMONE yarn ts --watch\"",
"dev:emulate": "NEXT_PUBLIC_FIREBASE_EMULATE=TRUE yarn devdev",
"ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty", "ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
@ -53,7 +54,7 @@
"pretty-quick": "3.1.2", "pretty-quick": "3.1.2",
"tailwindcss": "3.0.1", "tailwindcss": "3.0.1",
"tsc-files": "1.1.3", "tsc-files": "1.1.3",
"typescript": "4.5.2" "typescript": "4.5.3"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx}": "tsc-files --noEmit --incremental false" "*.{ts,tsx}": "tsc-files --noEmit --incremental false"

View File

@ -250,7 +250,10 @@ function ContractTopTrades(props: {
const topBettor = useUserById(betsById[topBetId]?.userId) const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit // And also the commentId of the comment with the highest profit
const topCommentId = _.sortBy(comments, (c) => -profitById[c.betId])[0]?.id const topCommentId = _.sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return ( return (
<div className="mt-12 max-w-sm"> <div className="mt-12 max-w-sm">

View File

@ -15,6 +15,10 @@
"jsx": "preserve", "jsx": "preserve",
"incremental": true "incremental": true
}, },
"watchOptions": {
"excludeDirectories": [".next"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../common/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../common/**/*.ts"],
"exclude": ["node_modules", ".next"] "exclude": ["node_modules"]
} }

File diff suppressed because it is too large Load Diff

View File

@ -5198,11 +5198,6 @@ typedarray-to-buffer@^3.1.5:
dependencies: dependencies:
is-typedarray "^1.0.0" is-typedarray "^1.0.0"
typescript@4.5.2:
version "4.5.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998"
integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==
typescript@4.5.3: typescript@4.5.3:
version "4.5.3" version "4.5.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.3.tgz#afaa858e68c7103317d89eb90c5d8906268d353c" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.3.tgz#afaa858e68c7103317d89eb90c5d8906268d353c"