Merge branch 'main' into range-markets

This commit is contained in:
mantikoros 2022-05-18 17:25:29 -04:00
commit fcdfd01664
120 changed files with 2515 additions and 1947 deletions

View File

@ -63,8 +63,10 @@ export function getCpmmLiquidityFee(
bet: number,
outcome: string
) {
const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet)
const betP = outcome === 'YES' ? 1 - prob : prob
const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet)
const probMid = Math.sqrt(probBefore * probAfter)
const betP = outcome === 'YES' ? 1 - probMid : probMid
const liquidityFee = LIQUIDITY_FEE * betP * bet
const platformFee = PLATFORM_FEE * betP * bet

View File

@ -1,21 +1,17 @@
export const CATEGORIES = {
politics: 'Politics',
technology: 'Technology',
sports: 'Sports',
gaming: 'Gaming',
manifold: 'Manifold',
science: 'Science',
world: 'World',
fun: 'Fun',
personal: 'Personal',
sports: 'Sports',
economics: 'Economics',
personal: 'Personal',
culture: 'Culture',
manifold: 'Manifold',
covid: 'Covid',
crypto: 'Crypto',
health: 'Health',
// entertainment: 'Entertainment',
// society: 'Society',
// friends: 'Friends / Community',
// business: 'Business',
// charity: 'Charities / Non-profits',
gaming: 'Gaming',
fun: 'Fun',
} as { [category: string]: string }
export const TO_CATEGORY = Object.fromEntries(

View File

@ -39,11 +39,12 @@ export const PROD_CONFIG: EnvConfig = {
'akrolsmir@gmail.com', // Austin
'jahooma@gmail.com', // James
'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold
],
visibility: 'PUBLIC',
moneyMoniker: 'M$',
moneyMoniker: 'ϻ',
navbarLogoPath: '',
faviconPath: '/favicon.ico',
newQuestionPlaceholders: [

View File

@ -45,8 +45,6 @@ export function getNewContract(
? getNumericProps(ante, bucketCount, min, max)
: getFreeAnswerProps(ante)
const volume = outcomeType === 'BINARY' ? 0 : ante
const contract: Contract = removeUndefinedProps({
id,
slug,
@ -66,7 +64,7 @@ export function getNewContract(
createdTime: Date.now(),
closeTime,
volume,
volume: 0,
volume24Hours: 0,
volume7Days: 0,

View File

@ -35,4 +35,5 @@ export type PrivateUser = {
unsubscribedFromGenericEmails?: boolean
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string
}

View File

@ -42,8 +42,6 @@ export function createRNG(seed: string) {
export const shuffle = (array: any[], rand: () => number) => {
for (let i = 0; i < array.length; i++) {
const swapIndex = Math.floor(rand() * (array.length - i))
const temp = array[i]
array[i] = array[swapIndex]
array[swapIndex] = temp
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]
}
}

View File

@ -21,6 +21,9 @@ service cloud.firestore {
match /private-users/{userId} {
allow read: if resource.data.id == request.auth.uid || isAdmin();
allow update: if (resource.data.id == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey']);
}
match /private-users/{userId}/views/{viewId} {

View File

@ -20,6 +20,7 @@
"main": "lib/functions/src/index.js",
"dependencies": {
"@react-query-firebase/firestore": "0.4.2",
"cors": "2.8.5",
"fetch": "1.1.0",
"firebase-admin": "10.0.0",
"firebase-functions": "3.16.0",

View File

@ -1,11 +1,11 @@
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 { 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'
import { getNewLiquidityProvision } from '../../common/add-liquidity'
export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
async (

129
functions/src/api.ts Normal file
View File

@ -0,0 +1,129 @@
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import * as Cors from 'cors'
import { User, PrivateUser } from 'common/user'
type Request = functions.https.Request
type Response = functions.Response
type Handler = (req: Request, res: Response) => Promise<any>
type AuthedUser = [User, PrivateUser]
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials
export class APIError {
code: number
msg: string
constructor(code: number, msg: string) {
this.code = code
this.msg = msg
}
}
export const parseCredentials = async (req: Request): Promise<Credentials> => {
const authHeader = req.get('Authorization')
if (!authHeader) {
throw new APIError(403, 'Missing Authorization header.')
}
const authParts = authHeader.split(' ')
if (authParts.length !== 2) {
throw new APIError(403, 'Invalid Authorization header.')
}
const [scheme, payload] = authParts
switch (scheme) {
case 'Bearer':
try {
const jwt = await admin.auth().verifyIdToken(payload)
if (!jwt.user_id) {
throw new APIError(403, 'JWT must contain Manifold user ID.')
}
return { kind: 'jwt', data: jwt }
} catch (err) {
// This is somewhat suspicious, so get it into the firebase console
functions.logger.error('Error verifying Firebase JWT: ', err)
throw new APIError(403, `Error validating token: ${err}.`)
}
case 'Key':
return { kind: 'key', data: payload }
default:
throw new APIError(403, 'Invalid auth scheme; must be "Key" or "Bearer".')
}
}
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
const firestore = admin.firestore()
const users = firestore.collection('users')
const privateUsers = firestore.collection('private-users')
switch (creds.kind) {
case 'jwt': {
const { user_id } = creds.data
const [userSnap, privateUserSnap] = await Promise.all([
users.doc(user_id).get(),
privateUsers.doc(user_id).get(),
])
if (!userSnap.exists || !privateUserSnap.exists) {
throw new APIError(403, 'No user exists with the provided ID.')
}
const user = userSnap.data() as User
const privateUser = privateUserSnap.data() as PrivateUser
return [user, privateUser]
}
case 'key': {
const key = creds.data
const privateUserQ = await privateUsers.where('apiKey', '==', key).get()
if (privateUserQ.empty) {
throw new APIError(403, `No private user exists with API key ${key}.`)
}
const privateUserSnap = privateUserQ.docs[0]
const userSnap = await users.doc(privateUserSnap.id).get()
if (!userSnap.exists) {
throw new APIError(403, `No user exists with ID ${privateUserSnap.id}.`)
}
const user = userSnap.data() as User
const privateUser = privateUserSnap.data() as PrivateUser
return [user, privateUser]
}
default:
throw new APIError(500, 'Invalid credential type.')
}
}
export const CORS_ORIGIN_MANIFOLD = /^https?:\/\/.+\.manifold\.markets$/
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
export const applyCors = (req: any, res: any, params: object) => {
return new Promise((resolve, reject) => {
Cors(params)(req, res, (result) => {
if (result instanceof Error) {
return reject(result)
}
return resolve(result)
})
})
}
export const newEndpoint = (methods: [string], fn: Handler) =>
functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => {
await applyCors(req, res, {
origins: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
methods: methods,
})
try {
if (!methods.includes(req.method)) {
const allowed = methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`)
}
const data = await fn(req, res)
data.status = 'success'
res.status(200).json({ data: data })
} catch (e) {
if (e instanceof APIError) {
// Emit a 200 anyway here for now, for backwards compatibility
res.status(200).json({ data: { status: 'error', message: e.msg } })
} else {
res.status(500).json({ data: { status: 'error', message: '???' } })
}
}
})

View File

@ -2,12 +2,12 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { Contract } from 'common/contract'
import { Comment } from 'common/comment'
import { User } from 'common/user'
import { cleanUsername } from 'common/util/clean-username'
import { removeUndefinedProps } from 'common/util/object'
import { Answer } from 'common/answer'
import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment'
import { User } from '../../common/user'
import { cleanUsername } from '../../common/util/clean-username'
import { removeUndefinedProps } from '../../common/util/object'
import { Answer } from '../../common/answer'
export const changeUserInfo = functions
.runWith({ minInstances: 1 })

View File

@ -1,10 +1,15 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract, DPM, FreeResponse, FullContract } from 'common/contract'
import { User } from 'common/user'
import { getNewMultiBetInfo } from 'common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from 'common/answer'
import {
Contract,
DPM,
FreeResponse,
FullContract,
} from '../../common/contract'
import { User } from '../../common/user'
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'

View File

@ -1,8 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { chargeUser, getUser } from './utils'
import {
Binary,
Contract,
@ -14,11 +11,13 @@ import {
MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH,
Numeric,
outcomeType,
} from 'common/contract'
import { slugify } from 'common/util/slugify'
import { randomString } from 'common/util/random'
import { getNewContract } from 'common/new-contract'
} from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { chargeUser } from './utils'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import {
FIXED_ANTE,
getAnteBets,
@ -27,209 +26,190 @@ import {
getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
MINIMUM_ANTE,
} from 'common/antes'
import { getNoneAnswer } from 'common/answer'
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
import { getNewContract } from 'common/new-contract'
import { NUMERIC_BUCKET_COUNT } from 'common/numeric-constants'
export const createContract = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
question: string
outcomeType: outcomeType
description: string
initialProb: number
ante: number
closeTime: number
tags?: string[]
min?: number
max?: number
manaLimitPerUser?: number
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
export const createContract = newEndpoint(['POST'], async (req, _res) => {
const [creator, _privateUser] = await lookupUser(await parseCredentials(req))
let {
question,
outcomeType,
description,
initialProb,
closeTime,
tags,
min,
max,
manaLimitPerUser,
} = req.body.data || {}
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
if (!question || typeof question != 'string')
throw new APIError(400, 'Missing or invalid question field')
let {
question,
description,
initialProb,
closeTime,
tags,
min,
max,
manaLimitPerUser,
} = data
question = question.slice(0, MAX_QUESTION_LENGTH)
if (!question || typeof question != 'string')
return { status: 'error', message: 'Missing or invalid question field' }
question = question.slice(0, MAX_QUESTION_LENGTH)
if (typeof description !== 'string')
throw new APIError(400, 'Invalid description field')
if (typeof description !== 'string')
return { status: 'error', message: 'Invalid description field' }
description = description.slice(0, MAX_DESCRIPTION_LENGTH)
description = description.slice(0, MAX_DESCRIPTION_LENGTH)
if (tags !== undefined && !_.isArray(tags))
return { status: 'error', message: 'Invalid tags field' }
tags = tags?.map((tag) => tag.toString().slice(0, MAX_TAG_LENGTH))
if (tags !== undefined && !Array.isArray(tags))
throw new APIError(400, 'Invalid tags field')
let outcomeType = data.outcomeType ?? 'BINARY'
if (
!['BINARY', 'MULTI', 'FREE_RESPONSE', 'NUMERIC'].includes(outcomeType)
)
return { status: 'error', message: 'Invalid outcomeType' }
if (
outcomeType === 'NUMERIC' &&
!(
min !== undefined &&
max !== undefined &&
isFinite(min) &&
isFinite(max) &&
min < max &&
max - min > 0.01
)
)
return { status: 'error', message: 'Invalid range' }
if (
outcomeType === 'BINARY' &&
(!initialProb || initialProb < 1 || initialProb > 99)
)
return { status: 'error', message: 'Invalid initial probability' }
// uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', userId)
.where('createdTime', '>=', today)
.get()
const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE // data.ante
if (
ante === undefined ||
ante < MINIMUM_ANTE ||
(ante > creator.balance && !isFree) ||
isNaN(ante) ||
!isFinite(ante)
)
return { status: 'error', message: 'Invalid ante' }
console.log(
'creating contract for',
creator.username,
'on',
question,
'ante:',
ante || 0
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
creator,
question,
outcomeType,
description,
initialProb,
ante,
closeTime,
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 100,
manaLimitPerUser ?? 0
)
if (!isFree && ante) await chargeUser(creator.id, ante, true)
await contractRef.create(contract)
if (ante) {
if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const noBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const { yesBet, noBet } = getAnteBets(
creator,
contract as FullContract<DPM, Binary>,
yesBetDoc.id,
noBetDoc.id
)
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'BINARY') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id
const lp = getCpmmInitialLiquidity(
providerId,
contract as FullContract<CPMM, Binary>,
liquidityDoc.id,
ante
)
await liquidityDoc.set(lp)
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
const noneAnswer = getNoneAnswer(contract.id, creator)
await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getFreeAnswerAnte(
creator,
contract as FullContract<DPM, FreeResponse>,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
} else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getNumericAnte(
creator,
contract as FullContract<DPM, Numeric>,
ante,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
}
}
return { status: 'success', contract }
}
tags = (tags || []).map((tag: string) =>
tag.toString().slice(0, MAX_TAG_LENGTH)
)
outcomeType = outcomeType ?? 'BINARY'
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
throw new APIError(400, 'Invalid outcomeType')
if (
outcomeType === 'NUMERIC' &&
!(
min !== undefined &&
max !== undefined &&
isFinite(min) &&
isFinite(max) &&
min < max &&
max - min > 0.01
)
)
throw new APIError(400, 'Invalid range')
if (
outcomeType === 'BINARY' &&
(!initialProb || initialProb < 1 || initialProb > 99)
)
throw new APIError(400, 'Invalid initial probability')
// uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', creator.id)
.where('createdTime', '>=', today)
.get()
const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE
if (
ante === undefined ||
ante < MINIMUM_ANTE ||
(ante > creator.balance && !isFree) ||
isNaN(ante) ||
!isFinite(ante)
)
throw new APIError(400, 'Invalid ante')
console.log(
'creating contract for',
creator.username,
'on',
question,
'ante:',
ante || 0
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
creator,
question,
outcomeType,
description,
initialProb,
ante,
closeTime,
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0,
manaLimitPerUser ?? 0
)
if (!isFree && ante) await chargeUser(creator.id, ante, true)
await contractRef.create(contract)
if (ante) {
if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const noBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const { yesBet, noBet } = getAnteBets(
creator,
contract as FullContract<DPM, Binary>,
yesBetDoc.id,
noBetDoc.id
)
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'BINARY') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id
const lp = getCpmmInitialLiquidity(
providerId,
contract as FullContract<CPMM, Binary>,
liquidityDoc.id,
ante
)
await liquidityDoc.set(lp)
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
const noneAnswer = getNoneAnswer(contract.id, creator)
await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getFreeAnswerAnte(
creator,
contract as FullContract<DPM, FreeResponse>,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
} else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getNumericAnte(
creator,
contract as FullContract<DPM, Numeric>,
ante,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
}
}
return { contract: contract }
})
const getSlug = async (question: string) => {
const proposedSlug = slugify(question)

View File

@ -3,10 +3,10 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getUser } from './utils'
import { Contract } from 'common/contract'
import { slugify } from 'common/util/slugify'
import { randomString } from 'common/util/random'
import { Fold } from 'common/fold'
import { Contract } from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Fold } from '../../common/fold'
export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
async (

View File

@ -6,12 +6,15 @@ import {
STARTING_BALANCE,
SUS_STARTING_BALANCE,
User,
} from 'common/user'
} from '../../common/user'
import { getUser, getUserByUsername } from './utils'
import { randomString } from 'common/util/random'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { randomString } from '../../common/util/random'
import {
cleanDisplayName,
cleanUsername,
} from '../../common/util/clean-username'
import { sendWelcomeEmail } from './emails'
import { isWhitelisted } from 'common/envs/constants'
import { isWhitelisted } from '../../common/envs/constants'
export const createUser = functions
.runWith({ minInstances: 1 })

View File

@ -302,7 +302,7 @@
font-family: Arial, sans-serif;
font-size: 18px;
"
>Best of luck with you forecasting!</span
>Best of luck with your forecasting!</span
>
</p>
<p

View File

@ -1,19 +1,15 @@
import * as _ from 'lodash'
import { DOMAIN, PROJECT_ID } from 'common/envs/constants'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { getProbability } from 'common/calculate'
import { getValueFromBucket } from 'common/calculate-dpm'
import { Comment } from 'common/comment'
import {
Contract,
FreeResponseContract,
NumericContract,
} from 'common/contract'
import { DPM_CREATOR_FEE } from 'common/fees'
import { PrivateUser, User } from 'common/user'
import { formatMoney, formatPercent } from 'common/util/format'
import { DOMAIN, PROJECT_ID } from '../../common/envs/constants'
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment'
import { Contract, FreeResponseContract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format'
import { getValueFromBucket } from '../../common/calculate-dpm'
import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
@ -253,7 +249,8 @@ export const sendNewCommentEmail = async (
contract: Contract,
comment: Comment,
bet?: Bet,
answer?: Answer
answerText?: string,
answerId?: string
) => {
const privateUser = await getPrivateUser(userId)
if (
@ -282,9 +279,8 @@ export const sendNewCommentEmail = async (
const subject = `Comment on ${question}`
const from = `${commentorName} <info@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerText = answer?.text ?? ''
const answerNumber = `#${answer?.id ?? ''}`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerNumber = `#${answerId}`
await sendTemplateEmail(
privateUser.email,

View File

@ -1,19 +1,5 @@
/* This use of module-alias hackily simulates the Typescript base URL so that
* the Firebase deploy machinery, which just uses the compiled Javascript in the
* lib directory, will be able to do imports from the root directory
* (i.e. "common/foo" instead of "../../../common/foo") just like we can in
* Typescript-land.
*
* Note that per the module-alias docs, this need to come before any other
* imports in order to work.
*
* Suggested by https://github.com/firebase/firebase-tools/issues/986 where many
* people complain about this problem.
*/
import { addPath } from 'module-alias'
addPath('./lib')
import * as admin from 'firebase-admin'
admin.initializeApp()
// export * from './keep-awake'

View File

@ -1,7 +1,7 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract } from 'common/contract'
import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils'
import { sendMarketCloseEmail } from './emails'

View File

@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getContract } from './utils'
import { Bet } from 'common/bet'
import { Bet } from '../../common/bet'
const firestore = admin.firestore()

View File

@ -3,10 +3,10 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getContract, getUser, getValues } from './utils'
import { Comment } from 'common/comment'
import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from 'common/bet'
import { Answer } from 'common/answer'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
const firestore = admin.firestore()
@ -34,7 +34,14 @@ export const onCreateComment = functions.firestore
let bet: Bet | undefined
let answer: Answer | undefined
if (comment.betId) {
if (comment.answerOutcome) {
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers?.find(
(answer) => answer.id === comment.answerOutcome
)
: undefined
} else if (comment.betId) {
const betSnapshot = await firestore
.collection('contracts')
.doc(contractId)
@ -66,7 +73,8 @@ export const onCreateComment = functions.firestore
contract,
comment,
bet,
answer
answer?.text,
answer?.id
)
)
)

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { View } from 'common/tracking'
import { View } from '../../common/tracking'
const firestore = admin.firestore()

View File

@ -1,165 +1,136 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import {
getNewBinaryCpmmBetInfo,
getNewBinaryDpmBetInfo,
getNewMultiBetInfo,
getNumericBetsInfo,
} from 'common/new-bet'
import { addObjects, removeUndefinedProps } from 'common/util/object'
import { Bet } from 'common/bet'
} from '../../common/new-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { Bet } from '../../common/bet'
import { redeemShares } from './redeem-shares'
import { Fees } from 'common/fees'
import { Fees } from '../../common/fees'
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
async (
data: {
amount: number
outcome: string
contractId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
export const placeBet = newEndpoint(['POST'], async (req, _res) => {
const [bettor, _privateUser] = await lookupUser(await parseCredentials(req))
const { amount, outcome, contractId } = req.body.data || {}
const { amount, outcome, contractId } = data
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
throw new APIError(400, 'Invalid amount')
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome))
throw new APIError(400, 'Invalid outcome')
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome))
return { status: 'error', message: 'Invalid outcome' }
// run as transaction to prevent race conditions
return await firestore
.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${bettor.id}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
// 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) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
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
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed')
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', bettor.id)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId)
const loanAmount = 0 // getLoanAmount(yourBets, amount)
if (user.balance < amount) throw new APIError(400, 'Insufficient balance')
if (outcomeType === 'FREE_RESPONSE') {
const answerSnap = await transaction.get(
contractDoc.collection('answers').doc(outcome)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
if (!answerSnap.exists) throw new APIError(400, 'Invalid contract')
}
const loanAmount = 0 // getLoanAmount(yourBets, amount)
if (user.balance < amount)
return { status: 'error', message: 'Insufficient balance' }
const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
if (outcomeType === 'FREE_RESPONSE') {
const answerSnap = await transaction.get(
contractDoc.collection('answers').doc(outcome)
)
if (!answerSnap.exists)
return { status: 'error', message: 'Invalid contract' }
}
const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
const {
newBet,
newBets,
newPool,
newTotalShares,
newTotalBets,
newBalance,
newTotalLiquidity,
fees,
newP,
} =
outcomeType === 'BINARY'
? mechanism === 'dpm-2'
? getNewBinaryDpmBetInfo(
user,
outcome as 'YES' | 'NO',
amount,
contract,
loanAmount,
newBetDoc.id
)
: (getNewBinaryCpmmBetInfo(
user,
outcome as 'YES' | 'NO',
amount,
contract,
loanAmount,
newBetDoc.id
) as any)
: outcomeType === 'NUMERIC' && mechanism === 'dpm-2'
? getNumericBetsInfo(user, outcome, amount, contract, newBetDoc.id)
: getNewMultiBetInfo(
const {
newBet,
newPool,
newTotalShares,
newTotalBets,
newBalance,
newTotalLiquidity,
fees,
newP,
} =
outcomeType === 'BINARY'
? mechanism === 'dpm-2'
? getNewBinaryDpmBetInfo(
user,
outcome,
outcome as 'YES' | 'NO',
amount,
contract as any,
contract,
loanAmount,
newBetDoc.id
)
: (getNewBinaryCpmmBetInfo(
user,
outcome as 'YES' | 'NO',
amount,
contract,
loanAmount,
newBetDoc.id
) as any)
: outcomeType === 'NUMERIC' && mechanism === 'dpm-2'
? getNumericBetsInfo(user, outcome, amount, contract, newBetDoc.id)
: getNewMultiBetInfo(
user,
outcome,
amount,
contract as any,
loanAmount,
newBetDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
return {
status: 'error',
message: 'Trade rejected due to overflow error.',
}
}
if (newP !== undefined && !isFinite(newP)) {
throw new APIError(400, 'Trade rejected due to overflow error.')
}
if (newBet) transaction.create(newBetDoc, newBet)
transaction.create(newBetDoc, newBet)
if (newBets) {
for (let newBet of newBets) {
const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalShares: newTotalShares,
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects<Fees>(fees ?? {}, collectedFees ?? {}),
volume: volume + Math.abs(amount),
})
)
transaction.create(newBetDoc, { id: newBetDoc.id, ...newBet })
}
}
if (!isFinite(newBalance)) {
throw new APIError(500, 'Invalid user balance for ' + user.username)
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalShares: newTotalShares,
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects<Fees>(fees ?? {}, collectedFees ?? {}),
volume: volume + Math.abs(amount),
})
)
transaction.update(userDoc, { balance: newBalance })
if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
return { status: 'success', betId: newBetDoc.id }
})
.then(async (result) => {
await redeemShares(userId, contractId)
return result
})
}
)
return { betId: newBetDoc.id }
})
.then(async (result) => {
await redeemShares(bettor.id, contractId)
return result
})
})
const firestore = admin.firestore()

