Merge branch 'main' into 500-Mana-email

This commit is contained in:
mantikoros 2022-08-04 10:58:53 -07:00
commit 6937c87766
199 changed files with 7265 additions and 2741 deletions

View File

@ -5,12 +5,14 @@ import {
CPMMBinaryContract,
DPMBinaryContract,
FreeResponseContract,
MultipleChoiceContract,
NumericContract,
} from './contract'
import { User } from './user'
import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants'
import { Answer } from './answer'
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
@ -111,6 +113,50 @@ export function getFreeAnswerAnte(
return anteBet
}
export function getMultipleChoiceAntes(
creator: User,
contract: MultipleChoiceContract,
answers: string[],
betDocIds: string[]
) {
const { totalBets, totalShares } = contract
const amount = totalBets['0']
const shares = totalShares['0']
const p = 1 / answers.length
const { createdTime } = contract
const bets: Bet[] = answers.map((answer, i) => ({
id: betDocIds[i],
userId: creator.id,
contractId: contract.id,
amount,
shares,
outcome: i.toString(),
probBefore: p,
probAfter: p,
createdTime,
isAnte: true,
fees: noFees,
}))
const { username, name, avatarUrl } = creator
const answerObjects: Answer[] = answers.map((answer, i) => ({
id: i.toString(),
number: i,
contractId: contract.id,
createdTime,
userId: creator.id,
username,
name,
avatarUrl,
text: answer,
}))
return { bets, answerObjects }
}
export function getNumericAnte(
anteBettorId: string,
contract: NumericContract,

View File

@ -12,7 +12,9 @@ export class APIError extends Error {
}
export function getFunctionUrl(name: string) {
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
if (process.env.NEXT_PUBLIC_FUNCTIONS_URL) {
return `${process.env.NEXT_PUBLIC_FUNCTIONS_URL}/${name}`
} else if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
const { projectId, region } = ENV_CONFIG.firebaseConfig
return `http://localhost:5001/${projectId}/${region}/${name}`
} else {

View File

@ -123,6 +123,7 @@ export function calculateCpmmAmountToProb(
prob: number,
outcome: 'YES' | 'NO'
) {
if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity
if (outcome === 'NO') prob = 1 - prob
// First, find an upper bound that leads to a more extreme probability than prob.

View File

@ -23,6 +23,7 @@ import {
BinaryContract,
FreeResponseContract,
PseudoNumericContract,
MultipleChoiceContract,
} from './contract'
import { floatingEqual } from './util/math'
@ -200,7 +201,9 @@ export function getContractBetNullMetrics() {
}
}
export function getTopAnswer(contract: FreeResponseContract) {
export function getTopAnswer(
contract: FreeResponseContract | MultipleChoiceContract
) {
const { answers } = contract
const top = maxBy(
answers?.map((answer) => ({

View File

@ -1,6 +1,7 @@
import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = {
politics: 'Politics',
technology: 'Technology',
@ -30,10 +31,13 @@ export const EXCLUDED_CATEGORIES: category[] = [
'manifold',
'personal',
'covid',
'culture',
'gaming',
'crypto',
'world',
]
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({
slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX,
name: CATEGORIES[c as category],
}))

View File

@ -169,7 +169,7 @@ export const charities: Charity[] = [
{
name: "Founder's Pledge Climate Change Fund",
website: 'https://founderspledge.com/funds/climate-change-fund',
photo: 'https://i.imgur.com/ZAhzHu4.png',
photo: 'https://i.imgur.com/9turaJW.png',
preview:
'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.',
description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally.
@ -183,7 +183,7 @@ export const charities: Charity[] = [
{
name: "Founder's Pledge Patient Philanthropy Fund",
website: 'https://founderspledge.com/funds/patient-philanthropy-fund',
photo: 'https://i.imgur.com/ZAhzHu4.png',
photo: 'https://i.imgur.com/LLR6CI6.png',
preview:
'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity',
description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future.
@ -551,6 +551,20 @@ With an emphasis on approval voting, we bring better elections to people across
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that dont represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
},
{
name: 'Founders Pledge Global Health and Development Fund',
website: 'https://founderspledge.com/funds/global-health-and-development',
photo: 'https://i.imgur.com/EXbxH7T.png',
preview:
'Tackling the vast global inequalities in health, wealth and opportunity',
description: `Nearly half the world lives on less than $2.50 a day, yet giving by the worlds richest often overlooks the worlds poorest and most vulnerable. Despite the average American household being richer than 90% of the rest of the world, only 6% of US charitable giving goes to charities which work internationally.
This Fund is focused on helping those who need it most, wherever that help can make the biggest difference. By building a mixed portfolio of direct and indirect interventions, such as policy work, we aim to:
Improve the lives of the world's most vulnerable people.
Reduce the number of easily preventable deaths worldwide.
Work towards sustainable, systemic change.`,
},
].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return {

View File

@ -1,15 +1,22 @@
import { Answer } from './answer'
import { Fees } from './fees'
import { JSONContent } from '@tiptap/core'
import { GroupLink } from 'common/group'
export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
export type AnyOutcomeType =
| Binary
| MultipleChoice
| PseudoNumeric
| FreeResponse
| Numeric
export type AnyContractType =
| (CPMM & Binary)
| (CPMM & PseudoNumeric)
| (DPM & Binary)
| (DPM & FreeResponse)
| (DPM & Numeric)
| (DPM & MultipleChoice)
export type Contract<T extends AnyContractType = AnyContractType> = {
id: string
@ -46,14 +53,17 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
collectedFees: Fees
groupSlugs?: string[]
groupLinks?: GroupLink[]
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
} & T
export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse
export type MultipleChoiceContract = Contract & MultipleChoice
export type DPMContract = Contract & DPM
export type CPMMContract = Contract & CPMM
export type DPMBinaryContract = BinaryContract & DPM
@ -101,6 +111,13 @@ export type FreeResponse = {
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
}
export type MultipleChoice = {
outcomeType: 'MULTIPLE_CHOICE'
answers: Answer[]
resolution?: string | 'MKT' | 'CANCEL'
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
}
export type Numeric = {
outcomeType: 'NUMERIC'
bucketCount: number
@ -115,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = [
'BINARY',
'MULTIPLE_CHOICE',
'FREE_RESPONSE',
'PSEUDO_NUMERIC',
'NUMERIC',

View File

@ -22,6 +22,7 @@ export type EnvConfig = {
// Currency controls
fixedAnte?: number
startingBalance?: number
referralBonus?: number
}
type FirebaseConfig = {

View File

@ -19,3 +19,11 @@ export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
export const GROUP_CHAT_SLUG = 'chat'
export type GroupLink = {
slug: string
name: string
groupId: string
createdTime: number
userId?: string
}

View File

@ -1,4 +1,4 @@
import { sortBy, sumBy } from 'lodash'
import { sortBy, sum, sumBy } from 'lodash'
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import {
@ -18,6 +18,7 @@ import {
CPMMBinaryContract,
DPMBinaryContract,
FreeResponseContract,
MultipleChoiceContract,
NumericContract,
PseudoNumericContract,
} from './contract'
@ -142,6 +143,13 @@ export const computeFills = (
limitProb: number | undefined,
unfilledBets: LimitBet[]
) => {
if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}')
}
if (isNaN(limitProb ?? 0)) {
throw new Error('Invalid limitProb: ${limitProb}')
}
const sortedBets = sortBy(
unfilledBets.filter((bet) => bet.outcome !== outcome),
(bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb),
@ -239,6 +247,32 @@ export const getBinaryCpmmBetInfo = (
}
}
export const getBinaryBetStats = (
outcome: 'YES' | 'NO',
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number,
unfilledBets: LimitBet[]
) => {
const { newBet } = getBinaryCpmmBetInfo(
outcome,
betAmount ?? 0,
contract,
limitProb,
unfilledBets as LimitBet[]
)
const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) /
(outcome === 'YES' ? limitProb : 1 - limitProb)
const currentPayout = newBet.shares + remainingMatched
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const totalFees = sum(Object.values(newBet.fees))
return { currentPayout, currentReturn, totalFees, newBet }
}
export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO',
amount: number,
@ -289,7 +323,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = (
outcome: string,
amount: number,
contract: FreeResponseContract,
contract: FreeResponseContract | MultipleChoiceContract,
loanAmount: number
) => {
const { pool, totalShares, totalBets } = contract

View File

@ -5,6 +5,7 @@ import {
CPMM,
DPM,
FreeResponse,
MultipleChoice,
Numeric,
outcomeType,
PseudoNumeric,
@ -30,7 +31,10 @@ export function getNewContract(
bucketCount: number,
min: number,
max: number,
isLogScale: boolean
isLogScale: boolean,
// for multiple choice
answers: string[]
) {
const tags = parseTags(
[
@ -48,6 +52,8 @@ export function getNewContract(
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
: outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max)
: outcomeType === 'MULTIPLE_CHOICE'
? getMultipleChoiceProps(ante, answers)
: getFreeAnswerProps(ante)
const contract: Contract = removeUndefinedProps({
@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => {
return system
}
const getMultipleChoiceProps = (ante: number, answers: string[]) => {
const numAnswers = answers.length
const betAnte = ante / numAnswers
const betShares = Math.sqrt(ante ** 2 / numAnswers)
const defaultValues = (x: any) =>
Object.fromEntries(range(0, numAnswers).map((k) => [k, x]))
const system: DPM & MultipleChoice = {
mechanism: 'dpm-2',
outcomeType: 'MULTIPLE_CHOICE',
pool: defaultValues(betAnte),
totalShares: defaultValues(betShares),
totalBets: defaultValues(betAnte),
answers: [],
}
return system
}
const getNumericProps = (
ante: number,
bucketCount: number,

View File

@ -63,3 +63,4 @@ export type notification_reason_types =
| 'on_group_you_are_member_of'
| 'tip_received'
| 'bet_fill'
| 'user_joined_from_your_group_invite'

View File

@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10

View File

@ -10,6 +10,7 @@
"dependencies": {
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"lodash": "4.17.21"
},

View File

@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet'
import { deductDpmFees, getDpmProbability } from './calculate-dpm'
import { DPMContract, FreeResponseContract } from './contract'
import {
DPMContract,
FreeResponseContract,
MultipleChoiceContract,
} from './contract'
import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
import { addObjects } from './util/object'
@ -180,7 +184,7 @@ export const getDpmMktPayouts = (
export const getPayoutsMultiOutcome = (
resolutions: { [outcome: string]: number },
contract: FreeResponseContract,
contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[]
) => {
const poolTotal = sum(Object.values(contract.pool))

View File

@ -117,6 +117,7 @@ export const getDpmPayouts = (
resolutionProbability?: number
): PayoutInfo => {
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const { outcomeType } = contract
switch (outcome) {
case 'YES':
@ -124,7 +125,8 @@ export const getDpmPayouts = (
return getDpmStandardPayouts(outcome, contract, openBets)
case 'MKT':
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
: getDpmMktPayouts(contract, openBets, resolutionProbability)
case 'CANCEL':
@ -132,7 +134,7 @@ export const getDpmPayouts = (
return getDpmCancelPayouts(contract, openBets)
default:
if (contract.outcomeType === 'NUMERIC')
if (outcomeType === 'NUMERIC')
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
// Outcome is a free response answer id.

View File

@ -16,8 +16,8 @@ export const getMappedValue =
const { min, max, isLogScale } = contract
if (isLogScale) {
const logValue = p * Math.log10(max - min)
return 10 ** logValue + min
const logValue = p * Math.log10(max - min + 1)
return 10 ** logValue + min - 1
}
return p * (max - min) + min
@ -37,8 +37,11 @@ export const getPseudoProbability = (
max: number,
isLogScale = false
) => {
if (value < min) return 0
if (value > max) return 1
if (isLogScale) {
return Math.log10(value - min) / Math.log10(max - min)
return Math.log10(value - min + 1) / Math.log10(max - min + 1)
}
return (value - min) / (max - min)

View File

@ -38,13 +38,15 @@ export type User = {
referredByUserId?: string
referredByContractId?: string
referredByGroupId?: string
lastPingTime?: number
shouldShowWelcome?: boolean
}
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = 500
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = {
id: string // same as User.id

View File

@ -33,20 +33,24 @@ export function formatPercent(zeroToOne: number) {
return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
}
const showPrecision = (x: number, sigfigs: number) =>
// convert back to number for weird formatting reason
`${Number(x.toPrecision(sigfigs))}`
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
export function formatLargeNumber(num: number, sigfigs = 2): string {
const absNum = Math.abs(num)
if (absNum < 1) return num.toPrecision(sigfigs)
if (absNum < 1) return showPrecision(num, sigfigs)
if (absNum < 100) return num.toPrecision(2)
if (absNum < 1000) return num.toPrecision(3)
if (absNum < 10000) return num.toPrecision(4)
if (absNum < 100) return showPrecision(num, 2)
if (absNum < 1000) return showPrecision(num, 3)
if (absNum < 10000) return showPrecision(num, 4)
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const i = Math.floor(Math.log10(absNum) / 3)
const numStr = (num / Math.pow(10, 3 * i)).toPrecision(sigfigs)
return `${numStr}${suffix[i]}`
const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs)
return `${numStr}${suffix[i] ?? ''}`
}
export function toCamelCase(words: string) {

View File

@ -20,6 +20,8 @@ import { Text } from '@tiptap/extension-text'
// other tiptap extensions
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
@ -80,8 +82,9 @@ export const exhibitExts = [
Image,
Link,
Mention,
Iframe,
]
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
export function richTextToString(text?: JSONContent) {
return !text ? '' : generateText(text, exhibitExts)

View File

@ -0,0 +1,92 @@
// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts
import { Node } from '@tiptap/core'
export interface IframeOptions {
allowFullscreen: boolean
HTMLAttributes: {
[key: string]: any
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
setIframe: (options: { src: string }) => ReturnType
}
}
}
// These classes style the outer wrapper and the inner iframe;
// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue
const wrapperClasses = 'relative h-auto w-full overflow-hidden'
const iframeClasses = 'absolute top-0 left-0 h-full w-full'
export default Node.create<IframeOptions>({
name: 'iframe',
group: 'block',
atom: true,
addOptions() {
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'iframe-wrapper' + ' ' + wrapperClasses,
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
style: 'padding-bottom: 20rem;',
},
}
},
addAttributes() {
return {
src: {
default: null,
},
frameborder: {
default: 0,
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen,
},
}
},
parseHTML() {
return [{ tag: 'iframe' }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
this.options.HTMLAttributes,
[
'iframe',
{
...HTMLAttributes,
class: HTMLAttributes.class + ' ' + iframeClasses,
},
],
]
},
addCommands() {
return {
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr
const node = this.type.create(options)
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node)
}
return true
},
}
},
})

43
dev.sh Executable file
View File

@ -0,0 +1,43 @@
#!/bin/bash
ENV=${1:-dev}
case $ENV in
dev)
FIREBASE_PROJECT=dev
NEXT_ENV=DEV ;;
prod)
FIREBASE_PROJECT=prod
NEXT_ENV=PROD ;;
localdb)
FIREBASE_PROJECT=dev
NEXT_ENV=DEV
EMULATOR=true ;;
*)
echo "Invalid environment; must be dev, prod, or localdb."
exit 1
esac
firebase use $FIREBASE_PROJECT
if [ ! -z $EMULATOR ]
then
npx concurrently \
-n FIRESTORE,FUNCTIONS,NEXT,TS \
-c green,white,magenta,cyan \
"yarn --cwd=functions firestore" \
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
yarn --cwd=web serve" \
"cross-env yarn --cwd=web ts-watch"
else
npx concurrently \
-n FUNCTIONS,NEXT,TS \
-c white,magenta,cyan \
"yarn --cwd=functions dev" \
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
yarn --cwd=web serve" \
"cross-env yarn --cwd=web ts-watch"
fi

View File

@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use
Requires no authorization.
### GET /v0/me
Returns the authenticated user.
### `GET /v0/groups`
Gets all groups, in no particular order.
Requires no authorization.
### `GET /v0/groups/[slug]`
Gets a group by its slug.
Requires no authorization.
### `GET /v0/groups/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
### `GET /v0/markets`
Lists all markets, ordered by creation date descending.
@ -481,6 +503,20 @@ Parameters:
answer. For numeric markets, this is a string representing the target bucket,
and an additional `value` parameter is required which is a number representing
the target value. (Bet on numeric markets at your own peril.)
- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing
the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the
probability percentage).
The bet will execute immediately in the direction of `outcome`, but not beyond this
specified limit. If not all the bet is filled, the bet will remain as an open offer
that can later be matched against an opposite direction bet.
- For example, if the current market probability is `50%`:
- A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market
probability moves down to `40%` and someone bets `M$15` of `NO` to match your
bet odds.
- A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely
depending on current unfilled limit bets and the AMM's liquidity. Any remaining
portion of the bet not filled would remain to be matched against in the future.
- An unfilled limit order bet can be cancelled using the cancel API.
Example request:
@ -579,6 +615,26 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
]}'
```
### `POST /v0/market/[marketId]/sell`
Sells some quantity of shares in a binary market on behalf of the authorized user.
Parameters:
- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
own one kind of shares, you will sell that kind of shares.
- `shares`: Optional. The amount of shares to sell of the outcome given
above. If not provided, all the shares you own will be sold.
Example request:
```
$ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "YES", "shares": 10}'
```
### `GET /v0/bets`
Gets a list of bets, ordered by creation date descending.
@ -597,7 +653,7 @@ Requires no authorization.
- Example request
```
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord
```
- Response type: A `Bet[]`.
@ -605,31 +661,60 @@ Requires no authorization.
```json
[
// Limit bet, partially filled.
{
"probAfter": 0.44418877319153904,
"shares": -645.8346334931828,
"isFilled": false,
"amount": 15.596681605353808,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"contractId": "Tz5dA01GkK5QKiQfZeDL",
"probBefore": 0.5730753474948571,
"isCancelled": false,
"outcome": "YES",
"contractId": "tgB1XmvFXZNhjr3xMNLp",
"sale": {
"betId": "RcOtarI3d1DUUTjiE0rx",
"amount": 474.9999999999998
},
"createdTime": 1644602886293,
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
"probBefore": 0.7229189477449224,
"id": "x9eNmCaqQeXW8AgJ8Zmp",
"amount": -499.9999999999998
"fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
"shares": 31.193363210707616,
"limitProb": 0.5,
"id": "yXB8lVbs86TKkhWA1FVi",
"loanAmount": 0,
"orderAmount": 100,
"probAfter": 0.5730753474948571,
"createdTime": 1659482775970,
"fills": [
{
"timestamp": 1659483249648,
"matchedBetId": "MfrMd5HTiGASDXzqibr7",
"amount": 15.596681605353808,
"shares": 31.193363210707616
}
]
},
// Normal bet (no limitProb specified).
{
"probAfter": 0.9901970375647697,
"contractId": "zdeaYVAfHlo9jKzWh57J",
"outcome": "YES",
"amount": 1,
"id": "8PqxKYwXCcLYoXy2m2Nm",
"shares": 1.0049875638533763,
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
"probBefore": 0.9900000000000001,
"createdTime": 1644705818872
"shares": 17.350459904608414,
"probBefore": 0.5304358279113885,
"isFilled": true,
"probAfter": 0.5730753474948571,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"amount": 10,
"contractId": "Tz5dA01GkK5QKiQfZeDL",
"id": "1LPJHNz5oAX4K6YtJlP1",
"fees": {
"platformFee": 0,
"liquidityFee": 0,
"creatorFee": 0.4251333951457593
},
"isCancelled": false,
"loanAmount": 0,
"orderAmount": 10,
"fills": [
{
"amount": 10,
"matchedBetId": null,
"shares": 17.350459904608414,
"timestamp": 1659482757271
}
],
"createdTime": 1659482757271,
"outcome": "YES"
}
]
```

View File

@ -10,13 +10,16 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
## API / Dev
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
## Bots
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets

View File

@ -22,11 +22,11 @@ service cloud.firestore {
allow read;
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId', 'lastPingTime']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
// User referral rules
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['referredByUserId'])
.hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
// only one referral allowed per user
&& !("referredByUserId" in resource.data)
// user can't refer themselves
@ -74,9 +74,9 @@ service cloud.firestore {
match /contracts/{contractId} {
allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs']);
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime'])
.hasOnly(['description', 'closeTime', 'question'])
&& resource.data.creatorId == request.auth.uid;
allow update: if isAdmin();
match /comments/{commentId} {

View File

@ -12,6 +12,8 @@
"start": "yarn shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log",
"dev": "nodemon src/serve.ts",
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export",
@ -27,8 +29,11 @@
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"dayjs": "1.11.4",
"cors": "2.8.5",
"express": "4.18.1",
"firebase-admin": "10.0.0",
"firebase-functions": "3.21.2",
"lodash": "4.17.21",

View File

@ -1,6 +1,7 @@
import * as admin from 'firebase-admin'
import { logger } from 'firebase-functions/v2'
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https'
import { Request, RequestHandler, Response } from 'express'
import { error } from 'firebase-functions/logger'
import { HttpsOptions } from 'firebase-functions/v2/https'
import { log } from './utils'
import { z } from 'zod'
import { APIError } from '../../common/api'
@ -45,7 +46,7 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
} catch (err) {
// This is somewhat suspicious, so get it into the firebase console
logger.error('Error verifying Firebase JWT: ', err)
error('Error verifying Firebase JWT: ', err)
throw new APIError(403, 'Error validating token.')
}
case 'Key':
@ -83,6 +84,11 @@ export const zTimestamp = () => {
}, z.date())
}
export type EndpointDefinition = {
opts: EndpointOptions & { method: string }
handler: RequestHandler
}
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const result = schema.safeParse(val)
if (!result.success) {
@ -99,12 +105,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
}
}
interface EndpointOptions extends HttpsOptions {
methods?: string[]
export interface EndpointOptions extends HttpsOptions {
method?: string
}
const DEFAULT_OPTS = {
methods: ['POST'],
method: 'POST',
minInstances: 1,
concurrency: 100,
memory: '2GiB',
@ -113,28 +119,29 @@ const DEFAULT_OPTS = {
}
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
return onRequest(opts, async (req, res) => {
log('Request processing started.')
try {
if (!opts.methods.includes(req.method)) {
const allowed = opts.methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`)
}
const authedUser = await lookupUser(await parseCredentials(req))
log('User credentials processed.')
res.status(200).json(await fn(req, authedUser))
} catch (e) {
if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.message }
if (e.details != null) {
output.details = e.details
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
return {
opts,
handler: async (req: Request, res: Response) => {
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
try {
if (opts.method !== req.method) {
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
}
const authedUser = await lookupUser(await parseCredentials(req))
res.status(200).json(await fn(req, authedUser))
} catch (e) {
if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.message }
if (e.details != null) {
output.details = e.details
}
res.status(e.code).json(output)
} else {
error(e)
res.status(500).json({ message: 'An unknown error occurred.' })
}
res.status(e.code).json(output)
} else {
logger.error(e)
res.status(500).json({ message: 'An unknown error occurred.' })
}
}
})
},
} as EndpointDefinition
}

View File

@ -10,7 +10,7 @@ const bodySchema = z.object({
export const cancelbet = newEndpoint({}, async (req, auth) => {
const { betId } = validate(bodySchema, req.body)
const result = await firestore.runTransaction(async (trans) => {
return await firestore.runTransaction(async (trans) => {
const snap = await trans.get(
firestore.collectionGroup('bets').where('id', '==', betId)
)
@ -28,8 +28,6 @@ export const cancelbet = newEndpoint({}, async (req, auth) => {
return { ...bet, isCancelled: true }
})
return result
})
const firestore = admin.firestore()

View File

@ -2,33 +2,37 @@ import * as admin from 'firebase-admin'
import { z } from 'zod'
import {
CPMMBinaryContract,
Contract,
CPMMBinaryContract,
FreeResponseContract,
MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH,
MultipleChoiceContract,
NumericContract,
OUTCOME_TYPES,
} from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { chargeUser } from './utils'
import { chargeUser, getContract } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api'
import {
FIXED_ANTE,
getCpmmInitialLiquidity,
getFreeAnswerAnte,
getMultipleChoiceAntes,
getNumericAnte,
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group'
import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet'
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
@ -79,11 +83,15 @@ const numericSchema = z.object({
isLogScale: z.boolean().optional(),
})
const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2),
})
export const createmarket = newEndpoint({}, async (req, auth) => {
const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body)
let min, max, initialProb, isLogScale
let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue
@ -97,12 +105,22 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
if (initialProb < 1 || initialProb > 99)
throw new APIError(400, 'Invalid initial value.')
if (outcomeType === 'PSEUDO_NUMERIC')
throw new APIError(
400,
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
)
else throw new APIError(400, 'Invalid initial probability.')
}
if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body))
}
if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body))
}
const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
@ -118,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
let group = null
if (groupId) {
const groupDocRef = await firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (!group.memberIds.includes(user.id)) {
throw new APIError(
400,
'User must be a member of the group to add markets to it.'
)
}
if (!group.contractIds.includes(contractRef.id))
await groupDocRef.update({
contractIds: [...group.contractIds, contractRef.id],
})
}
console.log(
'creating contract for',
user.username,
@ -162,13 +159,41 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0,
isLogScale ?? false
isLogScale ?? false,
answers ?? []
)
if (ante) await chargeUser(user.id, ante, true)
await contractRef.create(contract)
let group = null
if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (
!group.memberIds.includes(user.id) &&
!group.anyoneCanJoin &&
group.creatorId !== user.id
) {
throw new APIError(
400,
'User must be a member/creator of the group or group must be open to add markets to it.'
)
}
if (!group.contractIds.includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid)
await groupDocRef.update({
contractIds: uniq([...group.contractIds, contractRef.id]),
})
}
}
const providerId = user.id
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
@ -184,6 +209,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
)
await liquidityDoc.set(lp)
} else if (outcomeType === 'MULTIPLE_CHOICE') {
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
const betDocs = (answers ?? []).map(() => betCol.doc())
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
const answerDocs = (answers ?? []).map((_, i) =>
answerCol.doc(i.toString())
)
const { bets, answerObjects } = getMultipleChoiceAntes(
user,
contract as MultipleChoiceContract,
answers ?? [],
betDocs.map((bd) => bd.id)
)
await Promise.all(
zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
)
await Promise.all(
zip(answerObjects, answerDocs).map(([answer, doc]) =>
doc?.create(answer as Answer)
)
)
await contractRef.update({ answers: answerObjects })
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
@ -240,3 +290,38 @@ export async function getContractFromSlug(slug: string) {
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}
async function createGroupLinks(
group: Group,
contractIds: string[],
userId: string
) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)
if (!contract?.groupSlugs?.includes(group.slug)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
})
}
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupLinks: [
{
groupId: group.id,
name: group.name,
slug: group.slug,
userId,
createdTime: Date.now(),
} as GroupLink,
...(contract?.groupLinks ?? []),
],
})
}
}
}

