Merge branch 'main' into range-markets
This commit is contained in:
commit
fcdfd01664
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -35,4 +35,5 @@ export type PrivateUser = {
|
|||
unsubscribedFromGenericEmails?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
|
|
@ -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]]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
129
functions/src/api.ts
Normal 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: '???' } })
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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 })
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import clsx from 'clsx'
|
||||
import _ from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import clsx from 'clsx'
|
||||
import _ from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 />
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
|
61
web/components/feed/copy-link-date-time.tsx
Normal file
61
web/components/feed/copy-link-date-time.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
190
web/components/feed/feed-answer-comment-group.tsx
Normal file
190
web/components/feed/feed-answer-comment-group.tsx
Normal 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>
|
||||
)
|
||||
}
|
173
web/components/feed/feed-bets.tsx
Normal file
173
web/components/feed/feed-bets.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
498
web/components/feed/feed-comments.tsx
Normal file
498
web/components/feed/feed-comments.tsx
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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) || []
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?? []}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
21
web/components/toast-clipboard.tsx
Normal file
21
web/components/toast-clipboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -47,7 +47,6 @@ export function useInitialQueryAndSort(options?: {
|
|||
}
|
||||
setInitialSort(localSort ?? defaultSort)
|
||||
} else {
|
||||
console.log('ready setting to ', sort ?? defaultSort)
|
||||
setInitialSort(sort ?? defaultSort)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { collection, query, where, orderBy } from 'firebase/firestore'
|
||||
import _ from 'lodash'
|
||||
import { Txn } from 'common/txn'
|
||||
|
||||
import { db } from './init'
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue
Block a user