View File

@ -1,12 +1,12 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { Bet } from 'common/bet'
import { getProbability } from 'common/calculate'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Binary, CPMM, FullContract } from 'common/contract'
import { noFees } from 'common/fees'
import { User } from 'common/user'
import { Binary, CPMM, FullContract } from '../../common/contract'
import { noFees } from '../../common/fees'
import { User } from '../../common/user'
export const redeemShares = async (userId: string, contractId: string) => {
return await firestore.runTransaction(async (transaction) => {

View File

@ -2,9 +2,9 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { Bet } from 'common/bet'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils'
import { sendMarketResolutionEmail } from './emails'
import {
@ -12,9 +12,9 @@ import {
getPayouts,
groupPayoutsByUser,
Payout,
} from 'common/payouts'
import { removeUndefinedProps } from 'common/util/object'
import { LiquidityProvision } from 'common/liquidity-provision'
} from '../../common/payouts'
import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
export const resolveMarket = functions
.runWith({ minInstances: 1 })

View File

@ -5,9 +5,9 @@ import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { View } from 'common/tracking'
import { User } from 'common/user'
import { batchedWaitAll } from 'common/util/promise'
import { View } from '../../../common/tracking'
import { User } from '../../../common/user'
import { batchedWaitAll } from '../../../common/util/promise'
const firestore = admin.firestore()

View File

@ -4,9 +4,9 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Bet } from 'common/bet'
import { getDpmProbability } from 'common/calculate-dpm'
import { Binary, Contract, DPM, FullContract } from 'common/contract'
import { Bet } from '../../../common/bet'
import { getDpmProbability } from '../../../common/calculate-dpm'
import { Binary, Contract, DPM, FullContract } from '../../../common/contract'
type DocRef = admin.firestore.DocumentReference
const firestore = admin.firestore()

View File

@ -4,7 +4,7 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { PrivateUser, STARTING_BALANCE, User } from 'common/user'
import { PrivateUser, STARTING_BALANCE, User } from '../../../common/user'
const firestore = admin.firestore()

View File

@ -5,10 +5,10 @@ import * as fs from 'fs'
import { initAdmin } from './script-init'
initAdmin()
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'
import { getValues } from '../utils'
import { Comment } from 'common/comment'
import { Comment } from '../../../common/comment'
const firestore = admin.firestore()

View File

@ -5,7 +5,7 @@ import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { Fold } from 'common/fold'
import { Fold } from '../../../common/fold'
async function lowercaseFoldTags() {
const firestore = admin.firestore()

View File

@ -4,7 +4,7 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Contract } from 'common/contract'
import { Contract } from '../../../common/contract'
const firestore = admin.firestore()

View File

@ -4,8 +4,8 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'
type DocRef = admin.firestore.DocumentReference

View File

@ -4,13 +4,22 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Binary, Contract, CPMM, DPM, FullContract } from 'common/contract'
import { Bet } from 'common/bet'
import { calculateDpmPayout, getDpmProbability } from 'common/calculate-dpm'
import { User } from 'common/user'
import { getCpmmInitialLiquidity } from 'common/antes'
import { noFees } from 'common/fees'
import { addObjects } from 'common/util/object'
import {
Binary,
Contract,
CPMM,
DPM,
FullContract,
} from '../../../common/contract'
import { Bet } from '../../../common/bet'
import {
calculateDpmPayout,
getDpmProbability,
} from '../../../common/calculate-dpm'
import { User } from '../../../common/user'
import { getCpmmInitialLiquidity } from '../../../common/antes'
import { noFees } from '../../../common/fees'
import { addObjects } from '../../../common/util/object'
type DocRef = admin.firestore.DocumentReference

View File

@ -4,11 +4,14 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Binary, Contract, DPM, FullContract } from 'common/contract'
import { Bet } from 'common/bet'
import { calculateDpmShares, getDpmProbability } from 'common/calculate-dpm'
import { getSellBetInfo } from 'common/sell-bet'
import { User } from 'common/user'
import { Binary, Contract, DPM, FullContract } from '../../../common/contract'
import { Bet } from '../../../common/bet'
import {
calculateDpmShares,
getDpmProbability,
} from '../../../common/calculate-dpm'
import { getSellBetInfo } from '../../../common/sell-bet'
import { User } from '../../../common/user'
type DocRef = admin.firestore.DocumentReference

View File

@ -4,10 +4,10 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { getLoanPayouts, getPayouts } from 'common/payouts'
import { filterDefined } from 'common/util/array'
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'
import { getLoanPayouts, getPayouts } from '../../../common/payouts'
import { filterDefined } from '../../../common/util/array'
type DocRef = admin.firestore.DocumentReference

View File

@ -4,8 +4,8 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'
type DocRef = admin.firestore.DocumentReference

View File

@ -4,8 +4,8 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
import { Bet } from '../../../common/bet'
import { Contract } from '../../../common/contract'
import { getValues } from '../utils'
async function removeAnswerAnte() {

View File

@ -4,7 +4,7 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Contract } from 'common/contract'
import { Contract } from '../../../common/contract'
import { getValues } from '../utils'
const firestore = admin.firestore()

View File