View File

@ -29,12 +29,22 @@ export const createNotification = async (
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract?: Contract,
relatedSourceType?: notification_source_types,
relatedUserId?: string,
sourceSlug?: string,
sourceTitle?: string
miscData?: {
contract?: Contract
relatedSourceType?: notification_source_types
relatedUserId?: string
slug?: string
title?: string
}
) => {
const {
contract: sourceContract,
relatedSourceType,
relatedUserId,
slug,
title,
} = miscData ?? {}
const shouldGetNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
@ -70,8 +80,8 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
sourceSlug: slug ? slug : sourceContract?.slug,
sourceTitle: title ? title : sourceContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
@ -253,20 +263,6 @@ export const createNotification = async (
}
}
const notifyUserReceivedReferralBonus = async (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
// If the referrer is the market creator, just tell them they joined to bet on their market
reason:
sourceContract?.creatorId === relatedUserId
? 'user_joined_to_bet_on_your_market'
: 'you_referred_user',
}
}
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
@ -284,8 +280,6 @@ export const createNotification = async (
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
} else if (sourceType === 'user' && relatedUserId) {
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
}
// The following functions need sourceContract to be defined.
@ -411,6 +405,7 @@ export const createGroupCommentNotification = async (
group: Group,
idempotencyKey: string
) => {
if (toUserId === fromUser.id) return
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
@ -434,3 +429,52 @@ export const createGroupCommentNotification = async (
}
await notificationRef.set(removeUndefinedProps(notification))
}
export const createReferralNotification = async (
toUser: User,
referredUser: User,
idempotencyKey: string,
bonusAmount: string,
referredByContract?: Contract,
referredByGroup?: Group
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: referredByGroup
? 'user_joined_from_your_group_invite'
: referredByContract?.creatorId === toUser.id
? 'user_joined_to_bet_on_your_market'
: 'you_referred_user',
createdTime: Date.now(),
isSeen: false,
sourceId: referredUser.id,
sourceType: 'user',
sourceUpdateType: 'updated',
sourceContractId: referredByContract?.id,
sourceUserName: referredUser.name,
sourceUserUsername: referredUser.username,
sourceUserAvatarUrl: referredUser.avatarUrl,
sourceText: bonusAmount,
// Only pass the contract referral details if they weren't referred to a group
sourceContractCreatorUsername: !referredByGroup
? referredByContract?.creatorUsername
: undefined,
sourceContractTitle: !referredByGroup
? referredByContract?.question
: undefined,
sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined,
sourceSlug: referredByGroup
? groupPath(referredByGroup.slug)
: referredByContract?.slug,
sourceTitle: referredByGroup
? referredByGroup.name
: referredByContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`

View File

@ -64,10 +64,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
const deviceUsedBefore =
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0
const balance =
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE
const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE
const user: User = {
id: auth.uid,
@ -81,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
}
await firestore.collection('users').doc(auth.uid).create(user)
@ -114,7 +112,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => {
return !snap.empty
}
const numberUsersWithIp = async (ipAddress: string) => {
export const numberUsersWithIp = async (ipAddress: string) => {
const snap = await firestore
.collection('private-users')
.where('initialIpAddress', '==', ipAddress)
@ -160,7 +158,7 @@ const addUserToDefaultGroups = async (user: User) => {
id: welcomeCommentDoc.id,
groupId: group.id,
userId: manifoldAccount,
text: `Welcome, ${user.name} (@${user.username})!`,
text: `Welcome, @${user.username} aka ${user.name}!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: MANIFOLD_USERNAME,

View File

@ -301,7 +301,7 @@ export const sendNewCommentEmail = async (
)}`
}
const subject = `Comment from ${commentorName} on ${question}`
const subject = `Comment on ${question}`
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {

View File

@ -0,0 +1,18 @@
import { User } from 'common/user'
import * as admin from 'firebase-admin'
import { newEndpoint, APIError } from './api'
export const getcurrentuser = newEndpoint(
{ method: 'GET' },
async (_req, auth) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const [userSnap] = await firestore.getAll(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as User
return user
}
)
const firestore = admin.firestore()

View File

@ -1,6 +1,6 @@
import { newEndpoint } from './api'
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
export const health = newEndpoint({ method: 'GET' }, async (_req, auth) => {
return {
message: 'Server is working.',
uid: auth.uid,

View File

@ -1,4 +1,6 @@
import * as admin from 'firebase-admin'
import { onRequest } from 'firebase-functions/v2/https'
import { EndpointDefinition } from './api'
admin.initializeApp()
@ -22,6 +24,7 @@ export * from './on-update-user'
export * from './on-create-comment-on-group'
export * from './on-create-txn'
export * from './on-delete-group'
export * from './score-contracts'
// v2
export * from './health'
@ -41,4 +44,68 @@ export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
export * from './mana-bonus-email'
import { health } from './health'
import { transact } from './transact'
import { changeuserinfo } from './change-user-info'
import { createuser } from './create-user'
import { createanswer } from './create-answer'
import { placebet } from './place-bet'
import { cancelbet } from './cancel-bet'
import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-contract'
import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
}
const healthFunction = toCloudFunction(health)
const transactFunction = toCloudFunction(transact)
const changeUserInfoFunction = toCloudFunction(changeuserinfo)
const createUserFunction = toCloudFunction(createuser)
const createAnswerFunction = toCloudFunction(createanswer)
const placeBetFunction = toCloudFunction(placebet)
const cancelBetFunction = toCloudFunction(cancelbet)
const sellBetFunction = toCloudFunction(sellbet)
const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
export {
healthFunction as health,
transactFunction as transact,
changeUserInfoFunction as changeuserinfo,
createUserFunction as createuser,
createAnswerFunction as createanswer,
placeBetFunction as placebet,
cancelBetFunction as cancelbet,
sellBetFunction as sellbet,
sellSharesFunction as sellshares,
claimManalinkFunction as claimmanalink,
createMarketFunction as createmarket,
addLiquidityFunction as addliquidity,
withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket,
unsubscribeFunction as unsubscribe,
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
}

View File

@ -64,7 +64,7 @@ async function sendMarketCloseEmails() {
user,
'closed' + contract.id.slice(6, contract.id.length),
contract.closeTime?.toString() ?? new Date().toString(),
contract
{ contract }
)
}
}

View File

@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore
contractId: string
}
const { eventId } = context
const contract = await getContract(contractId)
if (!contract)
throw new Error('Could not find contract corresponding with answer')
const answer = change.data() as Answer
// Ignore ante answer.
if (answer.number === 0) return
const contract = await getContract(contractId)
if (!contract)
throw new Error('Could not find contract corresponding with answer')
const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator')
@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore
answerCreator,
eventId,
answer.text,
contract
{ contract }
)
})

View File

@ -64,10 +64,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
if (!previousUniqueBettorIds) {
const contractBets = (
await firestore
.collection(`contracts/${contractId}/bets`)
.where('userId', '!=', contract.creatorId)
.get()
await firestore.collection(`contracts/${contractId}/bets`).get()
).docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
@ -82,9 +79,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
)
}
const isNewUniqueBettor =
!previousUniqueBettorIds.includes(bettorId) &&
bettorId !== contract.creatorId
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
// Update contract unique bettors
@ -96,7 +91,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
uniqueBettorCount: newUniqueBettorIds.length,
})
}
if (!isNewUniqueBettor) return
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettorId == contract.creatorId) return
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
@ -134,12 +131,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
fromUser,
eventId + '-bonus',
result.txn.amount + '',
contract,
undefined,
// No need to set the user id, we'll use the contract creator id
undefined,
contract.slug,
contract.question
{
contract,
slug: contract.slug,
title: contract.question,
}
)
}
}

View File

@ -68,7 +68,7 @@ export const onCreateCommentOnContract = functions
? 'answer'
: undefined
const relatedUser = comment.replyToCommentId
const relatedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
@ -79,9 +79,7 @@ export const onCreateCommentOnContract = functions
commentCreator,
eventId,
comment.text,
contract,
relatedSourceType,
relatedUser
{ contract, relatedSourceType, relatedUserId }
)
const recipientUserIds = uniq([

View File

@ -21,6 +21,6 @@ export const onCreateContract = functions.firestore
contractCreator,
eventId,
richTextToString(contract.description as JSONContent),
contract
{ contract }
)
})

View File

@ -20,11 +20,11 @@ export const onCreateGroup = functions.firestore
groupCreator,
eventId,
group.about,
undefined,
undefined,
memberId,
group.slug,
group.name
{
relatedUserId: memberId,
slug: group.slug,
title: group.name,
}
)
}
})

View File

@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore
.onCreate(async (change, context) => {
const liquidity = change.data() as LiquidityProvision
const { eventId } = context
const contract = await getContract(liquidity.contractId)
if (!contract)
throw new Error('Could not find contract corresponding with liquidity')
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
const contract = await getContract(liquidity.contractId)
if (!contract)
throw new Error('Could not find contract corresponding with liquidity')
const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore
liquidityProvider,
eventId,
liquidity.amount.toString(),
contract
{ contract }
)
})

View File

@ -3,6 +3,7 @@ import * as admin from 'firebase-admin'
import { Group } from 'common/group'
import { Contract } from 'common/contract'
const firestore = admin.firestore()
export const onDeleteGroup = functions.firestore
@ -15,17 +16,21 @@ export const onDeleteGroup = functions.firestore
.collection('contracts')
.where('groupSlugs', 'array-contains', group.slug)
.get()
console.log("contracts with group's slug:", contracts)
for (const doc of contracts.docs) {
const contract = doc.data() as Contract
const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug
)
// remove the group from the contract
await firestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: (contract.groupSlugs ?? []).filter(
(groupSlug) => groupSlug !== group.slug
),
groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug),
groupLinks: newGroupLinks ?? [],
})
}
})

View File

@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore
followingUser,
eventId,
'',
undefined,
undefined,
follow.userId
{ relatedUserId: follow.userId }
)
})

View File

@ -36,7 +36,7 @@ export const onUpdateContract = functions.firestore
contractUpdater,
eventId,
resolutionText,
contract
{ contract }
)
} else if (
previousValue.closeTime !== contract.closeTime ||
@ -62,7 +62,7 @@ export const onUpdateContract = functions.firestore
contractUpdater,
eventId,
sourceText,
contract
{ contract }
)
}
})

View File

@ -1,6 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
import { getContract } from './utils'
import { uniq } from 'lodash'
const firestore = admin.firestore()
export const onUpdateGroup = functions.firestore
@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore
const prevGroup = change.before.data() as Group
const group = change.after.data() as Group
// ignore the update we just made
// Ignore the activity update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return
@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore
.doc(group.id)
.update({ mostRecentActivityTime: Date.now() })
})
export async function removeGroupLinks(group: Group, contractIds: string[]) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([
...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ??
[]),
]),
groupLinks: [
...(contract?.groupLinks?.filter(
(link) => link.groupId !== group.id
) ?? []),
],
})
}
}

View File

@ -2,11 +2,12 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { REFERRAL_AMOUNT, User } from '../../common/user'
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createNotification } from './create-notification'
import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
import { LimitBet } from 'common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from 'common/group'
const firestore = admin.firestore()
export const onUpdateUser = functions.firestore
@ -54,6 +55,17 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
}
console.log(`referredByContract: ${referredByContract}`)
let referredByGroup: Group | undefined = undefined
if (user.referredByGroupId) {
const referredByGroupDoc = firestore.doc(
`groups/${user.referredByGroupId}`
)
referredByGroup = await transaction
.get(referredByGroupDoc)
.then((snap) => snap.data() as Group)
}
console.log(`referredByGroup: ${referredByGroup}`)
const txns = (
await firestore
.collection('txns')
@ -91,8 +103,8 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
}
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
await transaction.set(txnDoc, txn)
const txnDoc = firestore.collection(`txns/`).doc(txn.id)
transaction.set(txnDoc, txn)
console.log('created referral with txn id:', txn.id)
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
transaction.update(referredByUserDoc, {
@ -100,18 +112,13 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
})
await createNotification(
user.id,
'user',
'updated',
await createReferralNotification(
referredByUser,
user,
eventId,
txn.amount.toString(),
referredByContract,
'user',
referredByUser.id,
referredByContract?.slug,
referredByContract?.question
referredByGroup
)
})
}

View File

@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb,
unfilledBets
)
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
} else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
mechanism == 'dpm-2'
) {
const { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc)

View File

@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import {
Contract,
FreeResponseContract,
MultipleChoiceContract,
RESOLUTIONS,
} from '../../common/contract'
import { User } from '../../common/user'
@ -17,6 +18,7 @@ import {
groupPayoutsByUser,
Payout,
} from '../../common/payouts'
import { isAdmin } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
@ -68,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] }
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body)
const userId = auth.uid
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
@ -82,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
req.body
)
if (creatorId !== userId)
if (creatorId !== auth.uid && !isAdmin(auth.uid))
throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
@ -245,7 +245,10 @@ function getResolutionParams(contract: Contract, body: string) {
...validate(pseudoNumericSchema, body),
resolutions: undefined,
}
} else if (outcomeType === 'FREE_RESPONSE') {
} else if (
outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE'
) {
const freeResponseParams = validate(freeResponseSchema, body)
const { outcome } = freeResponseParams
switch (outcome) {
@ -292,7 +295,10 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
}
function validateAnswer(contract: FreeResponseContract, answer: number) {
function validateAnswer(
contract: FreeResponseContract | MultipleChoiceContract,
answer: number
) {
const validIds = contract.answers.map((a) => a.id)
if (!validIds.includes(answer.toString())) {
throw new APIError(400, `${answer} is not a valid answer ID`)

View File

@ -0,0 +1,54 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Bet } from 'common/bet'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { log } from './utils'
export const scoreContracts = functions.pubsub
.schedule('every 1 hours')
.onRun(async () => {
await scoreContractsInternal()
})
const firestore = admin.firestore()
async function scoreContractsInternal() {
const now = Date.now()
const lastHour = now - 60 * 60 * 1000
const last3Days = now - 1000 * 60 * 60 * 24 * 3
const activeContractsSnap = await firestore
.collection('contracts')
.where('lastUpdatedTime', '>', lastHour)
.get()
const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract
)
// We have to downgrade previously active contracts to allow the new ones to bubble up
const previouslyActiveContractsSnap = await firestore
.collection('contracts')
.where('popularityScore', '>', 0)
.get()
const activeContractIds = activeContracts.map((c) => c.id)
const previouslyActiveContracts = previouslyActiveContractsSnap.docs
.map((doc) => doc.data() as Contract)
.filter((c) => !activeContractIds.includes(c.id))
const contracts = activeContracts.concat(previouslyActiveContracts)
log(`Found ${contracts.length} contracts to score`)
for (const contract of contracts) {
const bets = await firestore
.collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', last3Days)
.get()
const bettors = bets.docs
.map((doc) => doc.data() as Bet)
.map((bet) => bet.userId)
const score = uniq(bettors).length
if (contract.popularityScore !== score)
await firestore
.collection('contracts')
.doc(contract.id)
.update({ popularityScore: score })
}
}

View File

@ -0,0 +1,55 @@
// We have some old comments without IDs and user IDs. Let's fill them in.
// Luckily, this was back when all comments had associated bets, so it's possible
// to retrieve the user IDs through the bets.
import * as admin from 'firebase-admin'
import { QueryDocumentSnapshot } from 'firebase-admin/firestore'
import { initAdmin } from './script-init'
import { log, writeAsync } from '../utils'
import { Bet } from '../../../common/bet'
initAdmin()
const firestore = admin.firestore()
const getUserIdsByCommentId = async (comments: QueryDocumentSnapshot[]) => {
const bets = await firestore.collectionGroup('bets').get()
log(`Loaded ${bets.size} bets.`)
const betsById = Object.fromEntries(
bets.docs.map((b) => [b.id, b.data() as Bet])
)
return Object.fromEntries(
comments.map((c) => [c.id, betsById[c.data().betId].userId])
)
}
if (require.main === module) {
const commentsQuery = firestore.collectionGroup('comments')
commentsQuery.get().then(async (commentSnaps) => {
log(`Loaded ${commentSnaps.size} comments.`)
const needsFilling = commentSnaps.docs.filter((ct) => {
return !('id' in ct.data()) || !('userId' in ct.data())
})
log(`${needsFilling.length} comments need IDs.`)
const userIdNeedsFilling = needsFilling.filter((ct) => {
return !('userId' in ct.data())
})
log(`${userIdNeedsFilling.length} comments need user IDs.`)
const userIdsByCommentId =
userIdNeedsFilling.length > 0
? await getUserIdsByCommentId(userIdNeedsFilling)
: {}
const updates = needsFilling.map((ct) => {
const fields: { [k: string]: unknown } = {}
if (!ct.data().id) {
fields.id = ct.id
}
if (!ct.data().userId && userIdsByCommentId[ct.id]) {
fields.userId = userIdsByCommentId[ct.id]
}
return { doc: ct.ref, fields }
})
log(`Updating ${updates.length} comments.`)
await writeAsync(firestore, updates)
log(`Updated all comments.`)
})
}

View File

@ -0,0 +1,25 @@
// We have some groups without IDs. Let's fill them in.
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { log, writeAsync } from '../utils'
initAdmin()
const firestore = admin.firestore()
if (require.main === module) {
const groupsQuery = firestore.collection('groups')
groupsQuery.get().then(async (groupSnaps) => {
log(`Loaded ${groupSnaps.size} groups.`)
const needsFilling = groupSnaps.docs.filter((ct) => {
return !('id' in ct.data())
})
log(`${needsFilling.length} groups need IDs.`)
const updates = needsFilling.map((group) => {
return { doc: group.ref, fields: { id: group.id } }
})
log(`Updating ${updates.length} groups.`)
await writeAsync(firestore, updates)
log(`Updated all groups.`)
})
}

View File

@ -1,14 +1,9 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues, isProd } from '../utils'
import {
CATEGORIES_GROUP_SLUG_POSTFIX,
DEFAULT_CATEGORIES,
} from 'common/categories'
import { Group } from 'common/group'
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
import { Group, GroupLink } from 'common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { User } from 'common/user'
@ -18,28 +13,12 @@ import {
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
initAdmin()
const adminFirestore = admin.firestore()
async function convertCategoriesToGroups() {
const groups = await getValues<Group>(adminFirestore.collection('groups'))
const contracts = await getValues<Contract>(
adminFirestore.collection('contracts')
)
for (const group of groups) {
const groupContracts = contracts.filter((contract) =>
group.contractIds.includes(contract.id)
)
for (const contract of groupContracts) {
await adminFirestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
})
}
}
for (const category of Object.values(DEFAULT_CATEGORIES)) {
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
for (const category of categories) {
const markets = await getValues<Contract>(
adminFirestore
.collection('contracts')
@ -77,7 +56,7 @@ async function convertCategoriesToGroups() {
createdTime: Date.now(),
anyoneCanJoin: true,
memberIds: [manifoldAccount],
about: 'Official group for all things related to ' + category,
about: 'Default group for all things related to ' + category,
mostRecentActivityTime: Date.now(),
contractIds: markets.map((market) => market.id),
chatDisabled: true,
@ -93,16 +72,35 @@ async function convertCategoriesToGroups() {
})
for (const market of markets) {
if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id))
continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: newGroup.id,
createdTime: Date.now(),
slug: newGroup.slug,
name: newGroup.name,
} as GroupLink,
]
await adminFirestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]),
groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]),
groupLinks: newGroupLinks,
})
}
}
}
async function convertCategoriesToGroups() {
// const defaultCategories = Object.values(DEFAULT_CATEGORIES)
const moreCategories = ['world', 'culture']
await convertCategoriesToGroupsInternal(moreCategories)
}
if (require.main === module) {
convertCategoriesToGroups()
.then(() => process.exit())

View File

@ -0,0 +1,53 @@
import { getValues } from 'functions/src/utils'
import { Group } from 'common/group'
import { Contract } from 'common/contract'
import { initAdmin } from 'functions/src/scripts/script-init'
import * as admin from 'firebase-admin'
import { filterDefined } from 'common/util/array'
import { uniq } from 'lodash'
initAdmin()
const adminFirestore = admin.firestore()
const addGroupIdToContracts = async () => {
const groups = await getValues<Group>(adminFirestore.collection('groups'))
for (const group of groups) {
const groupContracts = await getValues<Contract>(
adminFirestore
.collection('contracts')
.where('groupSlugs', 'array-contains', group.slug)
)
for (const contract of groupContracts) {
const oldGroupLinks = contract.groupLinks?.filter(
(l) => l.slug != group.slug
)
const newGroupLinks = filterDefined([
...(oldGroupLinks ?? []),
group.id
? {
slug: group.slug,
name: group.name,
groupId: group.id,
createdTime: Date.now(),
}
: undefined,
])
await adminFirestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks,
})
}
}
}
if (require.main === module) {
addGroupIdToContracts()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -66,10 +66,18 @@ export const getServiceAccountCredentials = (env?: string) => {
}
export const initAdmin = (env?: string) => {
const serviceAccount = getServiceAccountCredentials(env)
console.log(`Initializing connection to ${serviceAccount.project_id}...`)
return admin.initializeApp({
projectId: serviceAccount.project_id,
credential: admin.credential.cert(serviceAccount),
})
try {
const serviceAccount = getServiceAccountCredentials(env)
console.log(
`Initializing connection to ${serviceAccount.project_id} Firebase...`
)
return admin.initializeApp({
projectId: serviceAccount.project_id,
credential: admin.credential.cert(serviceAccount),
})
} catch (err) {
console.error(err)
console.log(`Initializing connection to default Firebase...`)
return admin.initializeApp()
}
}

View File

@ -1,4 +1,4 @@
import { sumBy, uniq } from 'lodash'
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin'
import { z } from 'zod'
@ -9,15 +9,15 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues, log } from './utils'
import { Bet } from '../../common/bet'
import { floatingLesserEqual } from '../../common/util/math'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares'
const bodySchema = z.object({
contractId: z.string(),
shares: z.number(),
outcome: z.enum(['YES', 'NO']),
shares: z.number().optional(), // leave it out to sell all shares
outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have
})
export const sellshares = newEndpoint({}, async (req, auth) => {
@ -46,14 +46,37 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
throw new APIError(400, 'Trading is closed.')
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
sumBy(bets, (b) => b.shares)
)
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
let chosenOutcome: 'YES' | 'NO'
if (outcome != null) {
chosenOutcome = outcome
} else {
const nonzeroShares = Object.entries(sharesByOutcome).filter(
([_k, v]) => !floatingEqual(0, v)
)
if (nonzeroShares.length == 0) {
throw new APIError(400, "You don't own any shares in this market.")
}
if (nonzeroShares.length > 1) {
throw new APIError(
400,
`You own multiple kinds of shares, but did not specify which to sell.`
)
}
chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO'
}
if (!floatingLesserEqual(shares, maxShares))
const maxShares = sharesByOutcome[chosenOutcome]
const sharesToSell = shares ?? maxShares
if (!floatingLesserEqual(sharesToSell, maxShares))
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const soldShares = Math.min(shares, maxShares)
const soldShares = Math.min(sharesToSell, maxShares)
const unfilledBetsSnap = await transaction.get(
getUnfilledBetsQuery(contractDoc)
@ -62,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
outcome,
chosenOutcome,
contract,
prevLoanAmount,
unfilledBets

70
functions/src/serve.ts Normal file
View File

@ -0,0 +1,70 @@
import * as cors from 'cors'
import * as express from 'express'
import { Express, Request, Response, NextFunction } from 'express'
import { EndpointDefinition } from './api'
const PORT = 8088
import { initAdmin } from './scripts/script-init'
initAdmin()
import { health } from './health'
import { transact } from './transact'
import { changeuserinfo } from './change-user-info'
import { createuser } from './create-user'
import { createanswer } from './create-answer'
import { placebet } from './place-bet'
import { cancelbet } from './cancel-bet'
import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-contract'
import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
const addEndpointRoute = (
path: string,
endpoint: EndpointDefinition,
...middlewares: Middleware[]
) => {
const method = endpoint.opts.method.toLowerCase() as keyof Express
const corsMiddleware = cors({ origin: endpoint.opts.cors })
const allMiddleware = [...middlewares, corsMiddleware]
app.options(path, corsMiddleware) // preflight requests
app[method](path, ...allMiddleware, endpoint.handler)
}
const addJsonEndpointRoute = (name: string, endpoint: EndpointDefinition) => {
addEndpointRoute(name, endpoint, express.json())
}
addEndpointRoute('/health', health)
addJsonEndpointRoute('/transact', transact)
addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
addJsonEndpointRoute('/createuser', createuser)
addJsonEndpointRoute('/createanswer', createanswer)
addJsonEndpointRoute('/placebet', placebet)
addJsonEndpointRoute('/cancelbet', cancelbet)
addJsonEndpointRoute('/sellbet', sellbet)
addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`)

View File

@ -1,7 +1,7 @@
import { onRequest } from 'firebase-functions/v2/https'
import * as admin from 'firebase-admin'
import Stripe from 'stripe'
import { EndpointDefinition } from './api'
import { getPrivateUser, getUser, isProd, payUser } from './utils'
import { sendThankYouEmail } from './emails'
import { track } from './analytics'
@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd()
10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE',
}
export const createcheckoutsession = onRequest(
{ minInstances: 1, secrets: ['STRIPE_APIKEY'] },
async (req, res) => {
export const createcheckoutsession: EndpointDefinition = {
opts: { method: 'POST', minInstances: 1, secrets: ['STRIPE_APIKEY'] },
handler: async (req, res) => {
const userId = req.query.userId?.toString()
const manticDollarQuantity = req.query.manticDollarQuantity?.toString()
@ -86,21 +86,24 @@ export const createcheckoutsession = onRequest(
})
res.redirect(303, session.url || '')
}
)
},
}
export const stripewebhook = onRequest(
{
export const stripewebhook: EndpointDefinition = {
opts: {
method: 'POST',
minInstances: 1,
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
},
async (req, res) => {
handler: async (req, res) => {
const stripe = initStripe()
let event
try {
// Cloud Functions jam the raw body into a special `rawBody` property
const rawBody = (req as any).rawBody ?? req.body
event = stripe.webhooks.constructEvent(
req.rawBody,
rawBody,
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOKSECRET as string
)
@ -116,8 +119,8 @@ export const stripewebhook = onRequest(
}
res.status(200).send('success')
}
)
},
}
const issueMoneys = async (session: StripeSession) => {
const { id: sessionId } = session

View File

@ -1,66 +1,72 @@
import { onRequest } from 'firebase-functions/v2/https'
import * as admin from 'firebase-admin'
import { EndpointDefinition } from './api'
import { getUser } from './utils'
import { PrivateUser } from '../../common/user'
export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => {
const id = req.query.id as string
let type = req.query.type as string
if (!id || !type) {
res.status(400).send('Empty id or type parameter.')
return
}
export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 },
handler: async (req, res) => {
const id = req.query.id as string
let type = req.query.type as string
if (!id || !type) {
res.status(400).send('Empty id or type parameter.')
return
}
if (type === 'market-resolved') type = 'market-resolve'
if (type === 'market-resolved') type = 'market-resolve'
if (
!['market-resolve', 'market-comment', 'market-answer', 'generic'].includes(
type
)
) {
res.status(400).send('Invalid type parameter.')
return
}
if (
![
'market-resolve',
'market-comment',
'market-answer',
'generic',
].includes(type)
) {
res.status(400).send('Invalid type parameter.')
return
}
const user = await getUser(id)
const user = await getUser(id)
if (!user) {
res.send('This user is not currently subscribed or does not exist.')
return
}
if (!user) {
res.send('This user is not currently subscribed or does not exist.')
return
}
const { name } = user
const { name } = user
const update: Partial<PrivateUser> = {
...(type === 'market-resolve' && {
unsubscribedFromResolutionEmails: true,
}),
...(type === 'market-comment' && {
unsubscribedFromCommentEmails: true,
}),
...(type === 'market-answer' && {
unsubscribedFromAnswerEmails: true,
}),
...(type === 'generic' && {
unsubscribedFromGenericEmails: true,
}),
}
const update: Partial<PrivateUser> = {
...(type === 'market-resolve' && {
unsubscribedFromResolutionEmails: true,
}),
...(type === 'market-comment' && {
unsubscribedFromCommentEmails: true,
}),
...(type === 'market-answer' && {
unsubscribedFromAnswerEmails: true,
}),
...(type === 'generic' && {
unsubscribedFromGenericEmails: true,
}),
}
await firestore.collection('private-users').doc(id).update(update)
await firestore.collection('private-users').doc(id).update(update)
if (type === 'market-resolve')
res.send(
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
)
else if (type === 'market-comment')
res.send(
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
)
else if (type === 'market-answer')
res.send(
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
)
else res.send(`${name}, you have been unsubscribed.`)
})
if (type === 'market-resolve')
res.send(
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
)
else if (type === 'market-comment')
res.send(
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
)
else if (type === 'market-answer')
res.send(
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
)
else res.send(`${name}, you have been unsubscribed.`)
},
}
const firestore = admin.firestore()