@ -4,8 +4,8 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Contract } from 'common/contract'
import { parseTags } from 'common/util/parse'
import { Contract } from '../../../common/contract'
import { parseTags } from '../../../common/util/parse'
import { getValues } from '../utils'
async function updateContractTags() {

View File

@ -5,9 +5,9 @@ import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { User } from 'common/user'
import { batchedWaitAll } from 'common/util/promise'
import { Contract } from 'common/contract'
import { User } from '../../../common/user'
import { batchedWaitAll } from '../../../common/util/promise'
import { Contract } from '../../../common/contract'
import { updateWordScores } from '../update-recommendations'
import { computeFeed } from '../update-feed'
import { getFeedContracts, getTaggedContracts } from '../get-feed-data'

View File

@ -4,9 +4,9 @@ import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Contract } from 'common/contract'
import { Contract } from '../../../common/contract'
import { getValues } from '../utils'
import { Comment } from 'common/comment'
import { Comment } from '../../../common/comment'
async function updateLastCommentTime() {
const firestore = admin.firestore()

View File

@ -1,12 +1,12 @@
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { Bet } from 'common/bet'
import { getSellBetInfo } from 'common/sell-bet'
import { addObjects, removeUndefinedProps } from 'common/util/object'
import { Fees } from 'common/fees'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { Bet } from '../../common/bet'
import { getSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { Fees } from '../../common/fees'
export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall(
async (

View File

@ -2,12 +2,12 @@ import * as _ from 'lodash'
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import { Binary, CPMM, FullContract } from 'common/contract'
import { User } from 'common/user'
import { getCpmmSellBetInfo } from 'common/sell-bet'
import { addObjects, removeUndefinedProps } from 'common/util/object'
import { Binary, CPMM, FullContract } from '../../common/contract'
import { User } from '../../common/user'
import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues } from './utils'
import { Bet } from 'common/bet'
import { Bet } from '../../common/bet'
export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall(
async (

View File

@ -54,7 +54,7 @@ export const createCheckoutSession = functions
}
const referrer =
req.query.referer || req.headers.referer || 'https://mantic.markets'
req.query.referer || req.headers.referer || 'https://manifold.markets'
const session = await stripe.checkout.sessions.create({
metadata: {

View File

@ -1,9 +1,9 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from 'common/user'
import { Txn } from 'common/txn'
import { removeUndefinedProps } from 'common/util/object'
import { User } from '../../common/user'
import { Txn } from '../../common/txn'
import { removeUndefinedProps } from '../../common/util/object'
export const transact = functions
.runWith({ minInstances: 1 })

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getUser } from './utils'
import { PrivateUser } from 'common/user'
import { PrivateUser } from '../../common/user'
export const unsubscribe = functions
.runWith({ minInstances: 1 })

View File

@ -3,9 +3,9 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getValues } from './utils'
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
import { batchedWaitAll } from 'common/util/promise'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { batchedWaitAll } from '../../common/util/promise'
const firestore = admin.firestore()

View File

@ -3,9 +3,9 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getValue, getValues } from './utils'
import { Contract } from 'common/contract'
import { logInterpolation } from 'common/util/math'
import { DAY_MS } from 'common/util/time'
import { Contract } from '../../common/contract'
import { logInterpolation } from '../../common/util/math'
import { DAY_MS } from '../../common/util/time'
import {
getProbability,
getOutcomeProbability,
@ -15,7 +15,7 @@ import { User } from '../../common/user'
import {
getContractScore,
MAX_FEED_CONTRACTS,
} from 'common/recommended-contracts'
} from '../../common/recommended-contracts'
import { callCloudFunction } from './call-cloud-function'
import {
getFeedContracts,
@ -26,18 +26,25 @@ import { CATEGORY_LIST } from '../../common/categories'
const firestore = admin.firestore()
const BATCH_SIZE = 30
const MAX_BATCHES = 50
const getUserBatches = async () => {
const users = _.shuffle(await getValues<User>(firestore.collection('users')))
let userBatches: User[][] = []
for (let i = 0; i < users.length; i += BATCH_SIZE) {
userBatches.push(users.slice(i, i + BATCH_SIZE))
}
console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length)
return userBatches.slice(0, MAX_BATCHES)
}
export const updateFeed = functions.pubsub
.schedule('every 60 minutes')
.schedule('every 15 minutes')
.onRun(async () => {
const users = await getValues<User>(firestore.collection('users'))
const batchSize = 100
let userBatches: User[][] = []
for (let i = 0; i < users.length; i += batchSize) {
userBatches.push(users.slice(i, i + batchSize))
}
console.log('updating feed batch')
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map((users) =>
@ -72,13 +79,7 @@ export const updateFeedBatch = functions.https.onCall(
export const updateCategoryFeed = functions.https.onCall(
async (data: { category: string }) => {
const { category } = data
const users = await getValues<User>(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))
}
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map(async (users) => {

View File

@ -3,12 +3,12 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getValue, getValues } from './utils'
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
import { User } from 'common/user'
import { ClickEvent } from 'common/tracking'
import { getWordScores } from 'common/recommended-contracts'
import { batchedWaitAll } from 'common/util/promise'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { ClickEvent } from '../../common/tracking'
import { getWordScores } from '../../common/recommended-contracts'
import { batchedWaitAll } from '../../common/util/promise'
import { callCloudFunction } from './call-cloud-function'
const firestore = admin.firestore()

View File

@ -3,11 +3,11 @@ import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getValues } from './utils'
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
import { User } from 'common/user'
import { batchedWaitAll } from 'common/util/promise'
import { calculatePayout } from 'common/calculate'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { batchedWaitAll } from '../../common/util/promise'
import { calculatePayout } from '../../common/calculate'
const firestore = admin.firestore()

View File

@ -1,7 +1,7 @@
import * as admin from 'firebase-admin'
import { Contract } from 'common/contract'
import { PrivateUser, User } from 'common/user'
import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user'
export const isProd =
admin.instanceId().app.options.projectId === 'mantic-markets'

View File

@ -1,14 +1,10 @@
import clsx from 'clsx'
import _ from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { formatMoney, formatWithCommas } from 'common/util/format'
import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { Bet } from 'common/bet'
import { Spacer } from './layout/spacer'
import { calculateCpmmSale } from 'common/calculate-cpmm'
import { Binary, CPMM, FullContract } from 'common/contract'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants'
export function AmountInput(props: {
amount: number | undefined
@ -20,7 +16,6 @@ export function AmountInput(props: {
inputClassName?: string
// Needed to focus the amount input
inputRef?: React.MutableRefObject<any>
children?: any
}) {
const {
amount,
@ -31,7 +26,6 @@ export function AmountInput(props: {
className,
inputClassName,
inputRef,
children,
} = props
const onAmountChange = (str: string) => {
@ -78,8 +72,6 @@ export function AmountInput(props: {
)}
</div>
)}
{children}
</Col>
)
}
@ -129,7 +121,7 @@ export function BuyAmountInput(props: {
<AmountInput
amount={amount}
onChange={onAmountChange}
label="M$"
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
@ -138,88 +130,3 @@ export function BuyAmountInput(props: {
/>
)
}
export function SellAmountInput(props: {
contract: FullContract<CPMM, Binary>
amount: number | undefined
onChange: (newAmount: number | undefined) => void
userBets: Bet[]
error: string | undefined
setError: (error: string | undefined) => void
disabled?: boolean
className?: string
inputClassName?: string
// Needed to focus the amount input
inputRef?: React.MutableRefObject<any>
}) {
const {
contract,
amount,
onChange,
userBets,
error,
setError,
disabled,
className,
inputClassName,
inputRef,
} = props
const user = useUser()
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = _.partition(
openUserBets,
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares),
]
const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined
const shares = Math.round(yesShares) || Math.round(noShares)
const sharesSold = Math.min(amount ?? 0, shares)
const { saleValue } = calculateCpmmSale(
contract,
sharesSold,
sellOutcome as 'YES' | 'NO'
)
const onAmountChange = (amount: number | undefined) => {
onChange(amount)
// Check for errors.
if (amount !== undefined) {
if (amount > shares) {
setError(`Maximum ${formatWithCommas(Math.floor(shares))} shares`)
} else {
setError(undefined)
}
}
}
return (
<AmountInput
amount={amount}
onChange={onAmountChange}
label="Qty"
error={error}
disabled={disabled}
className={className}
inputClassName={inputClassName}
inputRef={inputRef}
>
{user && (
<Col className="gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500">
Sale proceeds{' '}
<span className="text-neutral">{formatMoney(saleValue)}</span>
</Row>
</Col>
)}
</AmountInput>
)
}

View File

@ -1,5 +1,4 @@
import clsx from 'clsx'
import _ from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid'

View File

@ -1,5 +1,4 @@
import clsx from 'clsx'
import _ from 'lodash'
import { Answer } from 'common/answer'
import { DPM, FreeResponse, FullContract } from 'common/contract'
@ -47,7 +46,7 @@ export function AnswerItem(props: {
wasResolvedTo
? resolution === 'MKT'
? 'mb-2 bg-blue-50'
: 'mb-8 bg-green-50'
: 'mb-10 bg-green-50'
: chosenProb === undefined
? 'bg-gray-50'
: showChoice === 'radio'

View File

@ -1,5 +1,5 @@
import _ from 'lodash'
import { useLayoutEffect, useState } from 'react'
import React, { useLayoutEffect, useState } from 'react'
import { DPM, FreeResponse, FullContract } from 'common/contract'
import { Col } from '../layout/col'
@ -11,11 +11,19 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
import { FeedItems } from '../feed/feed-items'
import { ActivityItem } from '../feed/activity-items'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer'
import clsx from 'clsx'
import { formatPercent } from 'common/util/format'
import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector'
export function AnswersPanel(props: {
contract: FullContract<DPM, FreeResponse>
@ -108,12 +116,17 @@ export function AnswersPanel(props: {
))}
{!resolveOption && (
<FeedItems
contract={contract}
items={answerItems}
className={'pr-2 md:pr-0'}
betRowClassName={''}
/>
<div className={clsx('flow-root pr-2 md:pr-0')}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{answerItems.map((item, activityItemIdx) => (
<div key={item.id} className={'relative pb-2'}>
<div className="relative flex items-start space-x-3">
<OpenAnswer {...item} />
</div>
</div>
))}
</div>
</div>
)}
{answers.length <= 1 && (
@ -167,3 +180,72 @@ function getAnswerItems(
})
.filter((group) => group.answer)
}
function OpenAnswer(props: {
contract: FullContract<any, FreeResponse>
answer: Answer
items: ActivityItem[]
type: string
}) {
const { answer, contract } = props
const { username, avatarUrl, name, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
return (
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
<Modal open={open} setOpen={setOpen}>
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setOpen(false)}
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
isModal={true}
/>
</Modal>
<div
className="pointer-events-none absolute -mx-2 h-full rounded-tl-md bg-green-600 bg-opacity-10"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
<Row className="my-4 gap-3">
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</div>
</Row>
</Col>
</Col>
</Row>
</Col>
)
}

View File

@ -1,5 +1,4 @@
import clsx from 'clsx'
import _ from 'lodash'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import _ from 'lodash'
import React, { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
@ -16,7 +17,7 @@ import { Title } from './title'
import { firebaseLogin, User } from 'web/lib/firebase/users'
import { Bet } from 'common/bet'
import { placeBet, sellShares } from 'web/lib/firebase/api-call'
import { BuyAmountInput, SellAmountInput } from './amount-input'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label'
import {
@ -66,7 +67,7 @@ export function BetPanel(props: {
<div className="mb-6 text-2xl">Place your bet</div>
{/* <Title className={clsx('!mt-0 text-neutral')} text="Place a trade" /> */}
<BuyPanel contract={contract} user={user} userBets={userBets ?? []} />
<BuyPanel contract={contract} user={user} />
{user === null && (
<button
@ -150,8 +151,7 @@ export function BetPanelSwitcher(props: {
<Col
className={clsx(
'rounded-b-md bg-white px-8 py-6',
!sharesOutcome && 'rounded-t-md',
className
!sharesOutcome && 'rounded-t-md'
)}
>
<Title
@ -177,7 +177,6 @@ export function BetPanelSwitcher(props: {
<BuyPanel
contract={contract}
user={user}
userBets={userBets ?? []}
selected={selected}
onBuySuccess={onBetSuccess}
/>
@ -199,11 +198,10 @@ export function BetPanelSwitcher(props: {
function BuyPanel(props: {
contract: FullContract<DPM | CPMM, Binary>
user: User | null | undefined
userBets: Bet[]
selected?: 'YES' | 'NO'
onBuySuccess?: () => void
}) {
const { contract, user, userBets, selected, onBuySuccess } = props
const { contract, user, selected, onBuySuccess } = props
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
@ -215,7 +213,7 @@ function BuyPanel(props: {
useEffect(() => {
// warm up cloud function
placeBet({}).catch()
placeBet({}).catch(() => {})
}, [])
useEffect(() => {
@ -321,11 +319,11 @@ function BuyPanel(props: {
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div>
<Row>
<div>{formatPercent(initialProb)}</div>
<div className="mx-2"></div>
<div>{formatPercent(resultProb)}</div>
</Row>
<div>
{formatPercent(initialProb)}
<span className="mx-2"></span>
{formatPercent(resultProb)}
</div>
</Row>
<Row className="items-center justify-between gap-2 text-sm">
@ -350,12 +348,12 @@ function BuyPanel(props: {
{dpmTooltip && <InfoTooltip text={dpmTooltip} />}
</Row>
<Row className="flex-wrap items-end justify-end gap-2">
<span className="whitespace-nowrap">
<div>
<span className="mr-2 whitespace-nowrap">
{formatMoney(currentPayout)}
</span>
<span>(+{currentReturnPercent})</span>
</Row>
(+{currentReturnPercent})
</div>
</Row>
</Col>
@ -437,11 +435,43 @@ export function SellPanel(props: {
)
const resultProb = getCpmmProbability(newPool, contract.p)
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = _.partition(
openUserBets,
(bet) => bet.outcome === 'YES'
)
const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares),
]
const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined
const ownedShares = Math.round(yesShares) || Math.round(noShares)
const sharesSold = Math.min(amount ?? 0, ownedShares)
const { saleValue } = calculateCpmmSale(
contract,
sharesSold,
sellOutcome as 'YES' | 'NO'
)
const onAmountChange = (amount: number | undefined) => {
setAmount(amount)
// Check for errors.
if (amount !== undefined) {
if (amount > ownedShares) {
setError(`Maximum ${formatWithCommas(Math.floor(ownedShares))} shares`)
} else {
setError(undefined)
}
}
}
return (
<>
<SellAmountInput
inputClassName="w-full"
contract={contract}
<AmountInput
amount={
amount
? Math.round(amount) === 0
@ -449,21 +479,25 @@ export function SellPanel(props: {
: Math.floor(amount)
: undefined
}
onChange={setAmount}
userBets={userBets}
onChange={onAmountChange}
label="Qty"
error={error}
setError={setError}
disabled={isSubmitting}
inputClassName="w-full"
/>
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
<Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500">
Sale proceeds
<span className="text-neutral">{formatMoney(saleValue)}</span>
</Row>
<Row className="items-center justify-between">
<div className="text-gray-500">Probability</div>
<Row>
<div>{formatPercent(initialProb)}</div>
<div className="mx-2"></div>
<div>{formatPercent(resultProb)}</div>
</Row>
<div>
{formatPercent(initialProb)}
<span className="mx-2"></span>
{formatPercent(resultProb)}
</div>
</Row>
</Col>

View File

@ -2,7 +2,6 @@ import { useState } from 'react'
import clsx from 'clsx'
import { BetPanelSwitcher } from './bet-panel'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { Binary, CPMM, DPM, FullContract } from 'common/contract'
import { Modal } from './layout/modal'
@ -16,8 +15,9 @@ export default function BetRow(props: {
contract: FullContract<DPM | CPMM, Binary>
className?: string
btnClassName?: string
betPanelClassName?: string
}) {
const { className, btnClassName, contract } = props
const { className, btnClassName, betPanelClassName, contract } = props
const [open, setOpen] = useState(false)
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(
undefined
@ -32,7 +32,7 @@ export default function BetRow(props: {
return (
<>
<YesNoSelector
className={clsx('mt-2 justify-end', className)}
className={clsx('justify-end', className)}
btnClassName={clsx('btn-sm w-24', btnClassName)}
onSelect={(choice) => {
setOpen(true)
@ -41,6 +41,7 @@ export default function BetRow(props: {
replaceNoButton={
yesFloorShares > 0 ? (
<SellButton
panelClassName={betPanelClassName}
contract={contract}
user={user}
sharesOutcome={'YES'}
@ -51,6 +52,7 @@ export default function BetRow(props: {
replaceYesButton={
noFloorShares > 0 ? (
<SellButton
panelClassName={betPanelClassName}
contract={contract}
user={user}
sharesOutcome={'NO'}
@ -61,6 +63,7 @@ export default function BetRow(props: {
/>
<Modal open={open} setOpen={setOpen}>
<BetPanelSwitcher
className={betPanelClassName}
contract={contract}
title={contract.question}
selected={betChoice}

View File

@ -560,7 +560,10 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
)
}
const warmUpSellBet = _.throttle(() => sellBet({}).catch(), 5000 /* ms */)
const warmUpSellBet = _.throttle(
() => sellBet({}).catch(() => {}),
5000 /* ms */
)
function SellButton(props: { contract: Contract; bet: Bet }) {
useEffect(() => {

View File

@ -3,6 +3,7 @@ import {
InstantSearch,
SearchBox,
SortBy,
useCurrentRefinements,
useInfiniteHits,
useRange,
useRefinementList,
@ -17,10 +18,12 @@ import {
} from '../hooks/use-sort-and-query-params'
import { ContractsGrid } from './contract/contracts-list'
import { Row } from './layout/row'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Spacer } from './layout/spacer'
import { useRouter } from 'next/router'
import { ENV } from 'common/envs/constants'
import { CategorySelector } from './feed/category-selector'
import { useUser } from 'web/hooks/use-user'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -44,15 +47,18 @@ export function ContractSearch(props: {
querySortOptions?: {
defaultSort: Sort
defaultFilter?: filter
filter?: {
creatorId?: string
tag?: string
}
shouldLoadFromStorage?: boolean
}
additionalFilter?: {
creatorId?: string
tag?: string
category?: string
}
showCategorySelector: boolean
}) {
const { querySortOptions } = props
const { querySortOptions, additionalFilter, showCategorySelector } = props
const user = useUser()
const { initialSort } = useInitialQueryAndSort(querySortOptions)
const sort = sortIndexes
@ -65,49 +71,59 @@ export function ContractSearch(props: {
querySortOptions?.defaultFilter ?? 'open'
)
const [category, setCategory] = useState<string>('all')
if (!sort) return <></>
return (
<InstantSearch
searchClient={searchClient}
indexName={`${indexPrefix}contracts-${sort}`}
key={`search-${
querySortOptions?.filter?.tag ??
querySortOptions?.filter?.creatorId ??
''
additionalFilter?.tag ?? additionalFilter?.creatorId ?? ''
}`}
>
<Row className="flex-wrap gap-2">
<Row className="gap-1 sm:gap-2">
<SearchBox
className="flex-1"
classNames={{
form: 'before:top-6',
input: '!pl-10 !input !input-bordered shadow-none',
resetIcon: 'mt-2',
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
resetIcon: 'mt-2 hidden sm:flex',
}}
/>
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
placeholder="Search markets"
/>
<Row className="mt-2 gap-2 sm:mt-0">
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
/>
</Row>
</Row>
<Spacer h={3} />
{showCategorySelector && (
<CategorySelector
className="mb-2"
user={user}
category={category}
setCategory={setCategory}
/>
)}
<ContractSearchInner
querySortOptions={querySortOptions}
filter={filter}
additionalFilter={{ category, ...additionalFilter }}
/>
</InstantSearch>
)
@ -116,15 +132,16 @@ export function ContractSearch(props: {
export function ContractSearchInner(props: {
querySortOptions?: {
defaultSort: Sort
filter?: {
creatorId?: string
tag?: string
}
shouldLoadFromStorage?: boolean
}
filter: filter
additionalFilter: {
creatorId?: string
tag?: string
category?: string
}
}) {
const { querySortOptions, filter } = props
const { querySortOptions, filter, additionalFilter } = props
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
const { query, setQuery, setSort } = useUpdateQueryAndSort({
@ -143,18 +160,24 @@ export function ContractSearchInner(props: {
setQuery(query)
}, [query])
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
const sort = index.split('contracts-')[1] as Sort
if (sort) {
setSort(sort)
}
}, [index])
const creatorId = querySortOptions?.filter?.creatorId
const { creatorId, category, tag } = additionalFilter
useFilterCreator(creatorId)
const tag = querySortOptions?.filter?.tag
useFilterTag(tag)
useFilterTag(tag ?? (category === 'all' ? undefined : category))
useFilterClosed(
filter === 'closed'
@ -167,25 +190,21 @@ export function ContractSearchInner(props: {
filter === 'resolved' ? true : filter === 'all' ? undefined : false
)
const { showMore, hits, isLastPage } = useInfiniteHits()
const { showMore, hits, isLastPage, results } = useInfiniteHits()
const contracts = hits as any as Contract[]
const router = useRouter()
const hasLoaded = contracts.length > 0 || router.isReady
return (
<div>
<Spacer h={8} />
if (!hasLoaded || !results) return <></>
{hasLoaded && (
<ContractsGrid
contracts={contracts}
loadMore={showMore}
hasMore={!isLastPage}
showCloseTime={index === 'contracts-closing-soon'}
/>
)}
</div>
return (
<ContractsGrid
contracts={contracts}
loadMore={showMore}
hasMore={!isLastPage}
showCloseTime={index === 'contracts-closing-soon'}
/>
)
}
@ -197,10 +216,15 @@ const useFilterCreator = (creatorId: string | undefined) => {
}
const useFilterTag = (tag: string | undefined) => {
const { items, refine: deleteRefinement } = useCurrentRefinements({
includedAttributes: ['lowercaseTags'],
})
const { refine } = useRefinementList({ attribute: 'lowercaseTags' })
useEffect(() => {
const refinements = items[0]?.refinements ?? []
if (tag) refine(tag.toLowerCase())
}, [tag, refine])
if (refinements[0]) deleteRefinement(refinements[0])
}, [tag])
}
const useFilterClosed = (value: boolean | undefined) => {

View File

@ -1,15 +1,14 @@
import clsx from 'clsx'
import Link from 'next/link'
import _ from 'lodash'
import { Row } from '../layout/row'
import { formatPercent } from 'common/util/format'
import {
Contract,
contractPath,
getBinaryProbPercent,
getBinaryProb,
} from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import {
Binary,
CPMM,
@ -23,11 +22,44 @@ import {
AnswerLabel,
BinaryContractOutcomeLabel,
FreeResponseOutcomeLabel,
OUTCOME_TO_COLOR,
} from '../outcome-label'
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
import { AbbrContractDetails } from './contract-details'
import { getValueFromBucket } from 'common/calculate-dpm'
// Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) {
const { outcomeType, resolution } = contract
return resolution
? 1
: outcomeType === 'BINARY'
? getBinaryProb(contract)
: outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: 1 // Should not happen
}
function getColor(contract: Contract) {
const { resolution } = contract
if (resolution) {
return (
// @ts-ignore; TODO: Have better typing for contract.resolution?
OUTCOME_TO_COLOR[resolution] ||
// If resolved to a FR answer, use 'primary'
'primary'
)
}
const marketClosed = (contract.closeTime || Infinity) < Date.now()
return marketClosed
? 'gray-400'
: getProb(contract) >= 0.5
? 'primary'
: 'red-400'
}
export function ContractCard(props: {
contract: Contract
showHotVolume?: boolean
@ -35,13 +67,18 @@ export function ContractCard(props: {
className?: string
}) {
const { contract, showHotVolume, showCloseTime, className } = props
const { question, outcomeType, resolution } = contract
const { question, outcomeType } = contract
const prob = getProb(contract)
const color = getColor(contract)
const marketClosed = (contract.closeTime || Infinity) < Date.now()
const showTopBar = prob >= 0.5 || marketClosed
return (
<div>
<div
<Col
className={clsx(
'relative rounded-lg bg-white p-6 shadow-md hover:bg-gray-100',
'relative gap-3 rounded-lg bg-white p-6 pr-7 shadow-md hover:bg-gray-100',
className
)}
>
@ -54,35 +91,49 @@ export function ContractCard(props: {
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
<Spacer h={3} />
<Row
className={clsx(
'justify-between gap-4',
outcomeType === 'FREE_RESPONSE' && 'flex-col items-start !gap-2'
)}
>
<p
className="break-words font-medium text-indigo-700"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
>
{question}
</p>
<Row className={clsx('justify-between gap-4')}>
<Col className="gap-3">
<p
className="break-words font-medium text-indigo-700"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
>
{question}
</p>
</Col>
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
)}
</Row>
</div>
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
)}
<div
className={clsx(
'absolute right-0 top-0 w-2 rounded-tr-md',
'bg-gray-200'
)}
style={{ height: `${100 * (1 - prob)}%` }}
></div>
<div
className={clsx(
'absolute right-0 bottom-0 w-2 rounded-br-md',
`bg-${color}`,
// If we're showing the full bar, also round the top
prob === 1 ? 'rounded-tr-md' : ''
)}
style={{ height: `${100 * prob}%` }}
></div>
</Col>
</div>
)
}
@ -94,9 +145,7 @@ export function BinaryResolutionOrChance(props: {
}) {
const { contract, large, className } = props
const { resolution } = contract
const marketClosed = (contract.closeTime || Infinity) < Date.now()
const probColor = marketClosed ? 'text-gray-400' : 'text-primary'
const textColor = `text-${getColor(contract)}`
return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
@ -114,8 +163,8 @@ export function BinaryResolutionOrChance(props: {
</>
) : (
<>
<div className={probColor}>{getBinaryProbPercent(contract)}</div>
<div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
<div className={textColor}>{getBinaryProbPercent(contract)}</div>
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
chance
</div>
</>
@ -133,6 +182,7 @@ export function FreeResponseResolutionOrChance(props: {
const { resolution } = contract
const topAnswer = getTopAnswer(contract)
const textColor = `text-${getColor(contract)}`
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
@ -154,7 +204,7 @@ export function FreeResponseResolutionOrChance(props: {
answer={topAnswer}
truncate={truncate}
/>
<Col className="text-primary text-3xl">
<Col className={clsx('text-3xl', textColor)}>
<div>
{formatPercent(getOutcomeProbability(contract, topAnswer.id))}
</div>

View File

@ -39,7 +39,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()))
const categories = tags.filter((tag) =>
CATEGORY_LIST.includes(tag.toLowerCase())
)
return (
<div
@ -50,9 +52,9 @@ export function ContractDescription(props: {
>
<Linkify text={contract.description} />
{category && (
{categories.length > 0 && (
<div className="mt-4">
<TagsList tags={[category]} label="Category" />
<TagsList tags={categories} noLabel />
</div>
)}

View File

@ -1,7 +1,11 @@
import clsx from 'clsx'
import _ from 'lodash'
import { ClockIcon, DatabaseIcon, PencilIcon } from '@heroicons/react/outline'
import { TrendingUpIcon } from '@heroicons/react/solid'
import {
ClockIcon,
DatabaseIcon,
PencilIcon,
CurrencyDollarIcon,
TrendingUpIcon,
} from '@heroicons/react/outline'
import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format'
import { UserLink } from '../user-page'
@ -19,6 +23,8 @@ import { useState } from 'react'
import { ContractInfoDialog } from './contract-info-dialog'
import { Bet } from 'common/bet'
import NewContractBadge from '../new-contract-badge'
import { CATEGORY_LIST } from 'common/categories'
import { TagsList } from '../tags-list'
export function AbbrContractDetails(props: {
contract: Contract
@ -26,9 +32,19 @@ export function AbbrContractDetails(props: {
showCloseTime?: boolean
}) {
const { contract, showHotVolume, showCloseTime } = props
const { volume, volume24Hours, creatorName, creatorUsername, closeTime } =
contract
const {
volume,
volume24Hours,
creatorName,
creatorUsername,
closeTime,
tags,
} = contract
const { volumeLabel } = contractMetrics(contract)
// Show at most one category that this contract is tagged by
const categories = CATEGORY_LIST.filter((category) =>
tags.map((t) => t.toLowerCase()).includes(category)
).slice(0, 1)
return (
<Col className={clsx('gap-2 text-sm text-gray-500')}>
@ -46,21 +62,28 @@ export function AbbrContractDetails(props: {
/>
</Row>
{showHotVolume ? (
<Row className="gap-1">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
<Row className="gap-1">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
) : volume > 0 ? (
<Row>{volumeLabel}</Row>
) : (
<NewContractBadge />
)}
<Row className="gap-3 text-gray-400">
{categories.length > 0 && (
<TagsList className="text-gray-400" tags={categories} noLabel />
)}
{showHotVolume ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" />{' '}
{formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
<Row className="gap-0.5">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
) : volume > 0 ? (
<Row>{volumeLabel}</Row>
) : (
<NewContractBadge />
)}
</Row>
</Row>
</Col>
)
@ -70,72 +93,73 @@ export function ContractDetails(props: {
contract: Contract
bets: Bet[]
isCreator?: boolean
hideShareButtons?: boolean
disabled?: boolean
}) {
const { contract, bets, isCreator, hideShareButtons } = props
const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername } = contract
const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract)
return (
<Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap">
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-3">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="items-center gap-2">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
noLink={disabled}
size={6}
/>
{disabled ? (
creatorName
) : (
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
</Row>
)}
</Row>
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{/* <DateTimeTooltip text="Market created:" time={contract.createdTime}>
{/* <DateTimeTooltip text="Market created:" time={contract.createdTime}>
{createdDate}
</DateTimeTooltip> */}
{resolvedDate && contract.resolutionTime ? (
<>
{/* {' - '} */}
<DateTimeTooltip
text="Market resolved:"
time={contract.resolutionTime}
>
{resolvedDate}
</DateTimeTooltip>
</>
) : null}
{resolvedDate && contract.resolutionTime ? (
<>
{/* {' - '} */}
<DateTimeTooltip
text="Market resolved:"
time={contract.resolutionTime}
>
{resolvedDate}
</DateTimeTooltip>
</>
) : null}
{!resolvedDate && closeTime && (
<>
{/* {' - '}{' '} */}
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
/>
</>
)}
</Row>
)}
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
{!resolvedDate && closeTime && (
<>
{/* {' - '}{' '} */}
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
/>
</>
)}
</Row>
)}
{!hideShareButtons && (
<ContractInfoDialog contract={contract} bets={bets} />
)}
<Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
</Col>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</Row>
)
}

View File

@ -49,12 +49,15 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<div>Share</div>
<Row className="justify-start gap-4">
<CopyLinkButton contract={contract} />
<CopyLinkButton
contract={contract}
toastClassName={'sm:-left-10 -left-4 min-w-[250%]'}
/>
<TweetButton
className="self-start"
tweetText={getTweetText(contract, false)}
/>
<ShareEmbedButton contract={contract} />
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
</Row>
<div />

View File

@ -1,11 +1,11 @@
import _ from 'lodash'
import { Contract } from '../../lib/firebase/contracts'
import { User } from '../../lib/firebase/users'
import { Col } from '../layout/col'
import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card'
import { ContractSearch } from '../contract-search'
import { useIsVisible } from 'web/hooks/use-is-visible'
import { useEffect, useState } from 'react'
export function ContractsGrid(props: {
contracts: Contract[]
@ -15,6 +15,15 @@ export function ContractsGrid(props: {
}) {
const { contracts, showCloseTime, hasMore, loadMore } = props
const [elem, setElem] = useState<HTMLElement | null>(null)
const isBottomVisible = useIsVisible(elem)
useEffect(() => {
if (isBottomVisible) {
loadMore()
}
}, [isBottomVisible, hasMore, loadMore])
if (contracts.length === 0) {
return (
<p className="mx-2 text-gray-500">
@ -28,7 +37,7 @@ export function ContractsGrid(props: {
return (
<Col className="gap-8">
<ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
<ul className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
{contracts.map((contract) => (
<ContractCard
contract={contract}
@ -37,14 +46,7 @@ export function ContractsGrid(props: {
/>
))}
</ul>
{hasMore && (
<button
className="btn btn-primary self-center normal-case"
onClick={loadMore}
>
Show more
</button>
)}
<div ref={setElem} className="relative -top-96 h-1" />
</Col>
)
}
@ -55,13 +57,14 @@ export function CreatorContractsList(props: { creator: User }) {
return (
<ContractSearch
querySortOptions={{
filter: {
creatorId: creator.id,
},
defaultSort: 'newest',
defaultFilter: 'all',
shouldLoadFromStorage: false,
}}
additionalFilter={{
creatorId: creator.id,
}}
showCategorySelector={false}
/>
)
}

View File

@ -1,4 +1,4 @@
import { Fragment } from 'react'
import React, { Fragment } from 'react'
import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
@ -6,6 +6,7 @@ import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard'
function copyContractUrl(contract: Contract) {
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
@ -14,8 +15,9 @@ function copyContractUrl(contract: Contract) {
export function CopyLinkButton(props: {
contract: Contract
buttonClassName?: string
toastClassName?: string
}) {
const { contract, buttonClassName } = props
const { contract, buttonClassName, toastClassName } = props
return (
<Menu
@ -42,9 +44,9 @@ export function CopyLinkButton(props: {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-center absolute left-0 mt-2 w-40 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Items>
<Menu.Item>
<div className="px-2 py-1">Link copied!</div>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>

View File

@ -12,24 +12,35 @@ import clsx from 'clsx'
import { Row } from './layout/row'
import { ENV_CONFIG } from '../../common/envs/constants'
import { SiteLink } from './site-link'
import { formatMoney } from 'common/util/format'
export function FeedPromo(props: { hotContracts: Contract[] }) {
const { hotContracts } = props
return (
<>
<Col className="my-6 rounded-xl text-center sm:m-12">
<h1 className="text-4xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2">A market for</div>
<span className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text font-bold text-transparent">
every question
</span>
<Col className="mb-6 rounded-xl text-center sm:m-12 sm:mt-0">
<img
height={250}
width={250}
className="self-center"
src="/flappy-logo.gif"
/>
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2">
Bet on{' '}
<span className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text font-bold text-transparent">
any question!
</span>
</div>
</h1>
<Spacer h={6} />
<div className="mb-4 px-2 text-gray-500">
Bet on any topic imaginable. Or create your own market!
Bet on any topic imaginable with play-money markets. Or create your
own!
<br />
Sign up and get M$1,000 - worth $10 to your{' '}
<br />
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
<SiteLink className="font-semibold" href="/charity">
favorite charity.
</SiteLink>

View File

@ -1,5 +1,3 @@
import _ from 'lodash'
import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments'
import { Col } from '../layout/col'

View File

@ -28,7 +28,7 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & {
type: 'commentInput'
betsByCurrentUser: Bet[]
comments: Comment[]
commentsByCurrentUser: Comment[]
answerOutcome?: string
}
@ -72,11 +72,11 @@ export type BetGroupItem = BaseActivityItem & {
}
export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup' | 'answer'
type: 'answergroup'
answer: Answer
items: ActivityItem[]
betsByCurrentUser?: Bet[]
comments?: Comment[]
commentsByCurrentUser?: Comment[]
}
export type CloseItem = BaseActivityItem & {
@ -87,7 +87,6 @@ export type ResolveItem = BaseActivityItem & {
type: 'resolve'
}
export const GENERAL_COMMENTS_OUTCOME_ID = 'General Comments'
const DAY_IN_MS = 24 * 60 * 60 * 1000
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
@ -280,6 +279,7 @@ function getAnswerAndCommentInputGroups(
outcomes = _.sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
)
const betsByCurrentUser = bets.filter((bet) => bet.userId === user?.id)
const answerGroups = outcomes
.map((outcome) => {
@ -293,9 +293,7 @@ function getAnswerAndCommentInputGroups(
comment.answerOutcome === outcome ||
answerBets.some((bet) => bet.id === comment.betId)
)
const items = getCommentThreads(answerBets, answerComments, contract)
if (outcome === GENERAL_COMMENTS_OUTCOME_ID) items.reverse()
const items = getCommentThreads(bets, answerComments, contract)
return {
id: outcome,
@ -304,8 +302,10 @@ function getAnswerAndCommentInputGroups(
answer,
items,
user,
betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id),
comments: answerComments,
betsByCurrentUser,
commentsByCurrentUser: answerComments.filter(
(comment) => comment.userId === user?.id
),
}
})
.filter((group) => group.answer) as ActivityItem[]
@ -433,7 +433,7 @@ export function getAllContractActivityItems(
id: 'commentInput',
contract,
betsByCurrentUser: [],
comments: [],
commentsByCurrentUser: [],
})
} else {
items.push(
@ -459,7 +459,7 @@ export function getAllContractActivityItems(
id: 'commentInput',
contract,
betsByCurrentUser: [],
comments: [],
commentsByCurrentUser: [],
})
}
@ -520,6 +520,15 @@ export function getRecentContractActivityItems(
return [questionItem, ...items]
}
function commentIsGeneralComment(comment: Comment, contract: Contract) {
return (
comment.answerOutcome === undefined &&
(contract.outcomeType === 'FREE_RESPONSE'
? comment.betId === undefined
: true)
)
}
export function getSpecificContractActivityItems(
contract: Contract,
bets: Bet[],
@ -550,8 +559,8 @@ export function getSpecificContractActivityItems(
break
case 'comments':
const nonFreeResponseComments = comments.filter(
(comment) => comment.answerOutcome === undefined
const nonFreeResponseComments = comments.filter((comment) =>
commentIsGeneralComment(comment, contract)
)
const nonFreeResponseBets =
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
@ -567,10 +576,12 @@ export function getSpecificContractActivityItems(
type: 'commentInput',
id: 'commentInput',
contract,
betsByCurrentUser: user
? nonFreeResponseBets.filter((bet) => bet.userId === user.id)
: [],
comments: nonFreeResponseComments,
betsByCurrentUser: nonFreeResponseBets.filter(
(bet) => bet.userId === user?.id
),
commentsByCurrentUser: nonFreeResponseComments.filter(
(comment) => comment.userId === user?.id
),
})
break
case 'free-response-comment-answer-groups':

View File

@ -1,5 +1,4 @@
import clsx from 'clsx'
import _ from 'lodash'
import { User } from '../../../common/user'
import { Row } from '../layout/row'
@ -16,7 +15,7 @@ export function CategorySelector(props: {
return (
<Row
className={clsx(
'mr-2 items-center space-x-2 space-y-2 overflow-x-scroll scroll-smooth pt-4 pb-4 sm:flex-wrap',
'carousel mr-2 items-center space-x-2 space-y-2 overflow-x-scroll pb-4 sm:flex-wrap',
className
)}
>

View File

@ -0,0 +1,61 @@
import { Contract } from 'common/contract'
import React, { useState } from 'react'
import { ENV_CONFIG } from 'common/envs/constants'
import { contractPath } from 'web/lib/firebase/contracts'
import { copyToClipboard } from 'web/lib/util/copy'
import { DateTimeTooltip } from 'web/components/datetime-tooltip'
import Link from 'next/link'
import { fromNow } from 'web/lib/util/time'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { LinkIcon } from '@heroicons/react/outline'
export function CopyLinkDateTimeComponent(props: {
contract: Contract
createdTime: number
elementId: string
}) {
const { contract, elementId, createdTime } = props
const [showToast, setShowToast] = useState(false)
function copyLinkToComment(
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) {
event.preventDefault()
let currentLocation = window.location.href.includes('/home')
? `https://${ENV_CONFIG.domain}${contractPath(contract)}#${elementId}`
: window.location.href
if (currentLocation.includes('#')) {
currentLocation = currentLocation.split('#')[0]
}
copyToClipboard(`${currentLocation}#${elementId}`)
setShowToast(true)
setTimeout(() => setShowToast(false), 2000)
}
return (
<>
<DateTimeTooltip time={createdTime}>
<Link
href={`/${contract.creatorUsername}/${contract.slug}#${elementId}`}
passHref={true}
>
<a
onClick={(event) => copyLinkToComment(event)}
className={'mx-1 cursor-pointer'}
>
<span className="whitespace-nowrap rounded-sm px-1 text-gray-400 hover:bg-gray-100 ">
{fromNow(createdTime)}
{showToast && (
<ToastClipboard className={'left-24 sm:-left-16'} />
)}
<LinkIcon
className="ml-1 mb-0.5 inline-block text-gray-400"
height={13}
/>
</span>
</a>
</Link>
</DateTimeTooltip>
</>
)
}

View File

@ -0,0 +1,190 @@
import { FreeResponse, FullContract } from 'common/contract'
import { Answer } from 'common/answer'
import { ActivityItem } from 'web/components/feed/activity-items'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { formatPercent } from 'common/util/format'
import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector'
import { FeedItem } from 'web/components/feed/feed-items'
import {
CommentInput,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router'
export function FeedAnswerCommentGroup(props: {
contract: FullContract<any, FreeResponse>
answer: Answer
items: ActivityItem[]
type: string
betsByCurrentUser?: Bet[]
commentsByCurrentUser?: Comment[]
}) {
const { answer, items, contract, betsByCurrentUser, commentsByCurrentUser } =
props
const { username, avatarUrl, name, text } = answer
const answerElementId = `answer-${answer.id}`
const user = useUser()
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser ?? [],
commentsByCurrentUser ?? [],
user,
answer.number + ''
)
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const isFreeResponseContractPage = !!commentsByCurrentUser
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
// If they've already opened the input box, focus it once again
function setShowReplyAndFocus(show: boolean) {
setShowReply(show)
inputRef?.focus()
}
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
}
}, [router.asPath])
return (
<Col className={'flex-1 gap-2'}>
<Modal open={open} setOpen={setOpen}>
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setOpen(false)}
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
isModal={true}
/>
</Modal>
<Row
className={clsx(
'my-4 flex gap-3 space-x-3 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)}
id={answerElementId}
>
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
<CopyLinkDateTimeComponent
contract={contract}
createdTime={answer.createdTime}
elementId={answerElementId}
/>
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={
'text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => setShowReplyAndFocus(true)}
>
Reply
</button>
</div>
)}
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</div>
</Row>
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => setShowReplyAndFocus(true)}
>
Reply
</button>
</div>
)}
</Col>
</Row>
{items.map((item, index) => (
<div
key={item.id}
className={clsx(
'relative ml-8',
index !== items.length - 1 && 'pb-4'
)}
>
{index !== items.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-1rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-3">
<FeedItem item={item} />
</div>
</div>
))}
{showReply && (
<div className={'ml-8 pt-4'}>
<CommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser ?? []}
commentsByCurrentUser={commentsByCurrentUser ?? []}
answerOutcome={answer.number + ''}
replyToUsername={answer.username}
setRef={setInputRef}
/>
</div>
)}
</Col>
)
}

View File

@ -0,0 +1,173 @@
import { Contract } from 'common/contract'
import { Bet } from 'common/bet'
import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import clsx from 'clsx'
import { UserIcon, UsersIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { Fragment } from 'react'
import * as _ from 'lodash'
import { JoinSpans } from 'web/components/join-spans'
export function FeedBet(props: {
contract: Contract
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
bettor?: User // If set: reveal bettor identity
}) {
const { contract, bet, hideOutcome, smallAvatar, bettor } = props
const { userId } = bet
const user = useUser()
const isSelf = user?.id === userId
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
)}
<div className={'min-w-0 flex-1 py-1.5'}>
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
/>
</div>
</Row>
</>
)
}
export function BetStatusText(props: {
contract: Contract
bet: Bet
isSelf: boolean
bettor?: User
hideOutcome?: boolean
}) {
const { bet, contract, bettor, isSelf, hideOutcome } = props
const { amount, outcome, createdTime } = bet
const bought = amount >= 0 ? 'bought' : 'sold'
const money = formatMoney(Math.abs(amount))
return (
<div className="text-sm text-gray-500">
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '}
{money}
{!hideOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}
<RelativeTimestamp time={createdTime} />
</div>
)
}
function BetGroupSpan(props: {
contract: Contract
bets: Bet[]
outcome?: string
}) {
const { contract, 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>
{outcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}{' '}
</span>
)
}
export function FeedBetGroup(props: {
contract: Contract
bets: Bet[]
hideOutcome: boolean
}) {
const { contract, bets, hideOutcome } = props
const betGroups = _.groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betGroups)
// Use the time of the last bet for the entire group
const createdTime = bets[bets.length - 1].createdTime
return (
<>
<div>
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
</div>
<div className={clsx('min-w-0 flex-1', outcomes.length === 1 && 'mt-1')}>
<div className="text-sm text-gray-500">
{outcomes.map((outcome, index) => (
<Fragment key={outcome}>
<BetGroupSpan
contract={contract}
outcome={hideOutcome ? undefined : outcome}
bets={betGroups[outcome]}
/>
{index !== outcomes.length - 1 && <br />}
</Fragment>
))}
<RelativeTimestamp time={createdTime} />
</div>
</div>
</>
)
}

View File

@ -0,0 +1,498 @@
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { User } from 'common/user'
import { Contract } from 'common/contract'
import { Dictionary } from 'lodash'
import React, { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { contractPath } from 'web/lib/firebase/contracts'
import { firebaseLogin } from 'web/lib/firebase/users'
import { createComment, MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import Textarea from 'react-expanding-textarea'
import * as _ from 'lodash'
import { Linkify } from 'web/components/linkify'
import { SiteLink } from 'web/components/site-link'
import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col'
import { getOutcomeProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
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) =>
parentComment.id && comment.replyToCommentId === parentComment.id
)
commentsList.unshift(parentComment)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
function scrollAndOpenReplyInput(comment: Comment) {
setReplyToUsername(comment.userUsername)
setShowReply(true)
inputRef?.focus()
}
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<div className={'w-full flex-col flex-col pr-6'}>
{commentsList.map((comment, commentIdx) => (
<div
key={comment.id}
id={comment.id}
className={commentIdx === 0 ? '' : 'mt-4 ml-8'}
>
<FeedComment
contract={contract}
comment={comment}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput}
smallAvatar={smallAvatar}
truncate={truncate}
/>
</div>
))}
{showReply && (
<div className={'ml-8 w-full pt-6'}>
<CommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={comments}
parentComment={parentComment}
replyToUsername={replyToUsername}
answerOutcome={comments[0].answerOutcome}
setRef={setInputRef}
/>
</div>
)}
</div>
)
}
export function FeedComment(props: {
contract: Contract
comment: Comment
betsBySameUser: Bet[]
truncate?: boolean
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
}) {
const {
contract,
comment,
betsBySameUser,
truncate,
smallAvatar,
onReplyClick,
} = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
let betOutcome: string | undefined,
bought: string | undefined,
money: string | undefined
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
if (matchedBet) {
betOutcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
}
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
if (router.asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [router.asPath])
// Only calculated if they don't have a matching bet
const { userPosition, outcome } = getBettorsPosition(
contract,
comment.createdTime,
matchedBet ? [] : betsBySameUser
)
return (
<Row
className={clsx(
'flex space-x-3 transition-all duration-1000',
highlighted ? `-m-2 rounded bg-indigo-500/[0.2] p-2` : ''
)}
>
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
username={userUsername}
avatarUrl={userAvatarUrl}
/>
<div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
{!matchedBet && userPosition > 0 && (
<>
{'is '}
<CommentStatus outcome={outcome} contract={contract} />
</>
)}
<>
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
</>
<CopyLinkDateTimeComponent
contract={contract}
createdTime={createdTime}
elementId={comment.id}
/>
</p>
<TruncatedComment
comment={text}
moreHref={contractPath(contract)}
shouldTruncate={truncate}
/>
{onReplyClick && (
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</div>
</Row>
)
}
export function getMostRecentCommentableBet(
betsByCurrentUser: Bet[],
commentsByCurrentUser: Comment[],
user?: User | null,
answerOutcome?: string
) {
return betsByCurrentUser
.filter((bet) => {
if (
canCommentOnBet(bet, user) &&
!commentsByCurrentUser.some(
(comment) => comment.createdTime > bet.createdTime
)
) {
if (!answerOutcome) return true
// If we're in free response, don't allow commenting on ante bet
return answerOutcome === bet.outcome
}
return false
})
.sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop()
}
function CommentStatus(props: { contract: Contract; outcome: string }) {
const { contract, outcome } = props
return (
<>
{' betting '}
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
{' at ' +
Math.round(getOutcomeProbability(contract, outcome) * 100) +
'%'}
</>
)
}
export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[]
// 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,
commentsByCurrentUser,
answerOutcome,
parentComment,
replyToUsername,
setRef,
} = props
const user = useUser()
const [comment, setComment] = useState('')
const [focused, setFocused] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
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 || isSubmitting) return
setIsSubmitting(true)
await createComment(
contract.id,
comment,
user,
betId,
answerOutcome,
parentComment?.id
)
setComment('')
setFocused(false)
setIsSubmitting(false)
}
const { userPosition, outcome } = getBettorsPosition(
contract,
Date.now(),
betsByCurrentUser
)
const shouldCollapseAfterClickOutside = false
return (
<>
<Row className={'mb-2 flex w-full gap-2'}>
<div className={'mt-1'}>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div>
<div className={'min-w-0 flex-1'}>
<div className="text-sm text-gray-500">
<div className={'mb-1'}>
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={contract.outcomeType === 'FREE_RESPONSE'}
/>
)}
{!mostRecentCommentableBet && user && userPosition > 0 && (
<>
{"You're"}
<CommentStatus outcome={outcome} contract={contract} />
</>
)}
</div>
<Row className="grid grid-cols-8 gap-1.5 text-gray-700">
<Col className={'col-span-4 sm:col-span-6'}>
<Textarea
ref={setRef}
value={comment}
onChange={(e) => setComment(e.target.value)}
className={clsx('textarea textarea-bordered resize-none')}
placeholder={
parentComment || answerOutcome
? 'Write a reply... '
: 'Write a comment...'
}
autoFocus={focused}
rows={focused ? 3 : 1}
onFocus={() => setFocused(true)}
onBlur={() =>
shouldCollapseAfterClickOutside && setFocused(false)
}
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submitComment(id)
e.currentTarget.blur()
}
}}
/>
</Col>
<Col
className={clsx(
'col-span-4 sm:col-span-2',
focused ? 'justify-end' : 'justify-center'
)}
>
{!user && (
<button
className={
'btn btn-outline btn-sm text-transform: capitalize'
}
onClick={() => submitComment(id)}
>
Sign in to Comment
</button>
)}
{user && !isSubmitting && (
<button
className={clsx(
'btn text-transform: block capitalize',
focused && comment
? 'btn-outline btn-sm '
: 'btn-ghost btn-sm pointer-events-none text-gray-500'
)}
onClick={() => {
if (!focused) return
else {
submitComment(id)
}
}}
>
{parentComment || answerOutcome ? 'Reply' : 'Comment'}
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</Col>
</Row>
</div>
</div>
</Row>
</>
)
}
export 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="mt-2 whitespace-pre-line break-words text-gray-700"
style={{ fontSize: 15 }}
>
<Linkify text={truncated} />
{truncated != comment && (
<SiteLink href={moreHref} className="text-indigo-700">
... (show more)
</SiteLink>
)}
</div>
)
}
function getBettorsPosition(
contract: Contract,
createdTime: number,
bets: Bet[]
) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
const emptyReturn = {
userPosition: 0,
outcome: '',
}
const previousBets = bets.filter(
(prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte
)
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerCounts: { [outcome: string]: number } = {}
for (const bet of previousBets) {
if (bet.outcome) {
if (!answerCounts[bet.outcome]) {
answerCounts[bet.outcome] = bet.amount
} else {
answerCounts[bet.outcome] += bet.amount
}
}
}
const majorityAnswer =
_.maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ??
''
return {
userPosition: answerCounts[majorityAnswer] || 0,
outcome: majorityAnswer,
}
}
if (bets.length === 0) {
return emptyReturn
}
const [yesBets, noBets] = _.partition(
previousBets ?? [],
(bet) => bet.outcome === 'YES'
)
yesShares = _.sumBy(yesBets, (bet) => bet.shares)
noShares = _.sumBy(noBets, (bet) => bet.shares)
yesFloorShares = Math.floor(yesShares)
noFloorShares = Math.floor(noShares)
const userPosition = yesFloorShares || noFloorShares
const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO'
return { userPosition, outcome }
}
function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour
return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
}

View File

@ -1,18 +1,14 @@
// From https://tailwindui.com/components/application-ui/lists/feeds
import React, { Fragment, useEffect, useRef, useState } from 'react'
import React, { useState } from 'react'
import * as _ from 'lodash'
import { Dictionary } from 'lodash'
import {
BanIcon,
CheckIcon,
DotsVerticalIcon,
LockClosedIcon,
UserIcon,
UsersIcon,
XIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import Textarea from 'react-expanding-textarea'
import { OutcomeLabel } from '../outcome-label'
import {
@ -21,35 +17,26 @@ import {
contractPath,
tradingAllowed,
} from 'web/lib/firebase/contracts'
import { useUser } from 'web/hooks/use-user'
import { Linkify } from '../linkify'
import { Row } from '../layout/row'
import { createComment, MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import { formatMoney, formatPercent } from 'common/util/format'
import { Comment } from 'common/comment'
import { BinaryResolutionOrChance } from '../contract/contract-card'
import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { UserLink } from '../user-page'
import { Bet } from 'web/lib/firebase/bets'
import { JoinSpans } from '../join-spans'
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, FreeResponse, FullContract } from 'common/contract'
import { BuyButton } from '../yes-no-selector'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { AnswerBetPanel } from '../answers/answer-bet-panel'
import { ActivityItem } from './activity-items'
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
import { User } from 'common/user'
import { Modal } from '../layout/modal'
import { trackClick } from 'web/lib/firebase/tracking'
import { firebaseLogin } from 'web/lib/firebase/users'
import { DAY_MS } from 'common/util/time'
import NewContractBadge from '../new-contract-badge'
import { RelativeTimestamp } from '../relative-timestamp'
import { calculateCpmmSale } from 'common/calculate-cpmm'
import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group'
import {
FeedCommentThread,
FeedComment,
CommentInput,
TruncatedComment,
} from 'web/components/feed/feed-comments'
import { FeedBet, FeedBetGroup } from 'web/components/feed/feed-bets'
export function FeedItems(props: {
contract: Contract
@ -60,19 +47,14 @@ export function FeedItems(props: {
const { contract, items, className, betRowClassName } = props
const { outcomeType } = contract
const ref = useRef<HTMLDivElement | null>(null)
useSaveSeenContract(ref, contract)
const [elem, setElem] = useState<HTMLElement | null>(null)
useSaveSeenContract(elem, contract)
return (
<div className={clsx('flow-root', className)} ref={ref}>
<div className={clsx('flow-root', className)} ref={setElem}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{items.map((item, activityItemIdx) => (
<div
key={item.id}
className={
item.type === 'answer' ? 'relative pb-2' : 'relative pb-6'
}
>
<div key={item.id} className={'relative pb-6'}>
{activityItemIdx !== items.length - 1 ||
item.type === 'answergroup' ? (
<span
@ -93,7 +75,7 @@ export function FeedItems(props: {
)
}
function FeedItem(props: { item: ActivityItem }) {
export function FeedItem(props: { item: ActivityItem }) {
const { item } = props
switch (item.type) {
@ -108,9 +90,7 @@ function FeedItem(props: { item: ActivityItem }) {
case 'betgroup':
return <FeedBetGroup {...item} />
case 'answergroup':
return <FeedAnswerGroup {...item} />
case 'answer':
return <FeedAnswerGroup {...item} />
return <FeedAnswerCommentGroup {...item} />
case 'close':
return <FeedClose {...item} />
case 'resolve':
@ -122,499 +102,6 @@ function FeedItem(props: { item: ActivityItem }) {
}
}
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<HTMLTextAreaElement | null>(null)
function scrollAndOpenReplyInput(comment: Comment) {
setReplyToUsername(comment.userUsername)
setShowReply(true)
inputRef?.focus()
}
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<div className={'w-full flex-col flex-col pr-6'}>
{commentsList.map((comment, commentIdx) => (
<div
key={comment.id}
id={comment.id}
className={clsx(
'flex space-x-3',
commentIdx === 0 ? '' : 'mt-4 ml-8'
)}
>
<FeedComment
contract={contract}
comment={comment}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput}
smallAvatar={smallAvatar}
truncate={truncate}
/>
</div>
))}
{showReply && (
<div className={'ml-8 w-full pt-6'}>
<CommentInput
contract={contract}
// Should we allow replies to contain recent bet info?
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
comments={comments}
parentComment={parentComment}
replyToUsername={replyToUsername}
answerOutcome={comments[0].answerOutcome}
setRef={setInputRef}
/>
</div>
)}
</div>
)
}
export function FeedComment(props: {
contract: Contract
comment: Comment
betsBySameUser: Bet[]
truncate?: boolean
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
}) {
const {
contract,
comment,
betsBySameUser,
truncate,
smallAvatar,
onReplyClick,
} = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
let outcome: string | undefined,
bought: string | undefined,
money: string | undefined
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
if (matchedBet) {
outcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
}
// Only calculated if they don't have a matching bet
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
getBettorsPosition(
contract,
comment.createdTime,
matchedBet ? [] : betsBySameUser
)
return (
<>
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
username={userUsername}
avatarUrl={userAvatarUrl}
/>
<div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
{!matchedBet && userPosition > 0 && (
<>
{'had ' + userPositionMoney + ' '}
<>
{' of '}
<OutcomeLabel
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
contract={contract}
truncate="short"
/>
</>
</>
)}
<>
{bought} {money}
{contract.outcomeType !== 'FREE_RESPONSE' && outcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome ? outcome : ''}
contract={contract}
truncate="short"
/>
</>
)}
</>
<RelativeTimestamp time={createdTime} />
</p>
<TruncatedComment
comment={text}
moreHref={contractPath(contract)}
shouldTruncate={truncate}
/>
{onReplyClick && (
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</div>
</>
)
}
export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
comments: Comment[]
// 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,
parentComment,
replyToUsername,
setRef,
} = props
const user = useUser()
const [comment, setComment] = useState('')
const [focused, setFocused] = useState(false)
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
// 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 (
<>
<Row className={'mb-2 flex w-full gap-2'}>
<div className={'mt-1'}>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div>
<div className={'min-w-0 flex-1'}>
<div className="text-sm text-gray-500">
<div className={'mb-1'}>
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={contract.outcomeType === 'FREE_RESPONSE'}
/>
)}
{!mostRecentCommentableBet && user && userPosition > 0 && (
<>
{'You have ' + userPositionMoney + ' '}
<>
{' of '}
<OutcomeLabel
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
contract={contract}
truncate="short"
/>
</>
</>
)}
</div>
<Row className="gap-1.5">
<Textarea
ref={(ref: HTMLTextAreaElement) => setRef?.(ref)}
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
placeholder={
parentComment || answerOutcome
? 'Write a reply... '
: 'Write a comment...'
}
autoFocus={focused}
rows={focused ? 3 : 1}
onFocus={() => setFocused(true)}
onBlur={() =>
shouldCollapseAfterClickOutside && setFocused(false)
}
maxLength={MAX_COMMENT_LENGTH}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submitComment(id)
}
}}
/>
<div
className={clsx(
'flex justify-center',
focused ? 'items-end' : 'items-center'
)}
>
{!user && (
<button
className={
'btn btn-outline btn-sm text-transform: capitalize'
}
onClick={() => submitComment(id)}
>
Sign in to Comment
</button>
)}
{user && (
<button
className={clsx(
'btn text-transform: block capitalize',
focused && comment
? 'btn-outline btn-sm '
: 'btn-ghost btn-sm text-gray-500'
)}
onClick={() => {
if (!focused) return
else {
submitComment(id)
setFocused(false)
}
}}
>
{parentComment || answerOutcome ? 'Reply' : 'Comment'}
</button>
)}
</div>
</Row>
</div>
</div>
</Row>
</>
)
}
function getBettorsPosition(
contract: Contract,
createdTime: number,
bets: Bet[]
) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
const emptyReturn = {
userPosition: 0,
userPositionMoney: 0,
yesFloorShares,
noFloorShares,
}
// TODO: show which of the answers was their majority stake at time of comment for FR?
if (contract.outcomeType != 'BINARY') {
return emptyReturn
}
if (bets.length === 0) {
return emptyReturn
}
// Calculate the majority shares they had when they made the comment
const betsBefore = bets.filter((prevBet) => prevBet.createdTime < createdTime)
const [yesBets, noBets] = _.partition(
betsBefore ?? [],
(bet) => bet.outcome === 'YES'
)
yesShares = _.sumBy(yesBets, (bet) => bet.shares)
noShares = _.sumBy(noBets, (bet) => bet.shares)
yesFloorShares = Math.floor(yesShares)
noFloorShares = Math.floor(noShares)
const userPosition = yesFloorShares || noFloorShares
const { saleValue } = calculateCpmmSale(
contract as FullContract<CPMM, Binary>,
yesShares || noShares,
yesFloorShares > noFloorShares ? 'YES' : 'NO'
)
const userPositionMoney = formatMoney(Math.abs(saleValue))
return { userPosition, userPositionMoney, yesFloorShares, noFloorShares }
}
export function FeedBet(props: {
contract: Contract
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
bettor?: User // If set: reveal bettor identity
}) {
const { contract, bet, hideOutcome, smallAvatar, bettor } = props
const { userId } = bet
const user = useUser()
const isSelf = user?.id === userId
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
)}
<div className={'min-w-0 flex-1 py-1.5'}>
<BetStatusText
bet={bet}
contract={contract}
isSelf={isSelf}
bettor={bettor}
hideOutcome={hideOutcome}
/>
</div>
</Row>
</>
)
}
function BetStatusText(props: {
contract: Contract
bet: Bet
isSelf: boolean
bettor?: User
hideOutcome?: boolean
}) {
const { bet, contract, bettor, isSelf, hideOutcome } = props
const { amount, outcome, createdTime } = bet
const bought = amount >= 0 ? 'bought' : 'sold'
const money = formatMoney(Math.abs(amount))
return (
<div className="text-sm text-gray-500">
<span>{isSelf ? 'You' : bettor ? bettor.name : 'A trader'}</span> {bought}{' '}
{money}
{!hideOutcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}
<RelativeTimestamp time={createdTime} />
</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="mt-2 whitespace-pre-line break-words text-gray-700"
style={{ fontSize: 15 }}
>
<Linkify text={truncated} />
{truncated != comment && (
<SiteLink href={moreHref} className="text-indigo-700">
... (show more)
</SiteLink>
)}
</div>
)
}
export function FeedQuestion(props: {
contract: Contract
showDescription: boolean
@ -687,44 +174,9 @@ export function FeedQuestion(props: {
)
}
function getMostRecentCommentableBet(
betsByCurrentUser: Bet[],
comments: Comment[],
user?: User | null,
answerOutcome?: string
) {
return 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()
}
function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour
return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
}
function FeedDescription(props: { contract: Contract }) {
const { contract } = props
const { creatorName, creatorUsername } = contract
const user = useUser()
const isCreator = user?.id === contract.creatorId
return (
<>
@ -819,237 +271,6 @@ function FeedClose(props: { contract: Contract }) {
)
}
function BetGroupSpan(props: {
contract: Contract
bets: Bet[]
outcome?: string
}) {
const { contract, 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>
{outcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}{' '}
</span>
)
}
function FeedBetGroup(props: {
contract: Contract
bets: Bet[]
hideOutcome: boolean
}) {
const { contract, bets, hideOutcome } = props
const betGroups = _.groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betGroups)
// Use the time of the last bet for the entire group
const createdTime = bets[bets.length - 1].createdTime
return (
<>
<div>
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
</div>
<div className={clsx('min-w-0 flex-1', outcomes.length === 1 && 'mt-1')}>
<div className="text-sm text-gray-500">
{outcomes.map((outcome, index) => (
<Fragment key={outcome}>
<BetGroupSpan
contract={contract}
outcome={hideOutcome ? undefined : outcome}
bets={betGroups[outcome]}
/>
{index !== outcomes.length - 1 && <br />}
</Fragment>
))}
<RelativeTimestamp time={createdTime} />
</div>
</div>
</>
)
}
function FeedAnswerGroup(props: {
contract: FullContract<any, FreeResponse>
answer: Answer
items: ActivityItem[]
type: string
betsByCurrentUser?: Bet[]
comments?: Comment[]
}) {
const { answer, items, contract, type, betsByCurrentUser, comments } = props
const { username, avatarUrl, name, text } = answer
const user = useUser()
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser ?? [],
comments ?? [],
user,
answer.number + ''
)
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const isFreeResponseContractPage = type === 'answergroup' && comments
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
// If they've already opened the input box, focus it once again
function setShowReplyAndFocus(show: boolean) {
setShowReply(show)
inputRef?.focus()
}
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<Col
className={
type === 'answer'
? 'border-base-200 bg-base-200 flex-1 rounded-md p-3'
: 'flex-1 gap-2'
}
>
<Modal open={open} setOpen={setOpen}>
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setOpen(false)}
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
isModal={true}
/>
</Modal>
{type == 'answer' && (
<div
className="pointer-events-none absolute -m-3 h-full rounded-tl-md bg-green-600 bg-opacity-10"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
></div>
)}
<Row className="my-4 gap-3">
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={
'text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => setShowReplyAndFocus(true)}
>
Reply
</button>
</div>
)}
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</div>
</Row>
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => setShowReplyAndFocus(true)}
>
Reply
</button>
</div>
)}
</Col>
</Row>
{items.map((item, index) => (
<div
key={item.id}
className={clsx(
'relative ml-8',
index !== items.length - 1 && 'pb-4'
)}
>
{index !== items.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-1rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-3">
<FeedItem item={item} />
</div>
</div>
))}
{showReply && (
<div className={'ml-8 pt-4'}>
<CommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser ?? []}
comments={comments ?? []}
answerOutcome={answer.number + ''}
replyToUsername={answer.username}
setRef={setInputRef}
/>
</div>
)}
</Col>
)
}
// TODO: Should highlight the entire Feed segment
function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) {
const { setExpanded } = props

View File

@ -1,7 +1,15 @@
import clsx from 'clsx'
export function Row(props: { children?: any; className?: string }) {
const { children, className } = props
export function Row(props: {
children?: any
className?: string
id?: string
}) {
const { children, className, id } = props
return <div className={clsx(className, 'flex flex-row')}>{children}</div>
return (
<div className={clsx(className, 'flex flex-row')} id={id}>
{children}
</div>
)
}

View File

@ -4,7 +4,12 @@ import { SiteLink } from './site-link'
// Return a JSX span, linkifying @username, #hashtags, and https://...
// TODO: Use a markdown parser instead of rolling our own here.
export function Linkify(props: { text: string; gray?: boolean }) {
const { text, gray } = props
let { text, gray } = props
// Replace "m1234" with "ϻ1234"
const mRegex = /(\W|^)m(\d+)/g
text = text.replace(mRegex, (_, pre, num) => `${pre}ϻ${num}`)
// Find instances of @username, #hashtag, and https://...
const regex =
/(?:^|\s)(?:[@#][a-z0-9_]+|https?:\/\/[-A-Za-z0-9+&@#\/%?=~_()|!:,.;]*[-A-Za-z0-9+&@#\/%=~_|])/gi
const matches = text.match(regex) || []

View File

@ -1,12 +1,18 @@
import clsx from 'clsx'
export function LoadingIndicator(props: { className?: string }) {
const { className } = props
export function LoadingIndicator(props: {
className?: string
spinnerClassName?: string
}) {
const { className, spinnerClassName } = props
return (
<div className={clsx('flex items-center justify-center', className)}>
<div
className="spinner-border inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-500 border-r-transparent"
className={clsx(
'spinner-border inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-500 border-r-transparent',
spinnerClassName
)}
role="status"
/>
</div>

View File

@ -5,6 +5,7 @@ import {
MenuAlt3Icon,
PresentationChartLineIcon,
SearchIcon,
ChatAltIcon,
XIcon,
} from '@heroicons/react/outline'
import { Transition, Dialog } from '@headlessui/react'
@ -29,15 +30,24 @@ export function BottomNavBar() {
</a>
</Link>
<Link href="/markets">
<a className="block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700">
<SearchIcon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
Explore
</a>
</Link>
{user === null ? (
<Link href="/markets">
<a className="block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700">
<SearchIcon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
Explore
</a>
</Link>
) : (
<Link href="/activity">
<a className="block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700">
<ChatAltIcon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
Activity
</a>
</Link>
)}
{user !== null && (
<Link href="/portfolio">
<Link href={`${user}/bets`}>
<a className="block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700">
<PresentationChartLineIcon
className="my-1 mx-auto h-6 w-6"
@ -137,7 +147,7 @@ export function MobileSidebar(props: {
</div>
</Transition.Child>
<div className="mx-2 mt-5 h-0 flex-1 overflow-y-auto">
<Sidebar />
<Sidebar className="pl-2" />
</div>
</div>
</Transition.Child>

View File

@ -1,8 +1,8 @@
import Link from 'next/link'
import { firebaseLogout, User } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { Row } from '../layout/row'
export function getNavigationOptions(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) {
@ -27,18 +27,18 @@ export function getNavigationOptions(user?: User | null) {
]
}
export function ProfileSummary(props: { user: User | undefined }) {
export function ProfileSummary(props: { user: User }) {
const { user } = props
return (
<Row className="group items-center gap-4 rounded-md py-3 text-gray-500 group-hover:bg-gray-100 group-hover:text-gray-700">
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} noLink />
<Link href={`/${user.username}`}>
<a className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700">
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />
<div className="truncate text-left">
<div>{user?.name}</div>
<div className="text-sm">
{user ? formatMoney(Math.floor(user.balance)) : ' '}
<div className="truncate">
<div>{user.name}</div>
<div className="text-sm">{formatMoney(Math.floor(user.balance))}</div>
</div>
</div>
</Row>
</a>
</Link>
)
}

View File

@ -1,14 +1,14 @@
import {
HomeIcon,
UserGroupIcon,
CakeIcon,
SearchIcon,
ChatIcon,
BookOpenIcon,
DotsHorizontalIcon,
CashIcon,
HeartIcon,
PresentationChartLineIcon,
ChatAltIcon,
SparklesIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
import _ from 'lodash'
@ -21,6 +21,7 @@ import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { getNavigationOptions, ProfileSummary } from './profile-menu'
import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today'
import { Row } from '../layout/row'
// Create an icon from the url of an image
function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
@ -29,12 +30,18 @@ function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
}
}
const navigation = [
{ name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Explore', href: '/markets', icon: SearchIcon },
{ name: 'Portfolio', href: '/portfolio', icon: PresentationChartLineIcon },
{ name: 'Charity', href: '/charity', icon: HeartIcon },
]
function getNavigation(username: string) {
return [
{ name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Activity', href: '/activity', icon: ChatAltIcon },
{
name: 'Portfolio',
href: `/${username}/bets`,
icon: PresentationChartLineIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon },
]
}
const signedOutNavigation = [
{ name: 'Home', href: '/home', icon: HomeIcon },
@ -110,7 +117,8 @@ function MoreButton() {
)
}
export default function Sidebar() {
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
@ -119,41 +127,21 @@ export default function Sidebar() {
folds = _.sortBy(folds, 'followCount').reverse()
const deservesDailyFreeMarket = !useHasCreatedContractToday(user)
const navigationOptions = user === null ? signedOutNavigation : navigation
const navigationOptions =
user === null
? signedOutNavigation
: getNavigation(user?.username || 'error')
const mobileNavigationOptions =
user === null ? signedOutMobileNavigation : mobileNavigation
return (
<nav aria-label="Sidebar" className="sticky top-4 divide-gray-300 pl-2">
<div className="space-y-1 pb-6">
<ManifoldLogo twoLine />
</div>
<div className="mb-2" style={{ minHeight: 80 }}>
{user ? (
<Link href={`/${user.username}`}>
<a className="group">
<ProfileSummary user={user} />
</a>
</Link>
) : user === null ? (
<div className="py-6 text-center">
<button
className="btn btn-sm px-6 font-medium normal-case "
style={{
backgroundColor: 'white',
border: '2px solid',
color: '#3D4451',
}}
onClick={firebaseLogin}
>
Sign in
</button>
</div>
) : (
<div />
)}
</div>
<nav aria-label="Sidebar" className={className}>
<ManifoldLogo className="pb-6" twoLine />
{user && (
<div className="mb-2" style={{ minHeight: 80 }}>
<ProfileSummary user={user} />
</div>
)}
<div className="space-y-1 lg:hidden">
{mobileNavigationOptions.map((item) => (
@ -181,22 +169,30 @@ export default function Sidebar() {
/>
</div>
{deservesDailyFreeMarket ? (
<div className=" text-primary mt-4 text-center">
Use your daily free market! 🎉
</div>
) : (
<div />
)}
{user && (
<div className={'aligncenter flex justify-center'}>
<Link href={'/create'}>
<button className="btn btn-primary btn-md mt-4 capitalize">
Create Market
<div className={'aligncenter flex justify-center'}>
{user ? (
<Link href={'/create'} passHref>
<button className="border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r from-purple-500 via-violet-500 to-indigo-500 py-2.5 text-base font-semibold text-white shadow-sm hover:from-purple-700 hover:via-violet-700 hover:to-indigo-700">
Ask a question
</button>
</Link>
</div>
) : (
<button
onClick={firebaseLogin}
className="border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r from-purple-500 via-violet-500 to-indigo-500 py-2.5 text-base font-semibold text-white shadow-sm hover:from-purple-700 hover:via-violet-700 hover:to-indigo-700"
>
Sign in
</button>
)}
</div>
{user && deservesDailyFreeMarket && (
<Row className="mt-2 justify-center">
<Row className="gap-1 align-middle text-sm text-indigo-400">
Daily free market
<SparklesIcon className="mt-0.5 h-4 w-4" aria-hidden="true" />
</Row>
</Row>
)}
</nav>
)

View File

@ -92,6 +92,13 @@ export function FreeResponseOutcomeLabel(props: {
)
}
export const OUTCOME_TO_COLOR = {
YES: 'primary',
NO: 'red-400',
CANCEL: 'yellow-400',
MKT: 'blue-400',
}
export function YesLabel() {
return <span className="text-primary">YES</span>
}

View File

@ -12,7 +12,7 @@ export function Page(props: {
const { margin, assertUser, children, rightSidebar, suspend } = props
return (
<div>
<>
<div
className={clsx(
'mx-auto w-full pb-14 lg:grid lg:grid-cols-12 lg:gap-8 lg:pt-6 xl:max-w-7xl',
@ -20,9 +20,7 @@ export function Page(props: {
)}
style={suspend ? visuallyHiddenStyle : undefined}
>
<div className="hidden lg:col-span-2 lg:block">
<Sidebar />
</div>
<Sidebar className="sticky top-4 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" />
<main
className={clsx(
'lg:col-span-8',
@ -40,7 +38,7 @@ export function Page(props: {
</div>
<BottomNavBar />
</div>
</>
)
}

View File

@ -20,7 +20,7 @@ export function ResolutionPanel(props: {
}) {
useEffect(() => {
// warm up cloud function
resolveMarket({} as any).catch()
resolveMarket({} as any).catch(() => {})
}, [])
const { contract, className } = props

View File

@ -11,8 +11,9 @@ export function SellButton(props: {
user: User | null | undefined
sharesOutcome: 'YES' | 'NO' | undefined
shares: number
panelClassName?: string
}) {
const { contract, user, sharesOutcome, shares } = props
const { contract, user, sharesOutcome, shares, panelClassName } = props
const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract
@ -24,7 +25,7 @@ export function SellButton(props: {
className={clsx(
'btn-sm w-24 gap-1',
// from the yes-no-selector:
'flex inline-flex flex-row items-center justify-center rounded-3xl border-2 p-2',
'inline-flex items-center justify-center rounded-3xl border-2 p-2',
sharesOutcome === 'NO'
? 'hover:bg-primary-focus border-primary hover:border-primary-focus text-primary hover:text-white'
: 'border-red-400 text-red-500 hover:border-red-500 hover:bg-red-500 hover:text-white'
@ -38,6 +39,7 @@ export function SellButton(props: {
</div>
{showSellModal && (
<SellSharesModal
className={panelClassName}
contract={contract as FullContract<CPMM, Binary>}
user={user}
userBets={userBets ?? []}

View File

@ -7,8 +7,10 @@ import { Title } from './title'
import { formatWithCommas } from 'common/util/format'
import { OutcomeLabel } from './outcome-label'
import { SellPanel } from './bet-panel'
import clsx from 'clsx'
export function SellSharesModal(props: {
className?: string
contract: FullContract<CPMM, Binary>
userBets: Bet[]
shares: number
@ -16,11 +18,19 @@ export function SellSharesModal(props: {
user: User
setOpen: (open: boolean) => void
}) {
const { contract, shares, sharesOutcome, userBets, user, setOpen } = props
const {
className,
contract,
shares,
sharesOutcome,
userBets,
user,
setOpen,
} = props
return (
<Modal open={true} setOpen={setOpen}>
<Col className="rounded-md bg-white px-8 py-6">
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
<Title className="!mt-0" text={'Sell shares'} />
<div className="mb-6">

View File

@ -1,10 +1,11 @@
import { Fragment } from 'react'
import React, { Fragment } from 'react'
import { CodeIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import { Contract } from 'common/contract'
import { contractPath } from 'web/lib/firebase/contracts'
import { DOMAIN } from 'common/envs/constants'
import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard'
function copyEmbedCode(contract: Contract) {
const title = contract.question
@ -15,8 +16,11 @@ function copyEmbedCode(contract: Contract) {
copyToClipboard(embedCode)
}
export function ShareEmbedButton(props: { contract: Contract }) {
const { contract } = props
export function ShareEmbedButton(props: {
contract: Contract
toastClassName?: string
}) {
const { contract, toastClassName } = props
return (
<Menu
@ -45,9 +49,9 @@ export function ShareEmbedButton(props: { contract: Contract }) {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-center absolute left-0 mt-2 w-40 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Items>
<Menu.Item>
<div className="px-2 py-1">Embed code copied!</div>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>

View File

@ -13,12 +13,14 @@ export function ShareMarket(props: { contract: Contract; className?: string }) {
<Row className="mb-6 items-center">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={contractUrl(contract)}
/>
<CopyLinkButton
contract={contract}
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Row>
</Col>

View File

@ -9,29 +9,15 @@ export const SiteLink = (props: {
}) => {
const { href, children, onClick, className } = props
return href.startsWith('http') ? (
<a
href={href}
className={clsx(
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
target="_blank"
onClick={(e) => {
e.stopPropagation()
if (onClick) onClick()
}}
>
{children}
</a>
) : (
<Link href={href}>
return (
<MaybeLink href={href}>
<a
className={clsx(
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
href={href}
target={href.startsWith('http') ? '_blank' : undefined}
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
onClick={(e) => {
e.stopPropagation()
@ -40,6 +26,15 @@ export const SiteLink = (props: {
>
{children}
</a>
</Link>
</MaybeLink>
)
}
function MaybeLink(props: { href: string; children: React.ReactNode }) {
const { href, children } = props
return href.startsWith('http') ? (
<>{children}</>
) : (
<Link href={href}>{children}</Link>
)
}

View File

@ -10,13 +10,8 @@ function Hashtag(props: { tag: string; noLink?: boolean }) {
const category = CATEGORIES[tag.replace('#', '').toLowerCase()]
const body = (
<div
className={clsx(
'rounded-full border-2 bg-gray-100 px-3 py-1 shadow-md',
!noLink && 'cursor-pointer'
)}
>
<span className="text-sm text-gray-600">{category ?? tag}</span>
<div className={clsx('', !noLink && 'cursor-pointer')}>
<span className="text-sm">{category ? '#' + category : tag} </span>
</div>
)
@ -38,7 +33,7 @@ export function TagsList(props: {
const { tags, className, noLink, noLabel, label } = props
return (
<Row className={clsx('flex-wrap items-center gap-2', className)}>
{!noLabel && <div className="mr-1 text-gray-500">{label || 'Tags'}</div>}
{!noLabel && <div className="mr-1">{label || 'Tags'}</div>}
{tags.map((tag) => (
<Hashtag
key={tag}

View File

@ -0,0 +1,21 @@
import { ClipboardCopyIcon } from '@heroicons/react/outline'
import React from 'react'
import clsx from 'clsx'
import { Row } from 'web/components/layout/row'
export function ToastClipboard(props: { className?: string }) {
const { className } = props
return (
<Row
className={clsx(
'border-base-300 absolute items-center' +
'gap-2 divide-x divide-gray-200 rounded-md border-2 bg-white ' +
'h-15 w-[15rem] p-2 pr-3 text-gray-500',
className
)}
>
<ClipboardCopyIcon height={20} className={'mr-2 self-center'} />
<div className="pl-4 text-sm font-normal">Link copied to clipboard!</div>
</Row>
)
}

View File

@ -21,6 +21,9 @@ import { getContractFromId, listContracts } from 'web/lib/firebase/contracts'
import { LoadingIndicator } from './loading-indicator'
import { useRouter } from 'next/router'
import _ from 'lodash'
import { BetsList } from './bets-list'
import { Bet } from 'common/bet'
import { getUserBets } from 'web/lib/firebase/bets'
export function UserLink(props: {
name: string
@ -38,12 +41,13 @@ export function UserLink(props: {
)
}
export const TAB_IDS = ['markets', 'comments', 'bets']
export function UserPage(props: {
user: User
currentUser?: User
defaultTabTitle?: string
defaultTabTitle?: 'markets' | 'comments' | 'bets'
}) {
const router = useRouter()
const { user, currentUser, defaultTabTitle } = props
const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
@ -51,6 +55,7 @@ export function UserPage(props: {
const [usersContracts, setUsersContracts] = useState<Contract[] | 'loading'>(
'loading'
)
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
const [commentsByContract, setCommentsByContract] = useState<
Map<Contract, Comment[]> | 'loading'
>('loading')
@ -59,6 +64,7 @@ export function UserPage(props: {
if (!user) return
getUsersComments(user.id).then(setUsersComments)
listContracts(user.id).then(setUsersContracts)
getUserBets(user.id).then(setUsersBets)
}, [user])
useEffect(() => {
@ -187,17 +193,14 @@ export function UserPage(props: {
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
<Tabs
className={'pb-2 pt-1 '}
defaultIndex={defaultTabTitle === 'Comments' ? 1 : 0}
onClick={(tabName) =>
router.push(
{
pathname: `/${user.username}`,
query: { tab: tabName },
},
undefined,
{ shallow: true }
)
}
defaultIndex={TAB_IDS.indexOf(defaultTabTitle || 'markets')}
onClick={(tabName) => {
const tabId = tabName.toLowerCase()
const subpath = tabId === 'markets' ? '' : '/' + tabId
// BUG: if you start on `/Bob/bets`, then click on Markets, use-query-and-sort-params
// rewrites the url incorrectly to `/Bob/bets` instead of `/Bob`
window.history.replaceState('', '', `/${user.username}${subpath}`)
}}
tabs={[
{
title: 'Markets',
@ -220,6 +223,24 @@ export function UserPage(props: {
<div className="px-0.5 font-bold">{usersComments.length}</div>
),
},
{
title: 'Bets',
content: (
<div>
<AlertBox
title="Bets are becoming publicly visible on 2022-06-01"
text="Bettor identities have always been traceable through the Manifold API.
However, our interface implied that they were private.
As we develop new features such as leaderboards and bet history, it won't be technically feasible to keep this info private.
For more context, or if you'd like to wipe your bet history, see: https://manifold.markets/Austin/will-all-bets-on-manifold-be-public"
/>
{isCurrentUser && <BetsList user={user} />}
</div>
),
tabIcon: (
<div className="px-0.5 font-bold">{usersBets.length}</div>
),
},
]}
/>
) : (
@ -242,3 +263,27 @@ export function defaultBannerUrl(userId: string) {
]
return defaultBanner[genHash(userId)() % defaultBanner.length]
}
import { ExclamationIcon } from '@heroicons/react/solid'
function AlertBox(props: { title: string; text: string }) {
const { title, text } = props
return (
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon
className="h-5 w-5 text-yellow-400"
aria-hidden="true"
/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
<div className="mt-2 text-sm text-yellow-700">
<Linkify text={text} />
</div>
</div>
</div>
</div>
)
}

View File

@ -2,8 +2,12 @@ import { listContracts } from 'web/lib/firebase/contracts'
import { useEffect, useState } from 'react'
import { User } from 'common/user'
let sessionCreatedContractToday = true
export const useHasCreatedContractToday = (user: User | null | undefined) => {
const [hasCreatedContractToday, setHasCreatedContractToday] = useState(true)
const [hasCreatedContractToday, setHasCreatedContractToday] = useState(
sessionCreatedContractToday
)
useEffect(() => {
// Uses utc time like the server.
@ -17,7 +21,9 @@ export const useHasCreatedContractToday = (user: User | null | undefined) => {
const todayContracts = contracts.filter(
(contract) => contract.createdTime > todayAtMidnight
)
setHasCreatedContractToday(todayContracts.length > 0)
sessionCreatedContractToday = todayContracts.length > 0
setHasCreatedContractToday(sessionCreatedContractToday)
}
listUserContractsForToday()

View File

@ -1,11 +1,11 @@
import { RefObject, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
export function useIsVisible(elementRef: RefObject<Element>) {
return !!useIntersectionObserver(elementRef)?.isIntersecting
export function useIsVisible(element: HTMLElement | null) {
return !!useIntersectionObserver(element)?.isIntersecting
}
function useIntersectionObserver(
elementRef: RefObject<Element>
elem: HTMLElement | null
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>()
@ -14,16 +14,15 @@ function useIntersectionObserver(
}
useEffect(() => {
const node = elementRef?.current
const hasIOSupport = !!window.IntersectionObserver
if (!hasIOSupport || !node) return
if (!hasIOSupport || !elem) return
const observer = new IntersectionObserver(updateEntry, {})
observer.observe(node)
observer.observe(elem)
return () => observer.disconnect()
}, [elementRef])
}, [elem])
return entry
}

View File

@ -1,5 +1,5 @@
import _ from 'lodash'
import { useEffect, RefObject, useState } from 'react'
import { useEffect, useState } from 'react'
import { Contract } from 'common/contract'
import { trackView } from 'web/lib/firebase/tracking'
import { useIsVisible } from './use-is-visible'
@ -17,10 +17,10 @@ export const useSeenContracts = () => {
}
export const useSaveSeenContract = (
ref: RefObject<Element>,
elem: HTMLElement | null,
contract: Contract
) => {
const isVisible = useIsVisible(ref)
const isVisible = useIsVisible(elem)
useEffect(() => {
if (isVisible) {

View File

@ -47,7 +47,6 @@ export function useInitialQueryAndSort(options?: {
}
setInitialSort(localSort ?? defaultSort)
} else {
console.log('ready setting to ', sort ?? defaultSort)
setInitialSort(sort ?? defaultSort)
}
}

View File

@ -60,6 +60,12 @@ export function listenForBets(
})
}
export async function getUserBets(userId: string) {
return getValues<Bet>(
query(collectionGroup(db, 'bets'), where('userId', '==', userId))
)
}
export function listenForUserBets(
userId: string,
setBets: (bets: Bet[]) => void

View File

@ -1,5 +1,4 @@
import { doc, collection, setDoc } from 'firebase/firestore'
import _ from 'lodash'
import { db } from './init'
import { ClickEvent, LatencyEvent, View } from 'common/tracking'

View File

@ -1,5 +1,4 @@
import { collection, query, where, orderBy } from 'firebase/firestore'
import _ from 'lodash'
import { Txn } from 'common/txn'
import { db } from './init'

View File

@ -55,6 +55,13 @@ export async function updateUser(userId: string, update: Partial<User>) {
await updateDoc(doc(db, 'users', userId), { ...update })
}
export async function updatePrivateUser(
userId: string,
update: Partial<PrivateUser>
) {
await updateDoc(doc(db, 'private-users', userId), { ...update })
}
export function listenForUser(
userId: string,
setUser: (user: User | null) => void

Some files were not shown because too many files have changed in this diff Show More