View File

@ -11,8 +11,6 @@ import { last } from 'lodash'
const firestore = admin.firestore()
const oneDay = 1000 * 60 * 60 * 24
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
@ -59,8 +57,8 @@ export const updateMetricsCore = async () => {
return {
doc: firestore.collection('contracts').doc(contract.id),
fields: {
volume24Hours: computeVolume(contractBets, now - oneDay),
volume7Days: computeVolume(contractBets, now - oneDay * 7),
volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
},
}
})

View File

@ -14,10 +14,13 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0",
"concurrently": "6.5.1",
"eslint": "8.15.0",
"eslint-plugin-lodash": "^7.4.0",
"prettier": "2.5.0",
"typescript": "4.6.4"
"typescript": "4.6.4",
"ts-node": "10.9.1",
"nodemon": "2.0.19"
},
"resolutions": {
"@types/react": "17.0.43"

View File

@ -1,3 +1,4 @@
# Ignore Next artifacts
.next/
out/
out/
public/**/*.json

View File

@ -0,0 +1,210 @@
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import toast from 'react-hot-toast'
import { track } from '@amplitude/analytics-browser'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
export function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
track('In-App Notification Preferences Changed', {
newPreference: newValue,
oldPreference: privateUser.notificationPreferences,
})
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolution of questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
</div>
</div>
)
}

View File

@ -1,24 +1,26 @@
import { ExclamationIcon } from '@heroicons/react/solid'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { Linkify } from './linkify'
export 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>
<Col className="rounded-md bg-yellow-50 p-4">
<Row className="mb-2 flex-shrink-0">
<ExclamationIcon
className="h-5 w-5 text-yellow-400"
aria-hidden="true"
/>
<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>
</Row>
<div className="mt-2 whitespace-pre-line text-sm text-yellow-700">
<Linkify text={text} />
</div>
</div>
</Col>
)
}

View File

@ -41,7 +41,7 @@ export function AmountInput(props: {
<span className="bg-gray-200 text-sm">{label}</span>
<input
className={clsx(
'input input-bordered max-w-[200px] text-lg',
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
error && 'input-error',
inputClassName
)}

View File

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col'
import { APIError, placeBet } from 'web/lib/firebase/api'
@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device'
export function AnswerBetPanel(props: {
answer: Answer
contract: FreeResponseContract
contract: FreeResponseContract | MultipleChoiceContract
closePanel: () => void
className?: string
isModal?: boolean

View File

@ -1,7 +1,7 @@
import clsx from 'clsx'
import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Avatar } from '../avatar'
@ -13,7 +13,7 @@ import { Linkify } from '../linkify'
export function AnswerItem(props: {
answer: Answer
contract: FreeResponseContract
contract: FreeResponseContract | MultipleChoiceContract
showChoice: 'radio' | 'checkbox' | undefined
chosenProb: number | undefined
totalChosenProb?: number

View File

@ -2,7 +2,7 @@ import clsx from 'clsx'
import { sum } from 'lodash'
import { useState } from 'react'
import { Contract, FreeResponse } from 'common/contract'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col'
import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { Row } from '../layout/row'
@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object'
export function AnswerResolvePanel(props: {
contract: Contract & FreeResponse
contract: FreeResponseContract | MultipleChoiceContract
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
setResolveOption: (
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined

View File

@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react'
import { Bet } from 'common/bet'
import { FreeResponseContract } from 'common/contract'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate'
import { useWindowSize } from 'web/hooks/use-window-size'
const NUM_LINES = 6
export const AnswersGraph = memo(function AnswersGraph(props: {
contract: FreeResponseContract
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
height?: number
}) {
@ -178,15 +178,22 @@ function formatTime(
return d.format(format)
}
const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => {
const { totalBets } = contract
const computeProbsByOutcome = (
bets: Bet[],
contract: FreeResponseContract | MultipleChoiceContract
) => {
const { totalBets, outcomeType } = contract
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter)
)
return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001
return (
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
maxProb > 0.02 &&
totalBets[outcome] > 0.000000001
)
})
const trackedOutcomes = sortBy(

View File

@ -1,7 +1,7 @@
import { sortBy, partition, sum, uniq } from 'lodash'
import { useEffect, useState } from 'react'
import { FreeResponseContract } from 'common/contract'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col'
import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
@ -25,14 +25,19 @@ 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: FreeResponseContract }) {
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
}) {
const { contract } = props
const { creatorId, resolution, resolutions, totalBets } = contract
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract
const answers = useAnswers(contract.id) ?? contract.answers
const [winningAnswers, losingAnswers] = partition(
answers.filter(
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
(answer) =>
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
),
(answer) =>
answer.id === resolution || (resolutions && resolutions[answer.id])
@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
<div className="pb-4 text-gray-500">No answers yet...</div>
)}
{tradingAllowed(contract) &&
{outcomeType === 'FREE_RESPONSE' &&
tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} />
)}
@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
}
function getAnswerItems(
contract: FreeResponseContract,
contract: FreeResponseContract | MultipleChoiceContract,
answers: Answer[],
user: User | undefined | null
) {
@ -178,7 +184,7 @@ function getAnswerItems(
}
function OpenAnswer(props: {
contract: FreeResponseContract
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
items: ActivityItem[]
type: string

View File

@ -0,0 +1,65 @@
import { MAX_ANSWER_LENGTH } from 'common/answer'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
export function MultipleChoiceAnswers(props: {
setAnswers: (answers: string[]) => void
}) {
const [answers, setInternalAnswers] = useState(['', '', ''])
const setAnswer = (i: number, answer: string) => {
const newAnswers = setElement(answers, i, answer)
setInternalAnswers(newAnswers)
props.setAnswers(newAnswers)
}
const removeAnswer = (i: number) => {
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
setInternalAnswers(newAnswers)
props.setAnswers(newAnswers)
}
const addAnswer = () => setAnswer(answers.length, '')
return (
<Col>
{answers.map((answer, i) => (
<Row className="mb-2 items-center align-middle">
{i + 1}.{' '}
<Textarea
value={answer}
onChange={(e) => setAnswer(i, e.target.value)}
className="textarea textarea-bordered ml-2 w-full resize-none"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}
/>
{answers.length > 2 && (
<button
className="btn btn-xs btn-outline ml-2"
onClick={() => removeAnswer(i)}
>
<XIcon className="h-4 w-4 flex-shrink-0" />
</button>
)}
</Row>
))}
<Row className="justify-end">
<button className="btn btn-outline btn-xs" onClick={addAnswer}>
Add answer
</button>
</Row>
</Col>
)
}
const setElement = <T,>(array: T[], i: number, elem: T) => {
const newArray = array.concat()
newArray[i] = elem
return newArray
}

View File

@ -0,0 +1,77 @@
import { createContext, useEffect } from 'react'
import { User } from 'common/user'
import { onIdTokenChanged } from 'firebase/auth'
import {
auth,
listenForUser,
getUser,
setCachedReferralInfoForUser,
} from 'web/lib/firebase/users'
import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
// Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in (User).
type AuthUser = undefined | null | User
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token')
if (!deviceToken) {
deviceToken = randomString()
localStorage.setItem('device-token', deviceToken)
}
return deviceToken
}
export const AuthContext = createContext<AuthUser>(null)
export function AuthProvider({ children }: any) {
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined)
useEffect(() => {
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
setAuthUser(cachedUser && JSON.parse(cachedUser))
}, [setAuthUser])
useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) {
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
let user = await getUser(fbUser.uid)
if (!user) {
const deviceToken = ensureDeviceToken()
user = (await createUser({ deviceToken })) as User
}
setAuthUser(user)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user)
} else {
// User logged out; reset to null
deleteAuthCookies()
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
}
})
}, [setAuthUser])
const authUserId = authUser?.id
const authUsername = authUser?.username
useEffect(() => {
if (authUserId && authUsername) {
identifyUser(authUserId)
setUserProperty('username', authUsername)
return listenForUser(authUserId, setAuthUser)
}
}, [authUserId, authUsername, setAuthUser])
return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
)
}

View File

@ -31,6 +31,7 @@ export function Avatar(props: {
!noLink && 'cursor-pointer',
className
)}
style={{ maxWidth: `${s * 0.25}rem` }}
src={avatarUrl}
onClick={onClick}
alt={username}

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import { partition, sum, sumBy } from 'lodash'
import { clamp, partition, sum, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
@ -13,34 +13,37 @@ import {
formatPercent,
formatWithCommas,
} from 'common/util/format'
import { getBinaryCpmmBetInfo } from 'common/new-bet'
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet } from 'web/lib/firebase/api'
import { sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label'
import {
BinaryOutcomeLabel,
HigherLabel,
LowerLabel,
NoLabel,
YesLabel,
} from './outcome-label'
import { getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import {
getFormattedMappedValue,
getPseudoProbability,
} from 'common/pseudo-numeric'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { SignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { ProbabilityInput } from './probability-input'
import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
import { removeUndefinedProps } from 'common/util/object'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets'
import { BucketInput } from './bucket-input'
import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
import { AlertBox } from './alert-box'
export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
@ -50,14 +53,10 @@ export function BetPanel(props: {
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false)
const showLimitOrders =
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
return (
<Col className={className}>
<SellRow
@ -75,17 +74,27 @@ export function BetPanel(props: {
<QuickOrLimitBet
isLimitOrder={isLimitOrder}
setIsLimitOrder={setIsLimitOrder}
hideToggle={!user}
/>
<BuyPanel
hidden={isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
isLimitOrder={isLimitOrder}
unfilledBets={unfilledBets}
/>
<SignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
</Col>
{showLimitOrders && (
{user && unfilledBets.length > 0 && (
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
)}
</Col>
@ -105,9 +114,6 @@ export function SimpleBetPanel(props: {
const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
const showLimitOrders =
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
return (
<Col className={className}>
@ -125,20 +131,30 @@ export function SimpleBetPanel(props: {
<QuickOrLimitBet
isLimitOrder={isLimitOrder}
setIsLimitOrder={setIsLimitOrder}
hideToggle={!user}
/>
<BuyPanel
hidden={isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
selected={selected}
onBuySuccess={onBetSuccess}
isLimitOrder={isLimitOrder}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
onBuySuccess={onBetSuccess}
/>
<SignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
</Col>
{showLimitOrders && (
{unfilledBets.length > 0 && (
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
)}
</Col>
@ -149,21 +165,17 @@ function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
isLimitOrder?: boolean
hidden: boolean
selected?: 'YES' | 'NO'
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } =
props
const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [limitProb, setLimitProb] = useState<number | undefined>(
Math.round(100 * initialProb)
)
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
@ -178,7 +190,7 @@ function BuyPanel(props: {
}, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') {
setBetChoice(choice)
setOutcome(choice)
setWasSubmitted(false)
focusAmountInput()
}
@ -186,29 +198,22 @@ function BuyPanel(props: {
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
if (!betChoice) {
setBetChoice('YES')
if (!outcome) {
setOutcome('YES')
}
}
async function submitBet() {
if (!user || !betAmount) return
if (isLimitOrder && limitProb === undefined) return
const limitProbScaled =
isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined
setError(undefined)
setIsSubmitting(true)
placeBet(
removeUndefinedProps({
amount: betAmount,
outcome: betChoice,
contractId: contract.id,
limitProb: limitProbScaled,
})
)
placeBet({
outcome,
amount: betAmount,
contractId: contract.id,
})
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
@ -232,21 +237,18 @@ function BuyPanel(props: {
slug: contract.slug,
contractId: contract.id,
amount: betAmount,
outcome: betChoice,
isLimitOrder,
limitProb: limitProbScaled,
outcome,
isLimitOrder: false,
})
}
const betDisabled = isSubmitting || !betAmount || error
const limitProbFrac = (limitProb ?? 0) / 100
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
betChoice ?? 'YES',
outcome ?? 'YES',
betAmount ?? 0,
contract,
isLimitOrder ? limitProbFrac : undefined,
undefined,
unfilledBets as LimitBet[]
)
@ -254,11 +256,7 @@ function BuyPanel(props: {
const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb)
const remainingMatched = isLimitOrder
? ((newBet.orderAmount ?? 0) - newBet.amount) /
(betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac)
: 0
const currentPayout = newBet.shares + remainingMatched
const currentPayout = newBet.shares
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
@ -267,15 +265,17 @@ function BuyPanel(props: {
const format = getFormattedMappedValue(contract)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
return (
<>
<Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500">
{isPseudoNumeric ? 'Direction' : 'Outcome'}
</div>
<YesNoSelector
className="mb-4"
btnClassName="flex-1"
selected={betChoice}
selected={outcome}
onSelect={(choice) => onBetChoice(choice)}
isPseudoNumeric={isPseudoNumeric}
/>
@ -290,61 +290,37 @@ function BuyPanel(props: {
disabled={isSubmitting}
inputRef={inputRef}
/>
{isLimitOrder && (
<>
<Row className="my-3 items-center gap-2 text-left text-sm text-gray-500">
Limit {isPseudoNumeric ? 'value' : 'probability'}
<InfoTooltip
text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${
isPseudoNumeric ? 'value' : 'probability'
} and wait to match other bets.`}
/>
</Row>
{isPseudoNumeric ? (
<BucketInput
contract={contract}
onBucketChange={(value) =>
setLimitProb(
value === undefined
? undefined
: 100 *
getPseudoProbability(
value,
contract.min,
contract.max,
contract.isLogScale
)
)
}
isSubmitting={isSubmitting}
/>
) : (
<ProbabilityInput
inputClassName="w-full max-w-none"
prob={limitProb}
onChange={setLimitProb}
disabled={isSubmitting}
/>
)}
</>
{(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (
''
)}
<Col className="mt-3 w-full gap-3">
{!isLimitOrder && (
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
{probStayedSame ? (
<div>{format(initialProb)}</div>
) : (
<div>
{format(initialProb)}
<span className="mx-2"></span>
{format(resultProb)}
</div>
{probStayedSame ? (
<div>{format(initialProb)}</div>
) : (
<div>
{format(initialProb)}
<span className="mx-2"></span>
{format(resultProb)}
</div>
)}
</Row>
)}
)}
</Row>
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
@ -353,7 +329,7 @@ function BuyPanel(props: {
'Max payout'
) : (
<>
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} />
</>
)}
</div>
@ -372,6 +348,348 @@ function BuyPanel(props: {
<Spacer h={8} />
{user && (
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: outcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
</button>
)}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
</Col>
)
}
function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
hidden: boolean
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
const betChoice = 'YES'
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const rangeError =
lowLimitProb !== undefined &&
highLimitProb !== undefined &&
lowLimitProb >= highLimitProb
const outOfRangeError =
(lowLimitProb !== undefined &&
(lowLimitProb <= 0 || lowLimitProb >= 100)) ||
(highLimitProb !== undefined &&
(highLimitProb <= 0 || highLimitProb >= 100))
const hasYesLimitBet = lowLimitProb !== undefined && !!betAmount
const hasNoLimitBet = highLimitProb !== undefined && !!betAmount
const hasTwoBets = hasYesLimitBet && hasNoLimitBet
const betDisabled =
isSubmitting ||
!betAmount ||
rangeError ||
outOfRangeError ||
error ||
(!hasYesLimitBet && !hasNoLimitBet)
const yesLimitProb =
lowLimitProb === undefined
? undefined
: clamp(lowLimitProb / 100, 0.001, 0.999)
const noLimitProb =
highLimitProb === undefined
? undefined
: clamp(highLimitProb / 100, 0.001, 0.999)
const amount = betAmount ?? 0
const shares =
yesLimitProb !== undefined && noLimitProb !== undefined
? Math.min(amount / yesLimitProb, amount / (1 - noLimitProb))
: yesLimitProb !== undefined
? amount / yesLimitProb
: noLimitProb !== undefined
? amount / (1 - noLimitProb)
: 0
const yesAmount = shares * (yesLimitProb ?? 1)
const noAmount = shares * (1 - (noLimitProb ?? 0))
const profitIfBothFilled = shares - (yesAmount + noAmount)
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
}
async function submitBet() {
if (!user || betDisabled) return
setError(undefined)
setIsSubmitting(true)
const betsPromise = hasTwoBets
? Promise.all([
placeBet({
outcome: 'YES',
amount: yesAmount,
limitProb: yesLimitProb,
contractId: contract.id,
}),
placeBet({
outcome: 'NO',
amount: noAmount,
limitProb: noLimitProb,
contractId: contract.id,
}),
])
: placeBet({
outcome: hasYesLimitBet ? 'YES' : 'NO',
amount: betAmount,
contractId: contract.id,
limitProb: hasYesLimitBet ? yesLimitProb : noLimitProb,
})
betsPromise
.catch((e) => {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error placing bet')
}
setIsSubmitting(false)
})
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
if (onBuySuccess) onBuySuccess()
})
if (hasYesLimitBet) {
track('bet', {
location: 'bet panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: yesAmount,
outcome: 'YES',
limitProb: yesLimitProb,
isLimitOrder: true,
isRangeOrder: hasTwoBets,
})
}
if (hasNoLimitBet) {
track('bet', {
location: 'bet panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: noAmount,
outcome: 'NO',
limitProb: noLimitProb,
isLimitOrder: true,
isRangeOrder: hasTwoBets,
})
}
}
const {
currentPayout: yesPayout,
currentReturn: yesReturn,
totalFees: yesFees,
newBet: yesBet,
} = getBinaryBetStats(
'YES',
yesAmount,
contract,
yesLimitProb ?? initialProb,
unfilledBets as LimitBet[]
)
const yesReturnPercent = formatPercent(yesReturn)
const {
currentPayout: noPayout,
currentReturn: noReturn,
totalFees: noFees,
newBet: noBet,
} = getBinaryBetStats(
'NO',
noAmount,
contract,
noLimitProb ?? initialProb,
unfilledBets as LimitBet[]
)
const noReturnPercent = formatPercent(noReturn)
return (
<Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4">
<Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at
</div>
<ProbabilityOrNumericInput
contract={contract}
prob={lowLimitProb}
setProb={setLowLimitProb}
isSubmitting={isSubmitting}
/>
</Col>
<Col className="gap-2">
<div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at
</div>
<ProbabilityOrNumericInput
contract={contract}
prob={highLimitProb}
setProb={setHighLimitProb}
isSubmitting={isSubmitting}
/>
</Col>
</Row>
{outOfRangeError && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
Limit is out of range
</div>
)}
{rangeError && !outOfRangeError && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{isPseudoNumeric ? 'HIGHER' : 'YES'} limit must be less than{' '}
{isPseudoNumeric ? 'LOWER' : 'NO'} limit
</div>
)}
<div className="mt-1 mb-3 text-left text-sm text-gray-500">
Max amount<span className="ml-1 text-red-500">*</span>
</div>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
onChange={onBetChange}
error={error}
setError={setError}
disabled={isSubmitting}
/>
<Col className="mt-3 w-full gap-3">
{(hasTwoBets || (hasYesLimitBet && yesBet.amount !== 0)) && (
<Row className="items-center justify-between gap-2 text-sm">
<div className="whitespace-nowrap text-gray-500">
{isPseudoNumeric ? (
<HigherLabel />
) : (
<BinaryOutcomeLabel outcome={'YES'} />
)}{' '}
filled now
</div>
<div className="mr-2 whitespace-nowrap">
{formatMoney(yesBet.amount)} of{' '}
{formatMoney(yesBet.orderAmount ?? 0)}
</div>
</Row>
)}
{(hasTwoBets || (hasNoLimitBet && noBet.amount !== 0)) && (
<Row className="items-center justify-between gap-2 text-sm">
<div className="whitespace-nowrap text-gray-500">
{isPseudoNumeric ? (
<LowerLabel />
) : (
<BinaryOutcomeLabel outcome={'NO'} />
)}{' '}
filled now
</div>
<div className="mr-2 whitespace-nowrap">
{formatMoney(noBet.amount)} of{' '}
{formatMoney(noBet.orderAmount ?? 0)}
</div>
</Row>
)}
{hasTwoBets && (
<Row className="items-center justify-between gap-2 text-sm">
<div className="whitespace-nowrap text-gray-500">
Profit if both orders filled
</div>
<div className="mr-2 whitespace-nowrap">
{formatMoney(profitIfBothFilled)}
</div>
</Row>
)}
{hasYesLimitBet && !hasTwoBets && (
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
{isPseudoNumeric ? (
'Max payout'
) : (
<>
Max <BinaryOutcomeLabel outcome={'YES'} /> payout
</>
)}
</div>
<InfoTooltip
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
/>
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
{formatMoney(yesPayout)}
</span>
(+{yesReturnPercent})
</div>
</Row>
)}
{hasNoLimitBet && !hasTwoBets && (
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
{isPseudoNumeric ? (
'Max payout'
) : (
<>
Max <BinaryOutcomeLabel outcome={'NO'} /> payout
</>
)}
</div>
<InfoTooltip
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
/>
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
{formatMoney(noPayout)}
</span>
(+{noReturnPercent})
</div>
</Row>
)}
</Col>
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
{user && (
<button
className={clsx(
@ -387,48 +705,47 @@ function BuyPanel(props: {
>
{isSubmitting
? 'Submitting...'
: isLimitOrder
? 'Submit order'
: 'Submit bet'}
: `Submit order${hasTwoBets ? 's' : ''}`}
</button>
)}
{wasSubmitted && (
<div className="mt-4">{isLimitOrder ? 'Order' : 'Bet'} submitted!</div>
)}
</>
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
</Col>
)
}
function QuickOrLimitBet(props: {
isLimitOrder: boolean
setIsLimitOrder: (isLimitOrder: boolean) => void
hideToggle?: boolean
}) {
const { isLimitOrder, setIsLimitOrder } = props
const { isLimitOrder, setIsLimitOrder, hideToggle } = props
return (
<Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div>
<Row className="mt-1 items-center gap-2">
<PillButton
selected={!isLimitOrder}
onSelect={() => {
setIsLimitOrder(false)
track('select quick order')
}}
>
Quick
</PillButton>
<PillButton
selected={isLimitOrder}
onSelect={() => {
setIsLimitOrder(true)
track('select limit order')
}}
>
Limit
</PillButton>
</Row>
{!hideToggle && (
<Row className="mt-1 items-center gap-2">
<PillButton
selected={!isLimitOrder}
onSelect={() => {
setIsLimitOrder(false)
track('select quick order')
}}
>
Quick
</PillButton>
<PillButton
selected={isLimitOrder}
onSelect={() => {
setIsLimitOrder(true)
track('select limit order')
}}
>
Limit
</PillButton>
</Row>
)}
</Row>
)
}
@ -454,7 +771,9 @@ export function SellPanel(props: {
const betDisabled = isSubmitting || !amount || error
// Sell all shares if remaining shares would be < 1
const sellQuantity = amount === Math.floor(shares) ? shares : amount
const isSellingAllShares = amount === Math.floor(shares)
const sellQuantity = isSellingAllShares ? shares : amount
async function submitSell() {
if (!user || !amount) return
@ -463,7 +782,7 @@ export function SellPanel(props: {
setIsSubmitting(true)
await sellShares({
shares: sellQuantity,
shares: isSellingAllShares ? undefined : amount,
outcome: sharesOutcome,
contractId: contract.id,
})

View File

@ -3,6 +3,7 @@ import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users'
@ -50,7 +51,7 @@ import { LimitOrderTable } from './limit-bets'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
const CONTRACTS_PER_PAGE = 20
const CONTRACTS_PER_PAGE = 50
export function BetsList(props: {
user: User
@ -78,10 +79,10 @@ export function BetsList(props: {
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (bets && contractsById) {
trackLatency('portfolio', getTime())
if (bets && contractsById && signedInUser) {
trackLatency(signedInUser.id, 'portfolio', getTime())
}
}, [bets, contractsById, getTime])
}, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) {
return <LoadingIndicator />
@ -156,9 +157,7 @@ export function BetsList(props: {
(c) => contractsMetrics[c.id].netPayout
)
const totalPortfolio = currentNetInvestment + user.balance
const totalPnl = totalPortfolio - user.totalDeposits
const totalPnl = user.profitCached.allTime
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
@ -277,13 +276,7 @@ function ContractBets(props: {
bets
)
return (
<div
tabIndex={0}
className={clsx(
'collapse collapse-arrow relative bg-white p-4 pr-6',
collapsed ? 'collapse-close' : 'collapse-open pb-2'
)}
>
<div tabIndex={0} className="relative bg-white p-4 pr-6">
<Row
className="cursor-pointer flex-wrap gap-2"
onClick={() => setCollapsed((collapsed) => !collapsed)}
@ -300,10 +293,11 @@ function ContractBets(props: {
</Link>
{/* Show carrot for collapsing. Hack the positioning. */}
<div
className="collapse-title absolute h-0 min-h-0 w-0 p-0"
style={{ top: -10, right: 0 }}
/>
{collapsed ? (
<ChevronDownIcon className="absolute top-5 right-4 h-6 w-6" />
) : (
<ChevronUpIcon className="absolute top-5 right-4 h-6 w-6" />
)}
</Row>
<Row className="flex-1 items-center gap-2 text-sm text-gray-500">
@ -335,55 +329,42 @@ function ContractBets(props: {
</Row>
</Col>
<Row className="mr-5 justify-end sm:mr-8">
<Col>
<div className="whitespace-nowrap text-right text-lg">
{formatMoney(metric === 'profit' ? profit : payout)}
</div>
<div className="text-right">
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
</Row>
<Col className="mr-5 sm:mr-8">
<div className="whitespace-nowrap text-right text-lg">
{formatMoney(metric === 'profit' ? profit : payout)}
</div>
<ProfitBadge className="text-right" profitPercent={profitPercent} />
</Col>
</Row>
<div
className="collapse-content !px-0"
style={{ backgroundColor: 'white' }}
>
<Spacer h={8} />
{!collapsed && (
<div className="bg-white">
<BetsSummary
className="mt-8 mr-5 flex-1 sm:mr-8"
contract={contract}
bets={bets}
isYourBets={isYourBets}
/>
<BetsSummary
className="mr-5 flex-1 sm:mr-8"
contract={contract}
bets={bets}
isYourBets={isYourBets}
/>
<Spacer h={4} />
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
<>
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
<div className="max-w-md">
<div className="bg-gray-50 px-4 py-2">Limit orders</div>
<div className="mt-4 bg-gray-50 px-4 py-2">Limit orders</div>
<LimitOrderTable
contract={contract}
limitBets={limitBets}
isYou={true}
isYou={isYourBets}
/>
</div>
</>
)}
)}
<Spacer h={4} />
<div className="bg-gray-50 px-4 py-2">Bets</div>
<ContractBetsTable
contract={contract}
bets={bets}
isYourBets={isYourBets}
/>
</div>
<div className="mt-4 bg-gray-50 px-4 py-2">Bets</div>
<ContractBetsTable
contract={contract}
bets={bets}
isYourBets={isYourBets}
/>
</div>
)}
</div>
)
}
@ -427,107 +408,92 @@ export function BetsSummary(props: {
return (
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
<Row className="flex-wrap gap-4 sm:gap-6">
{!isCpmm && (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Invested
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
)}
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(payout)}{' '}
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
) : (
<>
{isBinary ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <YesLabel />
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <NoLabel />
</div>
<div className="whitespace-nowrap">
{formatMoney(noWinnings)}
</div>
</Col>
</>
) : isPseudoNumeric ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'>='} {formatLargeNumber(contract.max)}
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'<='} {formatLargeNumber(contract.min)}
</div>
<div className="whitespace-nowrap">
{formatMoney(noWinnings)}
</div>
</Col>
</>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Current value
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>
)}
</>
)}
{!isCpmm && (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
<div className="whitespace-nowrap text-sm text-gray-500">
Invested
</div>
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
</Col>
)}
{resolution ? (
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
{isYourBets &&
isCpmm &&
(isBinary || isPseudoNumeric) &&
!isClosed &&
!resolution &&
hasShares &&
sharesOutcome &&
user && (
<>
<button
className="btn btn-sm ml-2"
onClick={() => setShowSellModal(true)}
>
Sell
</button>
{showSellModal && (
<SellSharesModal
contract={contract}
user={user}
userBets={bets}
shares={totalShares[sharesOutcome]}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</>
)}
{formatMoney(payout)} <ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
</Row>
) : isBinary ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <YesLabel />
</div>
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if <NoLabel />
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : isPseudoNumeric ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'>='} {formatLargeNumber(contract.max)}
</div>
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'<='} {formatLargeNumber(contract.min)}
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Current value
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>
)}
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
<div className="whitespace-nowrap">
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
{isYourBets &&
isCpmm &&
(isBinary || isPseudoNumeric) &&
!isClosed &&
!resolution &&
hasShares &&
sharesOutcome &&
user && (
<>
<button
className="btn btn-sm ml-2"
onClick={() => setShowSellModal(true)}
>
Sell
</button>
{showSellModal && (
<SellSharesModal
contract={contract}
user={user}
userBets={bets}
shares={totalShares[sharesOutcome]}
sharesOutcome={sharesOutcome}
setOpen={setShowSellModal}
/>
)}
</>
)}
</div>
</Col>
</Row>
)
}
@ -689,7 +655,13 @@ function BetRow(props: {
!isClosed &&
!isSold &&
!isAnte &&
!isNumeric && <SellButton contract={contract} bet={bet} />}
!isNumeric && (
<SellButton
contract={contract}
bet={bet}
unfilledBets={unfilledBets}
/>
)}
</td>
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
<td>
@ -729,8 +701,12 @@ function BetRow(props: {
)
}
function SellButton(props: { contract: Contract; bet: Bet }) {
const { contract, bet } = props
function SellButton(props: {
contract: Contract
bet: Bet
unfilledBets: LimitBet[]
}) {
const { contract, bet, unfilledBets } = props
const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false)
@ -740,8 +716,6 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
outcome === 'NO' ? 'YES' : outcome
)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const outcomeProb = getProbabilityAfterSale(
contract,
outcome,
@ -787,8 +761,8 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
)
}
function ProfitBadge(props: { profitPercent: number }) {
const { profitPercent } = props
function ProfitBadge(props: { profitPercent: number; className?: string }) {
const { profitPercent, className } = props
if (!profitPercent) return null
const colors =
profitPercent > 0
@ -799,7 +773,8 @@ function ProfitBadge(props: { profitPercent: number }) {
<span
className={clsx(
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
colors
colors,
className
)}
>
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}

View File

@ -9,8 +9,9 @@ export function BucketInput(props: {
contract: NumericContract | PseudoNumericContract
isSubmitting?: boolean
onBucketChange: (value?: number, bucket?: string) => void
placeholder?: string
}) {
const { contract, isSubmitting, onBucketChange } = props
const { contract, isSubmitting, onBucketChange, placeholder } = props
const [numberString, setNumberString] = useState('')
@ -39,7 +40,7 @@ export function BucketInput(props: {
error={undefined}
disabled={isSubmitting}
numberString={numberString}
label="Value"
placeholder={placeholder}
/>
)
}

View File

@ -6,7 +6,7 @@ export function Button(props: {
onClick?: () => void
children?: ReactNode
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray'
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
}) {
@ -39,7 +39,10 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
className
)}
disabled={disabled}

View File

@ -13,10 +13,10 @@ export function PillButton(props: {
return (
<button
className={clsx(
'cursor-pointer select-none rounded-full',
'cursor-pointer select-none whitespace-nowrap rounded-full',
selected
? ['text-white', color ?? 'bg-gray-700']
: 'bg-gray-100 hover:bg-gray-200',
? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3',
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
)}
onClick={onSelect}

View File

@ -6,10 +6,9 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row'
import { Col } from '../layout/col'
export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity, match } = props
const { charity } = props
const { slug, photo, preview, id, tags } = charity
const txns = useCharityTxns(id)
@ -36,18 +35,18 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Col>
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
<span>raised</span>
</Col>
{match && (
raised
</Row>
{/* {match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)}
)} */}
</Row>
</>
)}

View File

@ -17,58 +17,55 @@ export function UserCommentsList(props: {
contractsById: { [id: string]: Contract }
}) {
const { comments, contractsById } = props
const commentsByContract = groupBy(comments, 'contractId')
const contractCommentPairs = Object.entries(commentsByContract)
.map(
([contractId, comments]) => [contractsById[contractId], comments] as const
)
.filter(([contract]) => contract)
// we don't show comments in groups here atm, just comments on contracts
const contractComments = comments.filter((c) => c.contractId)
const commentsByContract = groupBy(contractComments, 'contractId')
return (
<Col className={'bg-white'}>
{contractCommentPairs.map(([contract, comments]) => (
<div key={contract.id} className={'border-width-1 border-b p-5'}>
<div className={'mb-2 text-sm text-indigo-700'}>
<SiteLink href={contractPath(contract)}>
{Object.entries(commentsByContract).map(([contractId, comments]) => {
const contract = contractsById[contractId]
return (
<div key={contractId} className={'border-width-1 border-b p-5'}>
<SiteLink
className={'mb-2 block text-sm text-indigo-700'}
href={contractPath(contract)}
>
{contract.question}
</SiteLink>
{comments.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3 pb-6"
/>
))}
</div>
{comments.map((comment) => (
<div key={comment.id} className={'relative pb-6'}>
<div className="relative flex items-start space-x-3">
<ProfileComment comment={comment} />
</div>
</div>
))}
</div>
))}
)
})}
</Col>
)
}
function ProfileComment(props: { comment: Comment }) {
const { comment } = props
function ProfileComment(props: { comment: Comment; className?: string }) {
const { comment, className } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
// TODO: find and attach relevant bets by comment betId at some point
return (
<div>
<Row className={'gap-4'}>
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
<div className="min-w-0 flex-1">
<div>
<p className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
<RelativeTimestamp time={createdTime} />
</p>
</div>
<Linkify text={text} />
</div>
</Row>
</div>
<Row className={className}>
<Avatar 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}
/>{' '}
<RelativeTimestamp time={createdTime} />
</p>
<Linkify text={text} />
</div>
</Row>
)
}

View File

@ -1,33 +1,26 @@
/* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite'
import {
Configure,
InstantSearch,
SearchBox,
SortBy,
useInfiniteHits,
useSortBy,
} from 'react-instantsearch-hooks-web'
import { Contract } from 'common/contract'
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
import {
Sort,
useInitialQueryAndSort,
useUpdateQueryAndSort,
} from '../hooks/use-sort-and-query-params'
import { ContractsGrid } from './contract/contracts-list'
ContractHighlightOptions,
ContractsGrid,
} from './contract/contracts-list'
import { Row } from './layout/row'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
import { trackCallback } from 'web/lib/service/analytics'
import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group'
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button'
import { toPairs } from 'lodash'
import { range, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -35,26 +28,21 @@ const searchClient = algoliasearch(
)
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
const sortIndexes = [
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most popular', value: indexPrefix + 'contracts-most-popular' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
const sortOptions = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
]
export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
const filterOptions: { [label: string]: filter } = {
All: 'all',
Open: 'open',
Closed: 'closed',
Resolved: 'resolved',
'For you': 'personal',
}
export function ContractSearch(props: {
querySortOptions?: {
@ -68,11 +56,15 @@ export function ContractSearch(props: {
excludeContractIds?: string[]
groupSlug?: string
}
highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void
showPlaceHolder?: boolean
hideOrderSelector?: boolean
overrideGridClassName?: string
hideQuickBet?: boolean
cardHideOptions?: {
hideGroupLink?: boolean
hideQuickBet?: boolean
}
}) {
const {
querySortOptions,
@ -81,68 +73,178 @@ export function ContractSearch(props: {
overrideGridClassName,
hideOrderSelector,
showPlaceHolder,
hideQuickBet,
cardHideOptions,
highlightOptions,
} = props
const user = useUser()
const memberGroupSlugs = useMemberGroups(user?.id)
?.map((g) => g.slug)
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s))
const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions)
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
)
const memberGroupSlugs =
memberGroups.length > 0
? memberGroups.map((g) => g.slug)
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const sort = sortIndexes
.map(({ value }) => value)
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
? initialSort
: querySortOptions?.defaultSort ?? 'most-popular'
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0),
(group) => group.contractIds.length
).reverse()
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
const pillGroups =
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id)
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
defaultSort,
shouldLoadFromStorage,
})
const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open'
)
const pillsEnabled = !additionalFilter && !query
const { filters, numericFilters } = useMemo(() => {
let filters = [
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
filter === 'personal'
? // Show contracts in groups that the user is a member of
(memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? [])
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
// Show contracts bet on by the user
)
.concat(user ? `uniqueBettorIds:${user.id}` : [])
: '',
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
: '',
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
additionalFilter?.groupSlug
? `groupSlugs:${additionalFilter.groupSlug}`
: '',
].filter((f) => f)
// Hack to make Algolia work.
filters = ['', ...filters]
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const numericFilters = [
filter === 'open' ? `closeTime > ${Date.now()}` : '',
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f)
const selectPill = (pill: string | undefined) => () => {
setPillFilter(pill)
setPage(0)
track('select search category', { category: pill ?? 'all' })
}
return { filters, numericFilters }
}, [
filter,
Object.values(additionalFilter ?? {}).join(','),
(memberGroupSlugs ?? []).join(','),
(follows ?? []).join(','),
])
const additionalFilters = [
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
: '',
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
additionalFilter?.groupSlug
? `groupLinks.slug:${additionalFilter.groupSlug}`
: '',
]
const facetFilters = query
? additionalFilters
: [
...additionalFilters,
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
? `groupLinks.slug:${pillFilter}`
: '',
pillFilter === 'personal'
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
)
: '',
// Subtract contracts you bet on from For you.
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
pillFilter === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
].filter((f) => f)
const numericFilters = query
? []
: [
filter === 'open' ? `closeTime > ${Date.now()}` : '',
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f)
const indexName = `${indexPrefix}contracts-${sort}`
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
const searchIndex = useMemo(
() => searchClient.initIndex(searchIndexName),
[searchIndexName]
)
const [page, setPage] = useState(0)
const [numPages, setNumPages] = useState(1)
const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>(
{}
)
useEffect(() => {
let wasMostRecentQuery = true
const algoliaIndex = query ? searchIndex : index
algoliaIndex
.search(query, {
facetFilters,
numericFilters,
page,
hitsPerPage: 20,
})
.then((results) => {
if (!wasMostRecentQuery) return
if (page === 0) {
setHitsByPage({
[0]: results.hits as any as Contract[],
})
} else {
setHitsByPage((hitsByPage) => ({
...hitsByPage,
[page]: results.hits,
}))
}
setNumPages(results.nbPages)
})
return () => {
wasMostRecentQuery = false
}
// Note numeric filters are unique based on current time, so can't compare
// them by value.
}, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter])
const loadMore = () => {
if (page >= numPages - 1) return
const haveLoadedCurrentPage = hitsByPage[page]
if (haveLoadedCurrentPage) setPage(page + 1)
}
const hits = range(0, page + 1)
.map((p) => hitsByPage[p] ?? [])
.flat()
const contracts = hits.filter(
(c) => !additionalFilter?.excludeContractIds?.includes(c.id)
)
const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined
const updateQuery = (newQuery: string) => {
setQuery(newQuery)
setPage(0)
}
const selectFilter = (newFilter: filter) => {
if (newFilter === filter) return
setFilter(newFilter)
setPage(0)
trackCallback('select search filter', { filter: newFilter })
}
const selectSort = (newSort: Sort) => {
if (newSort === sort) return
setPage(0)
setSort(newSort)
track('select sort', { sort: newSort })
}
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return (
@ -154,143 +256,103 @@ export function ContractSearch(props: {
}
return (
<InstantSearch searchClient={searchClient} indexName={indexName}>
<Col>
<Row className="gap-1 sm:gap-2">
<SearchBox
className="flex-1"
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
classNames={{
form: 'before:top-6',
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
resetIcon: 'mt-2 hidden sm:flex',
}}
<input
type="text"
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
className="input input-bordered w-full"
/>
{/*// TODO track WHICH filter users are using*/}
{!hideOrderSelector && (
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort')}
/>
{!query && (
<select
className="select select-bordered"
value={filter}
onChange={(e) => selectFilter(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>
)}
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
<Configure
facetFilters={filters}
numericFilters={numericFilters}
// Page resets on filters change.
page={0}
/>
</Row>
<Spacer h={3} />
<Row className="gap-2">
{toPairs<filter>(filterOptions).map(([label, f]) => {
return (
<PillButton selected={filter === f} onSelect={() => setFilter(f)}>
{label}
{pillsEnabled && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectPill(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
{user && (
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your bets
</PillButton>
)
})}
</Row>
)}
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectPill(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)}
<Spacer h={3} />
{filter === 'personal' &&
(follows ?? []).length === 0 &&
(memberGroupSlugs ?? []).length === 0 ? (
memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</>
) : (
<ContractSearchInner
querySortOptions={querySortOptions}
<ContractsGrid
contracts={contracts}
loadMore={loadMore}
hasMore={true}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
excludeContractIds={additionalFilter?.excludeContractIds}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
/>
)}
</InstantSearch>
)
}
export function ContractSearchInner(props: {
querySortOptions?: {
defaultSort: Sort
shouldLoadFromStorage?: boolean
}
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
hideQuickBet?: boolean
excludeContractIds?: string[]
}) {
const {
querySortOptions,
onContractClick,
overrideGridClassName,
hideQuickBet,
excludeContractIds,
} = props
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
const { query, setQuery, setSort } = useUpdateQueryAndSort({
shouldLoadFromStorage: true,
})
useEffect(() => {
setQuery(initialQuery)
}, [initialQuery])
const { currentRefinement: index } = useSortBy({
items: [],
})
useEffect(() => {
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 [isInitialLoad, setIsInitialLoad] = useState(true)
useEffect(() => {
const id = setTimeout(() => setIsInitialLoad(false), 1000)
return () => clearTimeout(id)
}, [])
const { showMore, hits, isLastPage } = useInfiniteHits()
let contracts = hits as any as Contract[]
if (isInitialLoad && contracts.length === 0) return <></>
const showTime = index.endsWith('close-date')
? 'close-date'
: index.endsWith('resolve-date')
? 'resolve-date'
: undefined
if (excludeContractIds)
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
return (
<ContractsGrid
contracts={contracts}
loadMore={showMore}
hasMore={!isLastPage}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
/>
</Col>
)
}

View File

@ -5,9 +5,10 @@ import { formatLargeNumber, formatPercent } from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col'
import {
Contract,
BinaryContract,
Contract,
FreeResponseContract,
MultipleChoiceContract,
NumericContract,
PseudoNumericContract,
} from 'common/contract'
@ -24,12 +25,12 @@ import {
} from 'common/calculate'
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { QuickBet, ProbBar, getColor } from './quick-bet'
import { getColor, ProbBar, QuickBet } from './quick-bet'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { getMappedValue } from 'common/pseudo-numeric'
export function ContractCard(props: {
contract: Contract
@ -38,8 +39,16 @@ export function ContractCard(props: {
className?: string
onClick?: () => void
hideQuickBet?: boolean
hideGroupLink?: boolean
}) {
const { showHotVolume, showTime, className, onClick, hideQuickBet } = props
const {
showHotVolume,
showTime,
className,
onClick,
hideQuickBet,
hideGroupLink,
} = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract
const { resolution } = contract
@ -106,7 +115,8 @@ export function ContractCard(props: {
{question}
</p>
{outcomeType === 'FREE_RESPONSE' &&
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
(resolution ? (
<FreeResponseOutcomeLabel
contract={contract}
@ -121,6 +131,7 @@ export function ContractCard(props: {
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>
</Col>
{showQuickBet ? (
@ -148,7 +159,8 @@ export function ContractCard(props: {
/>
)}
{outcomeType === 'FREE_RESPONSE' && (
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract}
@ -200,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
}
function FreeResponseTopAnswer(props: {
contract: FreeResponseContract
contract: FreeResponseContract | MultipleChoiceContract
truncate: 'short' | 'long' | 'none'
className?: string
}) {
@ -218,7 +230,7 @@ function FreeResponseTopAnswer(props: {
}
export function FreeResponseResolutionOrChance(props: {
contract: FreeResponseContract
contract: FreeResponseContract | MultipleChoiceContract
truncate: 'short' | 'long' | 'none'
className?: string
}) {
@ -305,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: {
const { resolution, resolutionValue, resolutionProbability } = contract
const textColor = `text-blue-400`
const value = resolution
? resolutionValue
? resolutionValue
: getMappedValue(contract)(resolutionProbability ?? 0)
: getMappedValue(contract)(getProbability(contract))
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
@ -314,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: {
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div className="text-blue-400">
{resolutionValue
? formatLargeNumber(resolutionValue)
: formatNumericProbability(
resolutionProbability ?? 0,
contract
)}
<div
className={clsx('tooltip', textColor)}
data-tip={value.toFixed(2)}
>
{formatLargeNumber(value)}
</div>
)}
</>
) : (
<>
<div className={clsx('text-3xl', textColor)}>
{formatNumericProbability(getProbability(contract), contract)}
<div
className={clsx('tooltip text-3xl', textColor)}
data-tip={value.toFixed(2)}
>
{formatLargeNumber(value)}
</div>
<div className={clsx('text-base', textColor)}>expected</div>
</>

View File

@ -24,13 +24,10 @@ export function ContractDescription(props: {
return (
<div className={clsx('mt-2 text-gray-700', className)}>
{isCreator || isAdmin ? (
<RichEditContract contract={contract} />
<RichEditContract contract={contract} isAdmin={isAdmin && !isCreator} />
) : (
<Content content={contract.description} />
)}
{isAdmin && !isCreator && (
<div className="mt-2 text-red-400">(👆 admin powers)</div>
)}
</div>
)
}
@ -39,8 +36,8 @@ function editTimestamp() {
return `${dayjs().format('MMM D, h:mma')}: `
}
function RichEditContract(props: { contract: Contract }) {
const { contract } = props
function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
const { contract, isAdmin } = props
const [editing, setEditing] = useState(false)
const [editingQ, setEditingQ] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
@ -90,9 +87,11 @@ function RichEditContract(props: { contract: Contract }) {
<>
<Content content={contract.description} />
<Spacer h={2} />
<Row className="gap-2">
<Row className="items-center gap-2">
{isAdmin && 'Admin: '}
<Button
color="gray"
size="xs"
onClick={() => {
setEditing(true)
editor
@ -105,7 +104,7 @@ function RichEditContract(props: { contract: Contract }) {
>
Edit description
</Button>
<Button color="gray" onClick={() => setEditingQ(true)}>
<Button color="gray" size="xs" onClick={() => setEditingQ(true)}>
Edit question
</Button>
</Row>

View File

@ -11,7 +11,7 @@ import { UserLink } from '../user-page'
import {
Contract,
contractMetrics,
contractPool,
contractPath,
updateContract,
} from 'web/lib/firebase/contracts'
import dayjs from 'dayjs'
@ -22,17 +22,19 @@ 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'
import { UserFollowButton } from '../follow-button'
import { groupPath } from 'web/lib/firebase/groups'
import { SiteLink } from 'web/components/site-link'
import { DAY_MS } from 'common/util/time'
import { useGroupsWithContract } from 'web/hooks/use-group'
import { ShareIconButton } from 'web/components/share-icon-button'
import { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse'
import { ENV_CONFIG } from 'common/envs/constants'
import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
import { SiteLink } from 'web/components/site-link'
import { groupPath } from 'web/lib/firebase/groups'
export type ShowTime = 'resolve-date' | 'close-date'
@ -40,21 +42,19 @@ export function MiscDetails(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
hideGroupLink?: boolean
}) {
const { contract, showHotVolume, showTime } = props
const { contract, showHotVolume, showTime, hideGroupLink } = props
const {
volume,
volume24Hours,
closeTime,
tags,
isResolved,
createdTime,
resolutionTime,
groupLinks,
} = 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)
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
return (
@ -76,13 +76,21 @@ export function MiscDetails(props: {
{fromNow(resolutionTime || 0)}
</Row>
) : volume > 0 || !isNew ? (
<Row>{contractPool(contract)} pool</Row>
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
) : (
<NewContractBadge />
)}
{categories.length > 0 && (
<TagsList className="text-gray-400" tags={categories} noLabel />
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
<SiteLink
href={groupPath(groupLinks[0].slug)}
className="text-sm text-gray-400"
>
<Row className={'line-clamp-1 flex-wrap items-center '}>
<UserGroupIcon className="mx-1 mb-0.5 inline h-4 w-4 shrink-0" />
{groupLinks[0].name}
</Row>
</SiteLink>
)}
</Row>
)
@ -130,34 +138,15 @@ export function ContractDetails(props: {
disabled?: boolean
}) {
const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId } = contract
const { closeTime, creatorName, creatorUsername, creatorId, groupLinks } =
contract
const { volumeLabel, resolvedDate } = contractMetrics(contract)
const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => {
return g2.createdTime - g1.createdTime
})
const user = useUser()
const groupsUserIsMemberOf = groups
? groups.filter((g) => g.memberIds.includes(contract.creatorId))
: []
const groupsUserIsCreatorOf = groups
? groups.filter((g) => g.creatorId === contract.creatorId)
: []
// Priorities for which group the contract belongs to:
// In order of created most recently
// Group that the contract owner created
// Group the contract owner is a member of
// Any group the contract is in
const groupToDisplay =
groupsUserIsCreatorOf.length > 0
? groupsUserIsCreatorOf[0]
: groupsUserIsMemberOf.length > 0
? groupsUserIsMemberOf[0]
: groups
? groups[0]
: undefined
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
const user = useUser()
const [open, setOpen] = useState(false)
return (
<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">
@ -178,16 +167,34 @@ export function ContractDetails(props: {
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row>
{groupToDisplay ? (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
<SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groupToDisplay.name}</span>
</SiteLink>
</Row>
) : (
<div />
)}
<Row>
<Button
size={'xs'}
className={'max-w-[200px]'}
color={'gray-white'}
onClick={() => setOpen(!open)}
>
<Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className={'line-clamp-1'}>
{groupToDisplay ? groupToDisplay.name : 'No group'}
</span>
</Row>
</Button>
</Row>
<Modal open={open} setOpen={setOpen} size={'md'}>
<Col
className={
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
}
>
<ContractGroupsList
groupLinks={groupLinks ?? []}
contract={contract}
user={user}
/>
</Col>
</Modal>
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
@ -222,9 +229,12 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
<ShareIconButton
contract={contract}
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
user?.username && contract.creatorUsername !== user?.username
? '?referrer=' + user?.username
: ''
}`}
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
username={user?.username}
/>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
@ -321,12 +331,13 @@ function EditableCloseDate(props: {
Done
</button>
) : (
<button
className="btn btn-xs btn-ghost"
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setIsEditingCloseTime(true)}
>
<PencilIcon className="mr-2 inline h-4 w-4" /> Edit
</button>
<PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit
</Button>
))}
</>
)

View File

@ -16,11 +16,10 @@ import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
import { DuplicateContractButton } from '../copy-contract-button'
export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
@ -42,6 +41,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
? 'YES / NO'
: outcomeType === 'FREE_RESPONSE'
? 'Free response'
: outcomeType === 'MULTIPLE_CHOICE'
? 'Multiple choice'
: 'Numeric'
return (
@ -141,9 +142,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</tbody>
</table>
<div>Tags</div>
<TagsInput contract={contract} />
<div />
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} />
)}

View File

@ -0,0 +1,141 @@
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useUserById } from 'web/hooks/use-user'
import { listUsers, User } from 'web/lib/firebase/users'
import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments'
import { Spacer } from '../layout/spacer'
import { Leaderboard } from '../leaderboard'
import { Title } from '../title'
export function ContractLeaderboard(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [userProfits, top5Ids])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top traders"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
export function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
truncate={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}

View File

@ -85,7 +85,8 @@ export const ContractOverview = (props: {
{tradingAllowed(contract) && <BetRow contract={contract} />}
</Row>
) : (
outcomeType === 'FREE_RESPONSE' &&
(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
resolution && (
<FreeResponseResolutionOrChance
contract={contract}
@ -110,7 +111,8 @@ export const ContractOverview = (props: {
{(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} />
)}{' '}
{outcomeType === 'FREE_RESPONSE' && (
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<AnswersGraph contract={contract} bets={bets} />
)}
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}

View File

@ -7,7 +7,6 @@ import { Bet } from 'common/bet'
import { getInitialProbability } from 'common/calculate'
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { useWindowSize } from 'web/hooks/use-window-size'
import { getMappedValue } from 'common/pseudo-numeric'
import { formatLargeNumber } from 'common/util/format'
export const ContractProbGraph = memo(function ContractProbGraph(props: {
@ -29,7 +28,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
...bets.map((bet) => bet.createdTime),
].map((time) => new Date(time))
const f = getMappedValue(contract)
const f: (p: number) => number = isBinary
? (p) => p
: isLogScale
? (p) => p * Math.log10(contract.max - contract.min + 1)
: (p) => p * (contract.max - contract.min) + contract.min
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
@ -69,10 +72,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const points: { x: Date; y: number }[] = []
const s = isBinary ? 100 : 1
const c = isLogScale && contract.min === 0 ? 1 : 0
for (let i = 0; i < times.length - 1; i++) {
points[points.length] = { x: times[i], y: s * probs[i] + c }
points[points.length] = { x: times[i], y: s * probs[i] }
const numPoints: number = Math.floor(
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
)
@ -84,7 +86,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
x: dayjs(times[i])
.add(thisTimeStep * n, 'ms')
.toDate(),
y: s * probs[i] + c,
y: s * probs[i],
}
}
}
@ -99,6 +101,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const formatter = isBinary
? formatPercent
: isLogScale
? (x: DatumValue) =>
formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
return (
@ -111,11 +116,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
yScale={
isBinary
? { min: 0, max: 100, type: 'linear' }
: {
min: contract.min + c,
max: contract.max + c,
type: contract.isLogScale ? 'log' : 'linear',
: isLogScale
? {
min: 0,
max: Math.log10(contract.max - contract.min + 1),
type: 'linear',
}
: { min: contract.min, max: contract.max, type: 'linear' }
}
yFormat={formatter}
gridYValues={yTickValues}
@ -143,6 +150,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
animate={false}
sliceTooltip={SliceTooltip}

View File

@ -9,6 +9,7 @@ import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
import { useComments } from 'web/hooks/use-comments'
export function ContractTabs(props: {
contract: Contract
@ -18,10 +19,17 @@ export function ContractTabs(props: {
comments: Comment[]
tips: CommentTipMap
}) {
const { contract, user, bets, comments, tips, liquidityProvisions } = props
const { contract, user, bets, tips, liquidityProvisions } = props
const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
// Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const betActivity = (
<ContractActivity
@ -89,8 +97,12 @@ export function ContractTabs(props: {
<Tabs
currentPageForAnalytics={'contract'}
tabs={[
{ title: 'Comments', content: commentActivity },
{ title: 'Bets', content: betActivity },
{
title: 'Comments',
content: commentActivity,
badge: `${comments.length}`,
},
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
...(!user || !userBets?.length
? []
: [{ title: 'Your bets', content: yourTrades }]),

View File

@ -1,5 +1,5 @@
import { Contract } from '../../lib/firebase/contracts'
import { User } from '../../lib/firebase/users'
import { Contract } from 'web/lib/firebase/contracts'
import { User } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card'
@ -9,6 +9,11 @@ import { useIsVisible } from 'web/hooks/use-is-visible'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
export type ContractHighlightOptions = {
contractIds?: string[]
highlightClassName?: string
}
export function ContractsGrid(props: {
contracts: Contract[]
loadMore: () => void
@ -16,7 +21,11 @@ export function ContractsGrid(props: {
showTime?: ShowTime
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
hideQuickBet?: boolean
cardHideOptions?: {
hideQuickBet?: boolean
hideGroupLink?: boolean
}
highlightOptions?: ContractHighlightOptions
}) {
const {
contracts,
@ -25,9 +34,12 @@ export function ContractsGrid(props: {
loadMore,
onContractClick,
overrideGridClassName,
hideQuickBet,
cardHideOptions,
highlightOptions,
} = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
const [elem, setElem] = useState<HTMLElement | null>(null)
const isBottomVisible = useIsVisible(elem)
@ -66,6 +78,12 @@ export function ContractsGrid(props: {
onContractClick ? () => onContractClick(contract) : undefined
}
hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink}
className={
contractIds?.includes(contract.id)
? highlightClassName
: undefined
}
/>
))}
</ul>

View File

@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
params.initValue = getMappedValue(contract)(contract.initialProbability)
}
if (contract.groupLinks && contract.groupLinks.length > 0) {
params.groupId = contract.groupLinks[0].groupId
}
return (
`/create?` +
Object.entries(params)

View File

@ -3,58 +3,63 @@ import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
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'
import { track } from 'web/lib/service/analytics'
function copyContractUrl(contract: Contract) {
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
}
import { Row } from './layout/row'
export function CopyLinkButton(props: {
contract: Contract
url: string
displayUrl?: string
tracking?: string
buttonClassName?: string
toastClassName?: string
}) {
const { contract, buttonClassName, toastClassName } = props
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
return (
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
copyContractUrl(contract)
track('copy share link')
}}
>
<Menu.Button
className={clsx(
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
buttonClassName
)}
>
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
Copy link
</Menu.Button>
<Row className="w-full">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={displayUrl ?? url}
/>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
copyToClipboard(url)
track(tracking ?? 'copy share link')
}}
>
<Menu.Items>
<Menu.Item>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
<Menu.Button
className={clsx(
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
buttonClassName
)}
>
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
Copy link
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items>
<Menu.Item>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</Row>
)
}

View File

@ -1,10 +1,12 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { firebaseLogin, User } from 'web/lib/firebase/users'
import React from 'react'
export const createButtonStyle =
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
export const CreateQuestionButton = (props: {
user: User | null | undefined
overrideText?: string
@ -15,17 +17,23 @@ export const CreateQuestionButton = (props: {
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
const { user, overrideText, className, query } = props
const router = useRouter()
return (
<div className={clsx('flex justify-center', className)}>
{user ? (
<Link href={`/create${query ? query : ''}`} passHref>
<button className={clsx(gradient, createButtonStyle)}>
{overrideText ? overrideText : 'Create a question'}
{overrideText ? overrideText : 'Create a market'}
</button>
</Link>
) : (
<button
onClick={firebaseLogin}
onClick={async () => {
// login, and then reload the page, to hit any SSR redirect (e.g.
// redirecting from / to /home for logged in users)
await firebaseLogin()
router.replace(router.asPath)
}}
className={clsx(gradient, createButtonStyle)}
>
Sign in

View File

@ -11,17 +11,29 @@ import {
import StarterKit from '@tiptap/starter-kit'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import clsx from 'clsx'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Linkify } from './linkify'
import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query'
import { exhibitExts } from 'common/util/parse'
import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link'
import { useUsers } from 'web/hooks/use-users'
import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe'
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
import { Modal } from './layout/modal'
import { Col } from './layout/col'
import { Button } from './button'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
const proseClass = clsx(
'prose prose-p:my-2 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless font-light'
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
'font-light prose-a:font-light prose-blockquote:font-light'
)
export function useTextEditor(props: {
@ -32,32 +44,41 @@ export function useTextEditor(props: {
}) {
const { placeholder, max, defaultValue = '', disabled } = props
const users = useUsers()
const editorClass = clsx(
proseClass,
'box-content min-h-[6em] textarea textarea-bordered'
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
)
const editor = useEditor({
editorProps: { attributes: { class: editorClass } },
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder,
emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
}),
CharacterCount.configure({ limit: max }),
Image,
Link.configure({
HTMLAttributes: {
class: clsx('no-underline !text-indigo-700', linkClass),
},
}),
],
content: defaultValue,
})
const editor = useEditor(
{
editorProps: { attributes: { class: editorClass } },
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder,
emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
}),
CharacterCount.configure({ limit: max }),
Image,
Link.configure({
HTMLAttributes: {
class: clsx('no-underline !text-indigo-700', linkClass),
},
}),
DisplayMention.configure({
suggestion: mentionSuggestion(users),
}),
Iframe,
],
content: defaultValue,
},
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
)
const upload = useUploadMutation(editor)
@ -68,12 +89,19 @@ export function useTextEditor(props: {
(file) => file.type.startsWith('image')
)
if (!imageFiles.length) {
return // if no files pasted, use default paste handler
if (imageFiles.length) {
event.preventDefault()
upload.mutate(imageFiles)
}
event.preventDefault()
upload.mutate(imageFiles)
// If the pasted content is iframe code, directly inject it
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
if (isValidIframe(text)) {
editor.chain().insertContent(text).run()
return true // Prevent the code from getting pasted as text
}
return // Otherwise, use default paste handler
},
},
})
@ -85,20 +113,25 @@ export function useTextEditor(props: {
return { editor, upload }
}
function isValidIframe(text: string) {
return /^<iframe.*<\/iframe>$/.test(text)
}
export function TextEditor(props: {
editor: Editor | null
upload: ReturnType<typeof useUploadMutation>
}) {
const { editor, upload } = props
const [iframeOpen, setIframeOpen] = useState(false)
return (
<>
{/* hide placeholder when focused */}
<div className="w-full [&:focus-within_p.is-empty]:before:content-none">
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
{editor && (
<FloatingMenu
editor={editor}
className="-ml-2 mr-2 w-full text-sm text-slate-300"
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
>
Type <em>*markdown*</em>. Paste or{' '}
<FileUploadButton
@ -110,7 +143,46 @@ export function TextEditor(props: {
images!
</FloatingMenu>
)}
<EditorContent editor={editor} />
<div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
<EditorContent editor={editor} />
{/* Spacer element to match the height of the toolbar */}
<div className="py-2" aria-hidden="true">
{/* Matches height of button in toolbar (1px border + 36px content height) */}
<div className="py-px">
<div className="h-9" />
</div>
</div>
</div>
{/* Toolbar, with buttons for image and embeds */}
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
<div className="flex items-center space-x-5">
<div className="flex items-center">
<FileUploadButton
onFiles={upload.mutate}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
>
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Upload an image</span>
</FileUploadButton>
</div>
<div className="flex items-center">
<button
type="button"
onClick={() => setIframeOpen(true)}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
>
<IframeModal
editor={editor}
open={iframeOpen}
setOpen={setIframeOpen}
/>
<CodeIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Embed an iframe</span>
</button>
</div>
</div>
</div>
</div>
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
{upload.isError && (
@ -120,6 +192,65 @@ export function TextEditor(props: {
)
}
function IframeModal(props: {
editor: Editor | null
open: boolean
setOpen: (open: boolean) => void
}) {
const { editor, open, setOpen } = props
const [embedCode, setEmbedCode] = useState('')
const valid = isValidIframe(embedCode)
return (
<Modal open={open} setOpen={setOpen}>
<Col className="gap-2 rounded bg-white p-6">
<label
htmlFor="embed"
className="block text-sm font-medium text-gray-700"
>
Embed a market, Youtube video, etc.
</label>
<input
type="text"
name="embed"
id="embed"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder='e.g. <iframe src="..."></iframe>'
value={embedCode}
onChange={(e) => setEmbedCode(e.target.value)}
/>
{/* Preview the embed if it's valid */}
{valid ? <RichContent content={embedCode} /> : <Spacer h={2} />}
<Row className="gap-2">
<Button
disabled={!valid}
onClick={() => {
if (editor && valid) {
editor.chain().insertContent(embedCode).run()
setEmbedCode('')
setOpen(false)
}
}}
>
Embed
</Button>
<Button
color="gray"
onClick={() => {
setEmbedCode('')
setOpen(false)
}}
>
Cancel
</Button>
</Row>
</Col>
</Modal>
)
}
const useUploadMutation = (editor: Editor | null) =>
useMutation(
(files: File[]) =>
@ -138,11 +269,15 @@ const useUploadMutation = (editor: Editor | null) =>
}
)
function RichContent(props: { content: JSONContent }) {
function RichContent(props: { content: JSONContent | string }) {
const { content } = props
const editor = useEditor({
editorProps: { attributes: { class: proseClass } },
extensions: exhibitExts,
extensions: [
// replace tiptap's Mention with ours, to add style and link
...exhibitExts.filter((ex) => ex.name !== Mention.name),
DisplayMention,
],
content,
editable: false,
})
@ -155,7 +290,7 @@ function RichContent(props: { content: JSONContent }) {
export function Content(props: { content: JSONContent | string }) {
const { content } = props
return typeof content === 'string' ? (
<div className="whitespace-pre-line break-words">
<div className="whitespace-pre-line font-light leading-relaxed">
<Linkify text={content} />
</div>
) : (

View File

@ -0,0 +1,62 @@
import { SuggestionProps } from '@tiptap/suggestion'
import clsx from 'clsx'
import { User } from 'common/user'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { Avatar } from '../avatar'
// copied from https://tiptap.dev/api/nodes/mention#usage
export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
const { items: users, command } = props
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => setSelectedIndex(0), [users])
const submitUser = (index: number) => {
const user = users[index]
if (user) command({ id: user.id, label: user.username } as any)
}
const onUp = () =>
setSelectedIndex((i) => (i + users.length - 1) % users.length)
const onDown = () => setSelectedIndex((i) => (i + 1) % users.length)
const onEnter = () => submitUser(selectedIndex)
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: any) => {
if (event.key === 'ArrowUp') {
onUp()
return true
}
if (event.key === 'ArrowDown') {
onDown()
return true
}
if (event.key === 'Enter') {
onEnter()
return true
}
return false
},
}))
return (
<div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{!users.length ? (
<span className="m-1 whitespace-nowrap">No results...</span>
) : (
users.map((user, i) => (
<button
className={clsx(
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
)}
onClick={() => submitUser(i)}
>
<Avatar avatarUrl={user.avatarUrl} size="xs" />
{user.username}
</button>
))
)}
</div>
)
})

View File

@ -0,0 +1,72 @@
import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash'
import tippy from 'tippy.js'
import { MentionList } from './mention-list'
type Suggestion = MentionOptions['suggestion']
const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage
export const mentionSuggestion = (users: User[]): Suggestion => ({
items: ({ query }) =>
orderBy(
users.filter((u) => searchInAny(query, u.username, u.name)),
[
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
'followerCountCached',
],
['desc', 'desc']
).slice(0, 5),
render: () => {
let component: ReactRenderer
let popup: ReturnType<typeof tippy>
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props) {
component.updateProps(props)
if (!props.clientRect) {
return
}
popup[0].setProps({
getReferenceClientRect: props.clientRect as any,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return (component.ref as any)?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
})

View File

@ -0,0 +1,29 @@
import Mention from '@tiptap/extension-mention'
import {
mergeAttributes,
NodeViewWrapper,
ReactNodeViewRenderer,
} from '@tiptap/react'
import clsx from 'clsx'
import { Linkify } from '../linkify'
const name = 'mention-component'
const MentionComponent = (props: any) => {
return (
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
<Linkify text={'@' + props.node.attrs.label} />
</NodeViewWrapper>
)
}
/**
* Mention extension that renders React. See:
* https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
* https://tiptap.dev/guide/node-views/react#render-a-react-component
*/
export const DisplayMention = Mention.extend({
parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
})

View File

@ -2,7 +2,6 @@ import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments'
import { Bet } from 'common/bet'
import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments'
import { getSpecificContractActivityItems } from './activity-items'
import { FeedItems } from './feed-items'
import { User } from 'common/user'
@ -26,10 +25,7 @@ export function ContractActivity(props: {
props
const contract = useContractWithPreload(props.contract) ?? props.contract
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const comments = props.comments
const updatedBets = useBets(contract.id)
const bets = (updatedBets ?? props.bets).filter(
(bet) => !bet.isRedemption && bet.amount !== 0
@ -50,6 +46,7 @@ export function ContractActivity(props: {
items={items}
className={className}
betRowClassName={betRowClassName}
user={user}
/>
)
}

View File

@ -1,18 +1,13 @@
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
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 {
CommentInput,
CommentRepliesList,
@ -23,7 +18,6 @@ import { useRouter } from 'next/router'
import { groupBy } from 'lodash'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
export function FeedAnswerCommentGroup(props: {
@ -38,7 +32,6 @@ export function FeedAnswerCommentGroup(props: {
const { username, avatarUrl, name, text } = answer
const [replyToUsername, setReplyToUsername] = useState('')
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [highlighted, setHighlighted] = useState(false)
@ -50,11 +43,6 @@ export function FeedAnswerCommentGroup(props: {
const commentsList = comments.filter(
(comment) => comment.answerOutcome === answer.number.toString()
)
const thisAnswerProb = getDpmOutcomeProbability(
contract.totalShares,
answer.id
)
const probPercent = formatPercent(thisAnswerProb)
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser
@ -112,27 +100,16 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath])
return (
<Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}>
<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>
<Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}>
<Row
className={clsx(
'my-4 flex gap-3 space-x-3 transition-all duration-1000',
'flex gap-3 space-x-3 pt-4 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>
<Avatar username={username} avatarUrl={avatarUrl} />
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
@ -144,43 +121,21 @@ export function FeedAnswerCommentGroup(props: {
/>
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<Col className="align-items justify-between gap-2 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={() => scrollAndOpenReplyInput(undefined, answer)}
>
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'
)}
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
Reply
</button>
</div>
</Row>
)}
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
@ -207,9 +162,9 @@ export function FeedAnswerCommentGroup(props: {
/>
{showReply && (
<div className={'ml-6 pt-4'}>
<div className={'ml-6'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput

View File

@ -93,6 +93,24 @@ export function BetStatusText(props: {
bet.fills?.some((fill) => fill.matchedBetId === null)) ??
false
const fromProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probBefore, contract)
: formatPercent(bet.probBefore)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probBefore, contract)
: formatPercent(bet.limitProb ?? bet.probBefore)
const toProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probAfter, contract)
: formatPercent(bet.probAfter)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probAfter, contract)
: formatPercent(bet.limitProb ?? bet.probAfter)
return (
<div className="text-sm text-gray-500">
{bettor ? (
@ -112,14 +130,9 @@ export function BetStatusText(props: {
contract={contract}
truncate="short"
/>{' '}
{isPseudoNumeric
? ' than ' + formatNumericProbability(bet.probAfter, contract)
: ' at ' +
formatPercent(
hadPoolMatch || isFreeResponse
? bet.probAfter
: bet.limitProb ?? bet.probAfter
)}
{fromProb === toProb
? `at ${fromProb}`
: `from ${fromProb} to ${toProb}`}
</>
)}
<RelativeTimestamp time={createdTime} />

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