Merge branch 'main' into rich-content

This commit is contained in:
Sinclair Chen 2022-07-05 16:56:10 -07:00
commit 903b7f1db0
119 changed files with 3663 additions and 1933 deletions

View File

@ -1,6 +1,7 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
env: {
browser: true,
node: true,
@ -31,6 +32,7 @@ module.exports = {
rules: {
'no-extra-semi': 'off',
'no-constant-condition': ['error', { checkLoops: false }],
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
},
}

View File

@ -10,12 +10,9 @@ import {
import { User } from './user'
import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants'
export const FIXED_ANTE = 100
// deprecated
export const PHANTOM_ANTE = 0.001
export const MINIMUM_ANTE = 50
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id

View File

@ -18,15 +18,24 @@ import {
getDpmProbabilityAfterSale,
} from './calculate-dpm'
import { calculateFixedPayout } from './calculate-fixed-payouts'
import { Contract, BinaryContract, FreeResponseContract } from './contract'
import {
Contract,
BinaryContract,
FreeResponseContract,
PseudoNumericContract,
} from './contract'
export function getProbability(contract: BinaryContract) {
export function getProbability(
contract: BinaryContract | PseudoNumericContract
) {
return contract.mechanism === 'cpmm-1'
? getCpmmProbability(contract.pool, contract.p)
: getDpmProbability(contract.totalShares)
}
export function getInitialProbability(contract: BinaryContract) {
export function getInitialProbability(
contract: BinaryContract | PseudoNumericContract
) {
if (contract.initialProbability) return contract.initialProbability
if (contract.mechanism === 'dpm-2' || (contract as any).totalShares)
@ -65,7 +74,9 @@ export function calculateShares(
}
export function calculateSaleAmount(contract: Contract, bet: Bet) {
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue
: calculateDpmSaleAmount(contract, bet)
}
@ -87,7 +98,9 @@ export function getProbabilityAfterSale(
}
export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateFixedPayout(contract, bet, outcome)
: calculateDpmPayout(contract, bet, outcome)
}
@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
const outcome = contract.resolution
if (!outcome) throw new Error('Contract not resolved')
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateFixedPayout(contract, bet, outcome)
: calculateDpmPayout(contract, bet, outcome)
}
@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100
const hasShares = Object.values(totalShares).some(
(shares) => shares > 0
)
const hasShares = Object.values(totalShares).some((shares) => shares > 0)
return {
invested: Math.max(0, currentInvested),

View File

@ -3,9 +3,10 @@ import { Fees } from './fees'
import { JSONContent } from '@tiptap/core'
export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | FreeResponse | Numeric
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
export type AnyContractType =
| (CPMM & Binary)
| (CPMM & PseudoNumeric)
| (DPM & Binary)
| (DPM & FreeResponse)
| (DPM & Numeric)
@ -45,7 +46,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
collectedFees: Fees
} & T
export type BinaryContract = Contract & Binary
export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse
export type DPMContract = Contract & DPM
@ -76,6 +78,18 @@ export type Binary = {
resolution?: resolution
}
export type PseudoNumeric = {
outcomeType: 'PSEUDO_NUMERIC'
min: number
max: number
isLogScale: boolean
resolutionValue?: number
// same as binary market; map everything to probability
initialProbability: number
resolutionProbability?: number
}
export type FreeResponse = {
outcomeType: 'FREE_RESPONSE'
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
@ -95,7 +109,7 @@ export type Numeric = {
export type outcomeType = AnyOutcomeType['outcomeType']
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const
export const MAX_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000

View File

@ -18,13 +18,17 @@ export type EnvConfig = {
faviconPath?: string // Should be a file in /public
navbarLogoPath?: string
newQuestionPlaceholders: string[]
// Currency controls
fixedAnte?: number
startingBalance?: number
}
type FirebaseConfig = {
apiKey: string
authDomain: string
projectId: string
region: string
region?: string
storageBucket: string
messagingSenderId: string
appId: string

View File

@ -14,14 +14,15 @@ import {
DPMBinaryContract,
FreeResponseContract,
NumericContract,
PseudoNumericContract,
} from './contract'
import { noFees } from './fees'
import { addObjects } from './util/object'
import { NUMERIC_FIXED_VAR } from './numeric-constants'
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'>
export type BetInfo = {
newBet: CandidateBet<Bet>
newBet: CandidateBet
newPool?: { [outcome: string]: number }
newTotalShares?: { [outcome: string]: number }
newTotalBets?: { [outcome: string]: number }
@ -32,7 +33,7 @@ export type BetInfo = {
export const getNewBinaryCpmmBetInfo = (
outcome: 'YES' | 'NO',
amount: number,
contract: CPMMBinaryContract,
contract: CPMMBinaryContract | PseudoNumericContract,
loanAmount: number
) => {
const { shares, newPool, newP, fees } = calculateCpmmPurchase(
@ -45,7 +46,7 @@ export const getNewBinaryCpmmBetInfo = (
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(newPool, newP)
const newBet: CandidateBet<Bet> = {
const newBet: CandidateBet = {
contractId: contract.id,
amount,
shares,
@ -95,7 +96,7 @@ export const getNewBinaryDpmBetInfo = (
const probBefore = getDpmProbability(contract.totalShares)
const probAfter = getDpmProbability(newTotalShares)
const newBet: CandidateBet<Bet> = {
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,
@ -132,7 +133,7 @@ export const getNewMultiBetInfo = (
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
const newBet: CandidateBet<Bet> = {
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,

View File

@ -7,6 +7,7 @@ import {
FreeResponse,
Numeric,
outcomeType,
PseudoNumeric,
} from './contract'
import { User } from './user'
import { parseTags, richTextToString } from './util/parse'
@ -28,7 +29,8 @@ export function getNewContract(
// used for numeric markets
bucketCount: number,
min: number,
max: number
max: number,
isLogScale: boolean
) {
const tags = parseTags(
[
@ -42,6 +44,8 @@ export function getNewContract(
const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
: outcomeType === 'PSEUDO_NUMERIC'
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
: outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max)
: getFreeAnswerProps(ante)
@ -116,6 +120,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
return system
}
const getPseudoNumericCpmmProps = (
initialProb: number,
ante: number,
min: number,
max: number,
isLogScale: boolean
) => {
const system: CPMM & PseudoNumeric = {
...getBinaryCpmmProps(initialProb, ante),
outcomeType: 'PSEUDO_NUMERIC',
min,
max,
isLogScale,
}
return system
}
const getFreeAnswerProps = (ante: number) => {
const system: DPM & FreeResponse = {
mechanism: 'dpm-2',

View File

@ -22,6 +22,8 @@ export type Notification = {
sourceSlug?: string
sourceTitle?: string
isSeenOnHref?: string
}
export type notification_source_types =
| 'contract'
@ -33,6 +35,8 @@ export type notification_source_types =
| 'tip'
| 'admin_message'
| 'group'
| 'user'
| 'bonus'
export type notification_source_update_types =
| 'created'
@ -53,3 +57,7 @@ export type notification_reason_types =
| 'on_new_follow'
| 'you_follow_user'
| 'added_you_to_group'
| 'you_referred_user'
| 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract'
| 'on_group_you_are_member_of'

View File

@ -3,3 +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

View File

@ -1,7 +1,12 @@
import { sumBy, groupBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet'
import { Contract, CPMMBinaryContract, DPMContract } from './contract'
import {
Contract,
CPMMBinaryContract,
DPMContract,
PseudoNumericContract,
} from './contract'
import { Fees } from './fees'
import { LiquidityProvision } from './liquidity-provision'
import {
@ -48,15 +53,19 @@ export type PayoutInfo = {
export const getPayouts = (
outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: Contract,
bets: Bet[],
liquidities: LiquidityProvision[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number
): PayoutInfo => {
if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') {
if (
contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
) {
return getFixedPayouts(
outcome,
contract,
@ -67,16 +76,16 @@ export const getPayouts = (
}
return getDpmPayouts(
outcome,
resolutions,
contract,
bets,
resolutions,
resolutionProbability
)
}
export const getFixedPayouts = (
outcome: string | undefined,
contract: CPMMBinaryContract,
contract: CPMMBinaryContract | PseudoNumericContract,
bets: Bet[],
liquidities: LiquidityProvision[],
resolutionProbability?: number
@ -100,11 +109,11 @@ export const getFixedPayouts = (
export const getDpmPayouts = (
outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: DPMContract,
bets: Bet[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number
): PayoutInfo => {
const openBets = bets.filter((b) => !b.isSold && !b.sale)
@ -115,8 +124,8 @@ export const getDpmPayouts = (
return getDpmStandardPayouts(outcome, contract, openBets)
case 'MKT':
return contract.outcomeType === 'FREE_RESPONSE'
? getPayoutsMultiOutcome(resolutions, contract, openBets)
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
: getDpmMktPayouts(contract, openBets, resolutionProbability)
case 'CANCEL':
case undefined:

45
common/pseudo-numeric.ts Normal file
View File

@ -0,0 +1,45 @@
import { BinaryContract, PseudoNumericContract } from './contract'
import { formatLargeNumber, formatPercent } from './util/format'
export function formatNumericProbability(
p: number,
contract: PseudoNumericContract
) {
const value = getMappedValue(contract)(p)
return formatLargeNumber(value)
}
export const getMappedValue =
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
if (contract.outcomeType === 'BINARY') return p
const { min, max, isLogScale } = contract
if (isLogScale) {
const logValue = p * Math.log10(max - min)
return 10 ** logValue + min
}
return p * (max - min) + min
}
export const getFormattedMappedValue =
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
if (contract.outcomeType === 'BINARY') return formatPercent(p)
const value = getMappedValue(contract)(p)
return formatLargeNumber(value)
}
export const getPseudoProbability = (
value: number,
min: number,
max: number,
isLogScale = false
) => {
if (isLogScale) {
return Math.log10(value - min) / Math.log10(max - min)
}
return (value - min) / (max - min)
}

54
common/redeem.ts Normal file
View File

@ -0,0 +1,54 @@
import { partition, sumBy } from 'lodash'
import { Bet } from './bet'
import { getProbability } from './calculate'
import { CPMMContract } from './contract'
import { noFees } from './fees'
import { CandidateBet } from './new-bet'
type RedeemableBet = Pick<Bet, 'outcome' | 'shares' | 'loanAmount'>
export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0)
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = Math.min(loanAmount, shares)
const netAmount = shares - loanPayment
return { shares, loanPayment, netAmount }
}
export const getRedemptionBets = (
shares: number,
loanPayment: number,
contract: CPMMContract
) => {
const p = getProbability(contract)
const createdTime = Date.now()
const yesBet: CandidateBet = {
contractId: contract.id,
amount: p * -shares,
shares: -shares,
loanAmount: loanPayment ? -loanPayment / 2 : 0,
outcome: 'YES',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
}
const noBet: CandidateBet = {
contractId: contract.id,
amount: (1 - p) * -shares,
shares: -shares,
loanAmount: loanPayment ? -loanPayment / 2 : 0,
outcome: 'NO',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
}
return [yesBet, noBet]
}

View File

@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
)
const { payouts: resolvePayouts } = getPayouts(
resolution as string,
{},
contract,
openBets,
[],
{},
resolutionProb
)

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
type AnyTxnType = Donation | Tip | Manalink
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
// Any extra data
data?: { [key: string]: any }
@ -46,6 +47,19 @@ type Manalink = {
category: 'MANALINK'
}
type Referral = {
fromType: 'BANK'
toType: 'USER'
category: 'REFERRAL'
}
type Bonus = {
fromType: 'BANK'
toType: 'USER'
category: 'UNIQUE_BETTOR_BONUS'
}
export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink
export type ReferralTxn = Txn & Referral

View File

@ -1,3 +1,5 @@
import { ENV_CONFIG } from './envs/constants'
export type User = {
id: string
createdTime: number
@ -33,11 +35,15 @@ export type User = {
followerCountCached: number
followedCategories?: string[]
referredByUserId?: string
referredByContractId?: string
}
export const STARTING_BALANCE = 1000
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
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 type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User
@ -51,6 +57,7 @@ export type PrivateUser = {
initialIpAddress?: string
apiKey?: string
notificationPreferences?: notification_subscribe_types
lastTimeCheckedBonuses?: number
}
export type notification_subscribe_types = 'all' | 'less' | 'none'

View File

@ -456,7 +456,6 @@ Requires no authorization.
}
```
### `POST /v0/bet`
Places a new bet on behalf of the authorized user.
@ -514,6 +513,60 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"initialProb":25}'
```
### `POST /v0/market/[marketId]/resolve`
Resolves a market on behalf of the authorized user.
Parameters:
For binary markets:
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
For free response markets:
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
For numeric markets:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `value`: The value that the market may resolves to.
Example request:
```
# Resolve a binary market
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "YES"}'
# Resolve a binary market with a specified probability
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "MKT", \
"probabilityInt": 75}'
# Resolve a free response market with a single answer chosen
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": 2}'
# Resolve a free response market with multiple answers chosen
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "MKT", \
"resolutions": [ \
{"answer": 0, "pct": 50}, \
{"answer": 2, "pct": 50} \
]}'
```
## Changelog
- 2022-06-08: Add paging to markets endpoint

View File

@ -19,7 +19,6 @@ for the pool to be sorted into.
- Users can create a market on any question they want.
- When a user creates a market, they must choose a close date, after which trading will halt.
- They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market.
- The creation fee for the first market created each day is provided by Manifold.
- The market creator will earn a commission on all bets placed in the market.
- The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution.
- Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares.

View File

@ -337,6 +337,20 @@
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "portfolioHistory",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "timestamp",
"order": "ASCENDING"
}
]
}
],
"fieldOverrides": [

View File

@ -20,7 +20,16 @@ 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']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['referredByUserId'])
// only one referral allowed per user
&& !("referredByUserId" in resource.data)
// user can't refer themselves
&& !(resource.data.id == request.resource.data.referredByUserId);
// quid pro quos enabled (only once though so nbd) - bc I can't make this work:
// && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id);
}
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {

3
functions/.env Normal file
View File

@ -0,0 +1,3 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=PROD

View File

@ -1,7 +1,7 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
ignorePatterns: ['dist', 'lib'],
env: {
node: true,
},
@ -30,6 +30,7 @@ module.exports = {
},
],
rules: {
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
},
}

View File

@ -1,5 +1,4 @@
# Secrets
.env*
.runtimeconfig.json
# GCP deployment artifact

View File

@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
### For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 0. `$ brew install java`
1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
1. `$ brew install java`
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
4. `$ mkdir firestore_export` to create a folder to store the exported database

View File

@ -5,14 +5,14 @@
"firestore": "dev-mantic-markets.appspot.com"
},
"scripts": {
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist",
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
"compile": "tsc -b",
"watch": "tsc -w",
"shell": "yarn build && firebase functions:shell",
"start": "yarn shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log",
"serve": "yarn build && firebase emulators:start --only functions,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",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
@ -23,9 +23,9 @@
"main": "functions/src/index.js",
"dependencies": {
"@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "^2.0.0-beta.181",
"@tiptap/starter-kit": "^2.0.0-beta.190",
"fetch": "1.1.0",
"firebase-admin": "10.0.0",
"firebase-functions": "3.21.2",
"lodash": "4.17.21",

View File

@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
const contract = contractSnap.data() as Contract
if (
contract.mechanism !== 'cpmm-1' ||
contract.outcomeType !== 'BINARY'
(contract.outcomeType !== 'BINARY' &&
contract.outcomeType !== 'PSEUDO_NUMERIC')
)
return { status: 'error', message: 'Invalid contract' }

View File

@ -108,7 +108,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
}
}
const DEFAULT_OPTS: HttpsOptions = {
interface EndpointOptions extends HttpsOptions {
methods?: string[]
}
const DEFAULT_OPTS = {
methods: ['POST'],
minInstances: 1,
concurrency: 100,
memory: '2GiB',
@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = {
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
}
export const newEndpoint = (methods: [string], fn: Handler) =>
onRequest(DEFAULT_OPTS, async (req, res) => {
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 (!methods.includes(req.method)) {
const allowed = methods.join(', ')
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))
@ -140,3 +146,4 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
}
}
})
}

View File

@ -18,46 +18,63 @@
import * as functions from 'firebase-functions'
import * as firestore from '@google-cloud/firestore'
const client = new firestore.v1.FirestoreAdminClient()
import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client'
const bucket = 'gs://manifold-firestore-backup'
export const backupDbCore = async (
client: FirestoreAdminClient,
project: string,
bucket: string
) => {
const name = client.databasePath(project, '(default)')
const outputUriPrefix = `gs://${bucket}`
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
// NOTE: Subcollections are not backed up by default
const collectionIds = [
'contracts',
'groups',
'private-users',
'stripe-transactions',
'transactions',
'users',
'bets',
'comments',
'follows',
'followers',
'answers',
'txns',
'manalinks',
'liquidity',
'stats',
'cache',
'latency',
'views',
'notifications',
'portfolioHistory',
'folds',
]
return await client.exportDocuments({ name, outputUriPrefix, collectionIds })
}
export const backupDb = functions.pubsub
.schedule('every 24 hours')
.onRun((_context) => {
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
if (projectId == null) {
throw new Error('No project ID environment variable set.')
.onRun(async (_context) => {
try {
const client = new firestore.v1.FirestoreAdminClient()
const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
if (project == null) {
throw new Error('No project ID environment variable set.')
}
const responses = await backupDbCore(
client,
project,
'manifold-firestore-backup'
)
const response = responses[0]
console.log(`Operation Name: ${response['name']}`)
} catch (err) {
console.error(err)
throw new Error('Export operation failed')
}
const databaseName = client.databasePath(projectId, '(default)')
return client
.exportDocuments({
name: databaseName,
outputUriPrefix: bucket,
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
// NOTE: Subcollections are not backed up by default
collectionIds: [
'contracts',
'groups',
'private-users',
'stripe-transactions',
'users',
'bets',
'comments',
'followers',
'answers',
'txns',
],
})
.then((responses) => {
const response = responses[0]
console.log(`Operation Name: ${response['name']}`)
})
.catch((err) => {
console.error(err)
throw new Error('Export operation failed')
})
})

View File

@ -1,17 +0,0 @@
import * as admin from 'firebase-admin'
import fetch from './fetch'
export const callCloudFunction = (functionName: string, data: unknown = {}) => {
const projectId = admin.instanceId().app.options.projectId
const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}`
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ data }),
}).then((response) => response.json())
}

View File

@ -27,6 +27,7 @@ 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 { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
@ -68,19 +69,31 @@ const binarySchema = z.object({
initialProb: z.number().min(1).max(99),
})
const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER)
const numericSchema = z.object({
min: z.number(),
max: z.number(),
min: finite(),
max: finite(),
initialValue: finite(),
isLogScale: z.boolean().optional(),
})
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
export const createmarket = newEndpoint({}, async (req, auth) => {
const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body)
let min, max, initialProb
if (outcomeType === 'NUMERIC') {
;({ min, max } = validate(numericSchema, req.body))
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.')
let min, max, initialProb, isLogScale
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue
;({ min, max, initialValue, isLogScale } = validate(
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue < min || initialValue > max)
throw new APIError(400, 'Invalid range.')
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
}
if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body))
@ -144,7 +157,8 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0
max ?? 0,
isLogScale ?? false
)
if (ante) await chargeUser(user.id, ante, true)
@ -153,7 +167,7 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
const providerId = user.id
if (outcomeType === 'BINARY') {
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()

View File

@ -20,7 +20,7 @@ const bodySchema = z.object({
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
})
export const creategroup = newEndpoint(['POST'], async (req, auth) => {
export const creategroup = newEndpoint({}, async (req, auth) => {
const { name, about, memberIds, anyoneCanJoin } = validate(
bodySchema,
req.body

View File

@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
const firestore = admin.firestore()
type user_to_reason_texts = {
[userId: string]: { reason: notification_reason_types }
[userId: string]: { reason: notification_reason_types; isSeeOnHref?: string }
}
export const createNotification = async (
@ -68,9 +68,11 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername,
// TODO: move away from sourceContractTitle to sourceTitle
sourceContractTitle: sourceContract?.question,
// TODO: move away from sourceContractSlug to sourceSlug
sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
}
await notificationRef.set(removeUndefinedProps(notification))
})
@ -252,44 +254,90 @@ 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
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const notifyOtherGroupMembersOfComment = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_group_you_are_member_of',
isSeeOnHref: sourceSlug,
}
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceContract) {
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) {
if (sourceType === 'comment') {
if (relatedUserId && relatedSourceType)
await notifyRepliedUsers(
userToReasonTexts,
relatedUserId,
relatedSourceType
)
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
}
} else if (sourceType === 'follow' && relatedUserId) {
if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
} else if (sourceType === 'user' && relatedUserId) {
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
} else if (sourceType === 'comment' && !sourceContract && relatedUserId) {
await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId)
}
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) {
if (sourceType === 'comment') {
if (relatedUserId && relatedSourceType)
await notifyRepliedUsers(
userToReasonTexts,
relatedUserId,
relatedSourceType
)
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
}
return userToReasonTexts
}

View File

@ -6,8 +6,13 @@ import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format'
import {
formatLargeNumber,
formatMoney,
formatPercent,
} from '../../common/util/format'
import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
@ -101,6 +106,17 @@ const toDisplayResolution = (
return display || resolution
}
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
const { resolutionValue } = contract
return resolutionValue
? formatLargeNumber(resolutionValue)
: formatNumericProbability(
resolutionProbability ?? getProbability(contract),
contract
)
}
if (resolution === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A'

View File

@ -1,9 +0,0 @@
let fetchRequest: typeof fetch
try {
fetchRequest = fetch
} catch {
fetchRequest = require('node-fetch')
}
export default fetchRequest

View File

@ -0,0 +1,139 @@
import { APIError, newEndpoint } from './api'
import { log } from './utils'
import * as admin from 'firebase-admin'
import { PrivateUser } from '../../common/lib/user'
import { uniq } from 'lodash'
import { Bet } from '../../common/lib/bet'
const firestore = admin.firestore()
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { runTxn, TxnData } from './transact'
import { createNotification } from './create-notification'
import { User } from '../../common/lib/user'
import { Contract } from '../../common/lib/contract'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime()
const QUERY_LIMIT_SECONDS = 60
export const getdailybonuses = newEndpoint({}, async (req, auth) => {
const { user, lastTimeCheckedBonuses } = await firestore.runTransaction(
async (trans) => {
const userSnap = await trans.get(
firestore.doc(`private-users/${auth.uid}`)
)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as PrivateUser
const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0
if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000)
throw new APIError(
400,
`Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.`
)
await trans.update(userSnap.ref, {
lastTimeCheckedBonuses: Date.now(),
})
return {
user,
lastTimeCheckedBonuses,
}
}
)
// TODO: switch to prod id
// const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account
const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
// Get all users contracts made since implementation time
const userContractsSnap = await firestore
.collection(`contracts`)
.where('creatorId', '==', user.id)
.where('createdTime', '>=', BONUS_START_DATE)
.get()
const userContracts = userContractsSnap.docs.map(
(doc) => doc.data() as Contract
)
const nullReturn = { status: 'no bets', txn: null }
for (const contract of userContracts) {
const result = await firestore.runTransaction(async (trans) => {
const contractId = contract.id
// Get all bets made on user's contracts
const bets = (
await firestore
.collection(`contracts/${contractId}/bets`)
.where('userId', '!=', user.id)
.get()
).docs.map((bet) => bet.ref)
if (bets.length === 0) {
return nullReturn
}
const contractBetsSnap = await trans.getAll(...bets)
const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet)
const uniqueBettorIdsBeforeLastResetTime = uniq(
contractBets
.filter((bet) => bet.createdTime < lastTimeCheckedBonuses)
.map((bet) => bet.userId)
)
// Filter users for ONLY those that have made bets since the last daily bonus received time
const uniqueBettorIdsWithBetsAfterLastResetTime = uniq(
contractBets
.filter((bet) => bet.createdTime > lastTimeCheckedBonuses)
.map((bet) => bet.userId)
)
// Filter for users only present in the above list
const newUniqueBettorIds =
uniqueBettorIdsWithBetsAfterLastResetTime.filter(
(userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId)
)
newUniqueBettorIds.length > 0 &&
log(
`Got ${newUniqueBettorIds.length} new unique bettors since last bonus`
)
if (newUniqueBettorIds.length === 0) {
return nullReturn
}
// Create combined txn for all unique bettors
const bonusTxnDetails = {
contractId: contractId,
uniqueBettors: newUniqueBettorIds.length,
}
const bonusTxn: TxnData = {
fromId: fromUser.id,
fromType: 'BANK',
toId: user.id,
toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length,
token: 'M$',
category: 'UNIQUE_BETTOR_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
result.status != nullReturn.status &&
log(`No bonus for user: ${user.id} - reason:`, result.status)
} else {
log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id)
await createNotification(
result.txn.id,
'bonus',
'created',
fromUser,
result.txn.id,
result.txn.amount + '',
contract,
undefined,
// No need to set the user id, we'll use the contract creator id
undefined,
contract.slug,
contract.question
)
}
}
return { userId: user.id, message: 'success' }
})

View File

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

View File

@ -6,12 +6,11 @@ admin.initializeApp()
// export * from './keep-awake'
export * from './claim-manalink'
export * from './transact'
export * from './resolve-market'
export * from './stripe'
export * from './create-user'
export * from './create-answer'
export * from './on-create-bet'
export * from './on-create-comment'
export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './unsubscribe'
export * from './update-metrics'
@ -28,6 +27,8 @@ export * from './on-unfollow-user'
export * from './on-create-liquidity-provision'
export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user'
export * from './on-create-comment-on-group'
// v2
export * from './health'
@ -37,3 +38,5 @@ export * from './sell-shares'
export * from './create-contract'
export * from './withdraw-liquidity'
export * from './create-group'
export * from './resolve-market'
export * from './get-daily-bonuses'

View File

@ -1,25 +0,0 @@
import * as functions from 'firebase-functions'
import { callCloudFunction } from './call-cloud-function'
export const keepAwake = functions.pubsub
.schedule('every 1 minutes')
.onRun(async () => {
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
await sleep(30)
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
})
const sleep = (seconds: number) => {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}

View File

@ -11,7 +11,7 @@ import { createNotification } from './create-notification'
const firestore = admin.firestore()
export const onCreateComment = functions
export const onCreateCommentOnContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/comments/{commentId}')
.onCreate(async (change, context) => {

View File

@ -0,0 +1,52 @@
import * as functions from 'firebase-functions'
import { Comment } from '../../common/comment'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
import { User } from '../../common/user'
import { createNotification } from './create-notification'
const firestore = admin.firestore()
export const onCreateCommentOnGroup = functions.firestore
.document('groups/{groupId}/comments/{commentId}')
.onCreate(async (change, context) => {
const { eventId } = context
const { groupId } = context.params as {
groupId: string
}
const comment = change.data() as Comment
const creatorSnapshot = await firestore
.collection('users')
.doc(comment.userId)
.get()
if (!creatorSnapshot.exists) throw new Error('Could not find user')
const groupSnapshot = await firestore
.collection('groups')
.doc(groupId)
.get()
if (!groupSnapshot.exists) throw new Error('Could not find group')
const group = groupSnapshot.data() as Group
await firestore.collection('groups').doc(groupId).update({
mostRecentActivityTime: comment.createdTime,
})
await Promise.all(
group.memberIds.map(async (memberId) => {
return await createNotification(
comment.id,
'comment',
'created',
creatorSnapshot.data() as User,
eventId,
comment.text,
undefined,
undefined,
memberId,
`/group/${group.slug}`,
`${group.name}`
)
})
)
})

View File

@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore
// ignore the update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
await firestore
.collection('groups')

View File

@ -0,0 +1,111 @@
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 { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
const firestore = admin.firestore()
export const onUpdateUser = functions.firestore
.document('users/{userId}')
.onUpdate(async (change, context) => {
const prevUser = change.before.data() as User
const user = change.after.data() as User
const { eventId } = context
if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId)
}
})
async function handleUserUpdatedReferral(user: User, eventId: string) {
// Only create a referral txn if the user has a referredByUserId
if (!user.referredByUserId) {
console.log(`Not set: referredByUserId ${user.referredByUserId}`)
return
}
const referredByUserId = user.referredByUserId
await firestore.runTransaction(async (transaction) => {
// get user that referred this user
const referredByUserDoc = firestore.doc(`users/${referredByUserId}`)
const referredByUserSnap = await transaction.get(referredByUserDoc)
if (!referredByUserSnap.exists) {
console.log(`User ${referredByUserId} not found`)
return
}
const referredByUser = referredByUserSnap.data() as User
let referredByContract: Contract | undefined = undefined
if (user.referredByContractId) {
const referredByContractDoc = firestore.doc(
`contracts/${user.referredByContractId}`
)
referredByContract = await transaction
.get(referredByContractDoc)
.then((snap) => snap.data() as Contract)
}
console.log(`referredByContract: ${referredByContract}`)
const txns = (
await firestore
.collection('txns')
.where('toId', '==', referredByUserId)
.where('category', '==', 'REFERRAL')
.get()
).docs.map((txn) => txn.ref)
if (txns.length > 0) {
const referralTxns = await transaction.getAll(...txns).catch((err) => {
console.error('error getting txns:', err)
throw err
})
// If the referring user already has a referral txn due to referring this user, halt
if (
referralTxns.map((txn) => txn.data()?.description).includes(user.id)
) {
console.log('found referral txn with the same details, aborting')
return
}
}
console.log('creating referral txns')
const fromId = HOUSE_LIQUIDITY_PROVIDER_ID
// if they're updating their referredId, create a txn for both
const txn: ReferralTxn = {
id: eventId,
createdTime: Date.now(),
fromId,
fromType: 'BANK',
toId: referredByUserId,
toType: 'USER',
amount: REFERRAL_AMOUNT,
token: 'M$',
category: 'REFERRAL',
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
}
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
await 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, {
balance: referredByUser.balance + REFERRAL_AMOUNT,
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
})
await createNotification(
user.id,
'user',
'updated',
user,
eventId,
txn.amount.toString(),
referredByContract,
'user',
referredByUser.id,
referredByContract?.slug,
referredByContract?.question
)
})
}

View File

@ -33,7 +33,7 @@ const numericSchema = z.object({
value: z.number(),
})
export const placebet = newEndpoint(['POST'], async (req, auth) => {
export const placebet = newEndpoint({}, async (req, auth) => {
log('Inside endpoint handler.')
const { amount, contractId } = validate(bodySchema, req.body)
@ -41,10 +41,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
log('Inside main transaction.')
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const [contractSnap, userSnap] = await Promise.all([
trans.get(contractDoc),
trans.get(userDoc),
])
const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
log('Loaded user and contract snapshots.')
@ -70,7 +67,10 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
const { outcome } = validate(binarySchema, req.body)
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') {
} else if (
(outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') &&
mechanism == 'cpmm-1'
) {
const { outcome } = validate(binarySchema, req.body)
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {

View File

@ -1,92 +1,46 @@
import * as admin from 'firebase-admin'
import { partition, sumBy } from 'lodash'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
import { Contract } from '../../common/contract'
import { noFees } from '../../common/fees'
import { User } from '../../common/user'
export const redeemShares = async (userId: string, contractId: string) => {
return await firestore.runTransaction(async (transaction) => {
return await firestore.runTransaction(async (trans) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1')
return { status: 'success' }
const { mechanism } = contract
if (mechanism !== 'cpmm-1') return { status: 'success' }
const betsSnap = await transaction.get(
firestore
.collection(`contracts/${contract.id}/bets`)
.where('userId', '==', userId)
)
const betsColl = firestore.collection(`contracts/${contract.id}/bets`)
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const amount = Math.min(yesShares, noShares)
if (amount <= 0) return
const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPaid = Math.min(prevLoanAmount, amount)
const netAmount = amount - loanPaid
const p = getProbability(contract)
const createdTime = Date.now()
const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
const yesBet: Bet = {
id: yesDoc.id,
userId: userId,
contractId: contract.id,
amount: p * -amount,
shares: -amount,
loanAmount: loanPaid ? -loanPaid / 2 : 0,
outcome: 'YES',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
}
const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
const noBet: Bet = {
id: noDoc.id,
userId: userId,
contractId: contract.id,
amount: (1 - p) * -amount,
shares: -amount,
loanAmount: loanPaid ? -loanPaid / 2 : 0,
outcome: 'NO',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
if (netAmount === 0) {
return { status: 'success' }
}
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)
const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
const userSnap = await trans.get(userDoc)
if (!userSnap.exists) return { status: 'error', message: 'User not found' }
const user = userSnap.data() as User
const newBalance = user.balance + netAmount
if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
transaction.create(yesDoc, yesBet)
transaction.create(noDoc, noBet)
const yesDoc = betsColl.doc()
const noDoc = betsColl.doc()
trans.update(userDoc, { balance: newBalance })
trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet })
trans.create(noDoc, { id: noDoc.id, userId, ...noBet })
return { status: 'success' }
})

View File

@ -1,8 +1,12 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { Contract, resolution, RESOLUTIONS } from '../../common/contract'
import {
Contract,
FreeResponseContract,
RESOLUTIONS,
} from '../../common/contract'
import { User } from '../../common/user'
import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils'
@ -15,156 +19,162 @@ import {
} from '../../common/payouts'
import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
export const resolveMarket = functions
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
.https.onCall(
async (
data: {
outcome: resolution
value?: number
contractId: string
probabilityInt?: number
resolutions?: { [outcome: string]: number }
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const bodySchema = z.object({
contractId: z.string(),
})
const { outcome, contractId, probabilityInt, resolutions, value } = data
const binarySchema = z.object({
outcome: z.enum(RESOLUTIONS),
probabilityInt: z.number().gte(0).lte(100).optional(),
})
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
const { creatorId, outcomeType, closeTime } = contract
const freeResponseSchema = z.union([
z.object({
outcome: z.literal('CANCEL'),
}),
z.object({
outcome: z.literal('MKT'),
resolutions: z.array(
z.object({
answer: z.number().int().nonnegative(),
pct: z.number().gte(0).lte(100),
})
),
}),
z.object({
outcome: z.number().int().nonnegative(),
}),
])
if (outcomeType === 'BINARY') {
if (!RESOLUTIONS.includes(outcome))
return { status: 'error', message: 'Invalid outcome' }
} else if (outcomeType === 'FREE_RESPONSE') {
if (
isNaN(+outcome) &&
!(outcome === 'MKT' && resolutions) &&
outcome !== 'CANCEL'
)
return { status: 'error', message: 'Invalid outcome' }
} else if (outcomeType === 'NUMERIC') {
if (isNaN(+outcome) && outcome !== 'CANCEL')
return { status: 'error', message: 'Invalid outcome' }
} else {
return { status: 'error', message: 'Invalid contract outcomeType' }
}
const numericSchema = z.object({
outcome: z.union([z.literal('CANCEL'), z.string()]),
value: z.number().optional(),
})
if (value !== undefined && !isFinite(value))
return { status: 'error', message: 'Invalid value' }
const pseudoNumericSchema = z.union([
z.object({
outcome: z.literal('CANCEL'),
}),
z.object({
outcome: z.literal('MKT'),
value: z.number(),
probabilityInt: z.number().gte(0).lte(100),
}),
])
if (
outcomeType === 'BINARY' &&
probabilityInt !== undefined &&
(probabilityInt < 0 ||
probabilityInt > 100 ||
!isFinite(probabilityInt))
)
return { status: 'error', message: 'Invalid probability' }
const opts = { secrets: ['MAILGUN_KEY'] }
if (creatorId !== userId)
return { status: 'error', message: 'User not creator of contract' }
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body)
const userId = auth.uid
if (contract.resolution)
return { status: 'error', message: 'Contract already resolved' }
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract
const { creatorId, closeTime } = contract
const creator = await getUser(creatorId)
if (!creator) return { status: 'error', message: 'Creator not found' }
const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined
const resolutionTime = Date.now()
const newCloseTime = closeTime
? Math.min(closeTime, resolutionTime)
: closeTime
const betsSnap = await firestore
.collection(`contracts/${contractId}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const liquiditiesSnap = await firestore
.collection(`contracts/${contractId}/liquidity`)
.get()
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
getPayouts(
outcome,
resolutions ?? {},
contract,
bets,
liquidities,
resolutionProbability
)
await contractDoc.update(
removeUndefinedProps({
isResolved: true,
resolution: outcome,
resolutionValue: value,
resolutionTime,
closeTime: newCloseTime,
resolutionProbability,
resolutions,
collectedFees,
})
)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets)
if (!isProd())
console.log(
'payouts:',
payouts,
'creator payout:',
creatorPayout,
'liquidity payout:'
)
if (creatorPayout)
await processPayouts(
[{ userId: creatorId, payout: creatorPayout }],
true
)
await processPayouts(liquidityPayouts, true)
const result = await processPayouts([...payouts, ...loanPayouts])
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
await sendResolutionEmails(
openBets,
userPayoutsWithoutLoans,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions
)
return result
}
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
contract,
req.body
)
if (creatorId !== userId)
throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
const creator = await getUser(creatorId)
if (!creator) throw new APIError(500, 'Creator not found')
const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined
const resolutionTime = Date.now()
const newCloseTime = closeTime
? Math.min(closeTime, resolutionTime)
: closeTime
const betsSnap = await firestore
.collection(`contracts/${contractId}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const liquiditiesSnap = await firestore
.collection(`contracts/${contractId}/liquidity`)
.get()
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
getPayouts(
outcome,
contract,
bets,
liquidities,
resolutions,
resolutionProbability
)
const updatedContract = {
...contract,
...removeUndefinedProps({
isResolved: true,
resolution: outcome,
resolutionValue: value,
resolutionTime,
closeTime: newCloseTime,
resolutionProbability,
resolutions,
collectedFees,
}),
}
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets)
if (!isProd())
console.log(
'payouts:',
payouts,
'creator payout:',
creatorPayout,
'liquidity payout:'
)
if (creatorPayout)
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
await processPayouts(liquidityPayouts, true)
await processPayouts([...payouts, ...loanPayouts])
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
await sendResolutionEmails(
openBets,
userPayoutsWithoutLoans,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions
)
return updatedContract
})
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts)
@ -221,4 +231,72 @@ const sendResolutionEmails = async (
)
}
function getResolutionParams(contract: Contract, body: string) {
const { outcomeType } = contract
if (outcomeType === 'NUMERIC') {
return {
...validate(numericSchema, body),
resolutions: undefined,
probabilityInt: undefined,
}
} else if (outcomeType === 'PSEUDO_NUMERIC') {
return {
...validate(pseudoNumericSchema, body),
resolutions: undefined,
}
} else if (outcomeType === 'FREE_RESPONSE') {
const freeResponseParams = validate(freeResponseSchema, body)
const { outcome } = freeResponseParams
switch (outcome) {
case 'CANCEL':
return {
outcome: outcome.toString(),
resolutions: undefined,
value: undefined,
probabilityInt: undefined,
}
case 'MKT': {
const { resolutions } = freeResponseParams
resolutions.forEach(({ answer }) => validateAnswer(contract, answer))
const pctSum = sumBy(resolutions, ({ pct }) => pct)
if (Math.abs(pctSum - 100) > 0.1) {
throw new APIError(400, 'Resolution percentages must sum to 100')
}
return {
outcome: outcome.toString(),
resolutions: Object.fromEntries(
resolutions.map((r) => [r.answer, r.pct])
),
value: undefined,
probabilityInt: undefined,
}
}
default: {
validateAnswer(contract, outcome)
return {
outcome: outcome.toString(),
resolutions: undefined,
value: undefined,
probabilityInt: undefined,
}
}
}
} else if (outcomeType === 'BINARY') {
return {
...validate(binarySchema, body),
value: undefined,
resolutions: undefined,
}
}
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
}
function validateAnswer(contract: FreeResponseContract, 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`)
}
}
const firestore = admin.firestore()

View File

@ -0,0 +1,16 @@
import * as firestore from '@google-cloud/firestore'
import { getServiceAccountCredentials } from './script-init'
import { backupDbCore } from '../backup-db'
async function backupDb() {
const credentials = getServiceAccountCredentials()
const projectId = credentials.project_id
const client = new firestore.v1.FirestoreAdminClient({ credentials })
const bucket = 'manifold-firestore-backup'
const resp = await backupDbCore(client, projectId, bucket)
console.log(`Operation: ${resp[0]['name']}`)
}
if (require.main === module) {
backupDb().then(() => process.exit())
}

View File

@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
const { payouts } = getPayouts(
resolution,
resolutions,
contract,
openBets,
[],
resolutions,
resolutionProbability
)

View File

@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => {
}
}
export const initAdmin = (env?: string) => {
export const getServiceAccountCredentials = (env?: string) => {
env = env || getFirebaseActiveProject(process.cwd())
if (env == null) {
console.error(
throw new Error(
"Couldn't find active Firebase project; did you do `firebase use <alias>?`"
)
return
}
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
const keyPath = process.env[envVar]
if (keyPath == null) {
console.error(
throw new Error(
`Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.`
)
return
}
console.log(`Initializing connection to ${env} Firebase...`)
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const serviceAccount = require(keyPath)
admin.initializeApp({
return require(keyPath)
}
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),
})
}

View File

@ -1,53 +0,0 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { User } from '../../../common/user'
import { batchedWaitAll } from '../../../common/util/promise'
import { Contract } from '../../../common/contract'
import { updateWordScores } from '../update-recommendations'
import { computeFeed } from '../update-feed'
import { getFeedContracts, getTaggedContracts } from '../get-feed-data'
import { CATEGORY_LIST } from '../../../common/categories'
const firestore = admin.firestore()
async function updateFeed() {
console.log('Updating feed')
const contracts = await getValues<Contract>(firestore.collection('contracts'))
const feedContracts = await getFeedContracts()
const users = await getValues<User>(
firestore.collection('users').where('username', '==', 'JamesGrugett')
)
await batchedWaitAll(
users.map((user) => async () => {
console.log('Updating recs for', user.username)
await updateWordScores(user, contracts)
console.log('Updating feed for', user.username)
await computeFeed(user, feedContracts)
})
)
console.log('Updating feed categories!')
await batchedWaitAll(
users.map((user) => async () => {
for (const category of CATEGORY_LIST) {
const contracts = await getTaggedContracts(category)
const feed = await computeFeed(user, contracts)
await firestore
.collection(`private-users/${user.id}/cache`)
.doc(`feed-${category}`)
.set({ feed })
}
})
)
}
if (require.main === module) {
updateFeed().then(() => process.exit())
}

View File

@ -13,7 +13,7 @@ const bodySchema = z.object({
betId: z.string(),
})
export const sellbet = newEndpoint(['POST'], async (req, auth) => {
export const sellbet = newEndpoint({}, async (req, auth) => {
const { contractId, betId } = validate(bodySchema, req.body)
// run as transaction to prevent race conditions
@ -21,11 +21,11 @@ export const sellbet = newEndpoint(['POST'], async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
const [contractSnap, userSnap, betSnap] = await Promise.all([
transaction.get(contractDoc),
transaction.get(userDoc),
transaction.get(betDoc),
])
const [contractSnap, userSnap, betSnap] = await transaction.getAll(
contractDoc,
userDoc,
betDoc
)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')

View File

@ -16,7 +16,7 @@ const bodySchema = z.object({
outcome: z.enum(['YES', 'NO']),
})
export const sellshares = newEndpoint(['POST'], async (req, auth) => {
export const sellshares = newEndpoint({}, async (req, auth) => {
const { contractId, shares, outcome } = validate(bodySchema, req.body)
// Run as transaction to prevent race conditions.
@ -24,9 +24,8 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [contractSnap, userSnap, userBets] = await Promise.all([
transaction.get(contractDoc),
transaction.get(userDoc),
const [[contractSnap, userSnap], userBets] = await Promise.all([
transaction.getAll(contractDoc, userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
@ -47,7 +46,7 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
if (shares > maxShares + 0.000000000001)
if (shares > maxShares)
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo(

View File

@ -1,220 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'
import { logInterpolation } from '../../common/util/math'
import { DAY_MS } from '../../common/util/time'
import {
getProbability,
getOutcomeProbability,
getTopAnswer,
} from '../../common/calculate'
import { User } from '../../common/user'
import {
getContractScore,
MAX_FEED_CONTRACTS,
} from '../../common/recommended-contracts'
import { callCloudFunction } from './call-cloud-function'
import {
getFeedContracts,
getRecentBetsAndComments,
getTaggedContracts,
} from './get-feed-data'
import { CATEGORY_LIST } from '../../common/categories'
const firestore = admin.firestore()
const BATCH_SIZE = 30
const MAX_BATCHES = 50
const getUserBatches = async () => {
const users = shuffle(await getValues<User>(firestore.collection('users')))
const userBatches: User[][] = []
for (let i = 0; i < users.length; i += BATCH_SIZE) {
userBatches.push(users.slice(i, i + BATCH_SIZE))
}
console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length)
return userBatches.slice(0, MAX_BATCHES)
}
export const updateFeed = functions.pubsub
.schedule('every 60 minutes')
.onRun(async () => {
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map((users) =>
callCloudFunction('updateFeedBatch', { users })
)
)
console.log('updating category feed')
await Promise.all(
CATEGORY_LIST.map((category) =>
callCloudFunction('updateCategoryFeed', {
category,
})
)
)
})
export const updateFeedBatch = functions.https.onCall(
async (data: { users: User[] }) => {
const { users } = data
const contracts = await getFeedContracts()
const feeds = await getNewFeeds(users, contracts)
await Promise.all(
zip(users, feeds).map(([user, feed]) =>
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
getUserCacheCollection(user!).doc('feed').set({ feed })
)
)
}
)
export const updateCategoryFeed = functions.https.onCall(
async (data: { category: string }) => {
const { category } = data
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map(async (users) => {
await callCloudFunction('updateCategoryFeedBatch', {
users,
category,
})
})
)
}
)
export const updateCategoryFeedBatch = functions.https.onCall(
async (data: { users: User[]; category: string }) => {
const { users, category } = data
const contracts = await getTaggedContracts(category)
const feeds = await getNewFeeds(users, contracts)
await Promise.all(
zip(users, feeds).map(([user, feed]) =>
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed })
)
)
}
)
const getNewFeeds = async (users: User[], contracts: Contract[]) => {
const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts)))
const contractIds = uniq(flatten(feeds).map((c) => c.id))
const data = await Promise.all(contractIds.map(getRecentBetsAndComments))
const dataByContractId = zipObject(contractIds, data)
return feeds.map((feed) =>
feed.map((contract) => {
return { contract, ...dataByContractId[contract.id] }
})
)
}
const getUserCacheCollection = (user: User) =>
firestore.collection(`private-users/${user.id}/cache`)
export const computeFeed = async (user: User, contracts: Contract[]) => {
const userCacheCollection = getUserCacheCollection(user)
const [wordScores, lastViewedTime] = await Promise.all([
getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')),
getValue<{ [contractId: string]: number }>(
userCacheCollection.doc('lastViewTime')
),
]).then((dicts) => dicts.map((dict) => dict ?? {}))
const scoredContracts = contracts.map((contract) => {
const score = scoreContract(
contract,
wordScores,
lastViewedTime[contract.id]
)
return [contract, score] as [Contract, number]
})
const sortedContracts = sortBy(
scoredContracts,
([_, score]) => score
).reverse()
// console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score))
return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c)
}
function scoreContract(
contract: Contract,
wordScores: { [word: string]: number },
viewTime: number | undefined
) {
const recommendationScore = getContractScore(contract, wordScores)
const activityScore = getActivityScore(contract, viewTime)
// const lastViewedScore = getLastViewedScore(viewTime)
return recommendationScore * activityScore
}
function getActivityScore(contract: Contract, viewTime: number | undefined) {
const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract
const hasNewComments =
lastCommentTime && (!viewTime || lastCommentTime > viewTime)
const newCommentScore = hasNewComments ? 1 : 0.5
const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime)
const commentDaysAgo = timeSinceLastComment / DAY_MS
const commentTimeScore =
0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo))
const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime)
const betDaysAgo = timeSinceLastBet / DAY_MS
const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo))
let prob = 0.5
if (outcomeType === 'BINARY') {
prob = getProbability(contract)
} else if (outcomeType === 'FREE_RESPONSE') {
const topAnswer = getTopAnswer(contract)
if (topAnswer)
prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id))
}
const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25
const probScore = 0.5 + frac * 0.5
const { volume24Hours, volume7Days } = contract
const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1)
const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume)
const score =
newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore
// Map score to [0.5, 1] since no recent activty is not a deal breaker.
const mappedScore = 0.5 + 0.5 * score
const newMappedScore = 0.7 + 0.3 * score
const isNew = Date.now() < contract.createdTime + DAY_MS
return isNew ? newMappedScore : mappedScore
}
// function getLastViewedScore(viewTime: number | undefined) {
// if (viewTime === undefined) {
// return 1
// }
// const daysAgo = (Date.now() - viewTime) / DAY_MS
// if (daysAgo < 0.5) {
// const frac = logInterpolation(0, 0.5, daysAgo)
// return 0.5 + 0.25 * frac
// }
// const frac = logInterpolation(0.5, 14, daysAgo)
// return 0.75 + 0.25 * frac
// }

View File

@ -1,70 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { ClickEvent } from '../../common/tracking'
import { getWordScores } from '../../common/recommended-contracts'
import { batchedWaitAll } from '../../common/util/promise'
import { callCloudFunction } from './call-cloud-function'
const firestore = admin.firestore()
export const updateRecommendations = functions.pubsub
.schedule('every 24 hours')
.onRun(async () => {
const users = await getValues<User>(firestore.collection('users'))
const batchSize = 100
const userBatches: User[][] = []
for (let i = 0; i < users.length; i += batchSize) {
userBatches.push(users.slice(i, i + batchSize))
}
await Promise.all(
userBatches.map((batch) =>
callCloudFunction('updateRecommendationsBatch', { users: batch })
)
)
})
export const updateRecommendationsBatch = functions.https.onCall(
async (data: { users: User[] }) => {
const { users } = data
const contracts = await getValues<Contract>(
firestore.collection('contracts')
)
await batchedWaitAll(
users.map((user) => () => updateWordScores(user, contracts))
)
}
)
export const updateWordScores = async (user: User, contracts: Contract[]) => {
const [bets, viewCounts, clicks] = await Promise.all([
getValues<Bet>(
firestore.collectionGroup('bets').where('userId', '==', user.id)
),
getValue<{ [contractId: string]: number }>(
firestore.doc(`private-users/${user.id}/cache/viewCounts`)
),
getValues<ClickEvent>(
firestore
.collection(`private-users/${user.id}/events`)
.where('type', '==', 'click')
),
])
const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets)
const cachedCollection = firestore.collection(
`private-users/${user.id}/cache`
)
await cachedCollection.doc('wordScores').set(wordScores)
}

View File

@ -1,138 +1,138 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
import { subtractObjects } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { noFees } from '../../common/fees'
import { APIError } from './api'
import { redeemShares } from './redeem-shares'
export const withdrawLiquidity = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
contractId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const { contractId } = data
if (!contractId)
return { status: 'error', message: 'Missing contract id' }
return await firestore
.runTransaction(async (trans) => {
const lpDoc = firestore.doc(`users/${userId}`)
const lpSnap = await trans.get(lpDoc)
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
const lp = lpSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists)
throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as CPMMContract
const liquidityCollection = firestore.collection(
`contracts/${contractId}/liquidity`
)
const liquiditiesSnap = await trans.get(liquidityCollection)
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const userShares = getUserLiquidityShares(
userId,
contract,
liquidities
)
// zero all added amounts for now
// can add support for partial withdrawals in the future
liquiditiesSnap.docs
.filter(
(_, i) =>
!liquidities[i].isAnte && liquidities[i].userId === userId
)
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
const payout = Math.min(...Object.values(userShares))
if (payout <= 0) return {}
const newBalance = lp.balance + payout
const newTotalDeposits = lp.totalDeposits + payout
trans.update(lpDoc, {
balance: newBalance,
totalDeposits: newTotalDeposits,
} as Partial<User>)
const newPool = subtractObjects(contract.pool, userShares)
const minPoolShares = Math.min(...Object.values(newPool))
const adjustedTotal = contract.totalLiquidity - payout
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
trans.update(contractDoc, {
pool: newPool,
totalLiquidity: newTotalLiquidity,
})
const prob = getProbability(contract)
// surplus shares become user's bets
const bets = Object.entries(userShares)
.map(([outcome, shares]) =>
shares - payout < 1 // don't create bet if less than 1 share
? undefined
: ({
userId: userId,
contractId: contract.id,
amount:
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
shares: shares - payout,
outcome,
probBefore: prob,
probAfter: prob,
createdTime: Date.now(),
isLiquidityProvision: true,
fees: noFees,
} as Omit<Bet, 'id'>)
)
.filter((x) => x !== undefined)
for (const bet of bets) {
const doc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
trans.create(doc, { id: doc.id, ...bet })
}
return userShares
})
.then(async (result) => {
// redeem surplus bet with pre-existing bets
await redeemShares(userId, contractId)
console.log('userid', userId, 'withdraws', result)
return { status: 'success', userShares: result }
})
.catch((e) => {
return { status: 'error', message: e.message }
})
}
)
const firestore = admin.firestore()
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
import { subtractObjects } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { noFees } from '../../common/fees'
import { APIError } from './api'
import { redeemShares } from './redeem-shares'
export const withdrawLiquidity = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
contractId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const { contractId } = data
if (!contractId)
return { status: 'error', message: 'Missing contract id' }
return await firestore
.runTransaction(async (trans) => {
const lpDoc = firestore.doc(`users/${userId}`)
const lpSnap = await trans.get(lpDoc)
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
const lp = lpSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists)
throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as CPMMContract
const liquidityCollection = firestore.collection(
`contracts/${contractId}/liquidity`
)
const liquiditiesSnap = await trans.get(liquidityCollection)
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const userShares = getUserLiquidityShares(
userId,
contract,
liquidities
)
// zero all added amounts for now
// can add support for partial withdrawals in the future
liquiditiesSnap.docs
.filter(
(_, i) =>
!liquidities[i].isAnte && liquidities[i].userId === userId
)
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
const payout = Math.min(...Object.values(userShares))
if (payout <= 0) return {}
const newBalance = lp.balance + payout
const newTotalDeposits = lp.totalDeposits + payout
trans.update(lpDoc, {
balance: newBalance,
totalDeposits: newTotalDeposits,
} as Partial<User>)
const newPool = subtractObjects(contract.pool, userShares)
const minPoolShares = Math.min(...Object.values(newPool))
const adjustedTotal = contract.totalLiquidity - payout
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
trans.update(contractDoc, {
pool: newPool,
totalLiquidity: newTotalLiquidity,
})
const prob = getProbability(contract)
// surplus shares become user's bets
const bets = Object.entries(userShares)
.map(([outcome, shares]) =>
shares - payout < 1 // don't create bet if less than 1 share
? undefined
: ({
userId: userId,
contractId: contract.id,
amount:
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
shares: shares - payout,
outcome,
probBefore: prob,
probAfter: prob,
createdTime: Date.now(),
isLiquidityProvision: true,
fees: noFees,
} as Omit<Bet, 'id'>)
)
.filter((x) => x !== undefined)
for (const bet of bets) {
const doc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
trans.create(doc, { id: doc.id, ...bet })
}
return userShares
})
.then(async (result) => {
// redeem surplus bet with pre-existing bets
await redeemShares(userId, contractId)
console.log('userid', userId, 'withdraws', result)
return { status: 'success', userShares: result }
})
.catch((e) => {
return { status: 'error', message: e.message }
})
}
)
const firestore = admin.firestore()

View File

@ -19,6 +19,7 @@ module.exports = {
],
'@next/next/no-img-element': 'off',
'@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
},
env: {

View File

@ -1,10 +1,10 @@
import clsx from 'clsx'
import { sum, mapValues } from 'lodash'
import { sum } from 'lodash'
import { useState } from 'react'
import { Contract, FreeResponse } from 'common/contract'
import { Col } from '../layout/col'
import { resolveMarket } from 'web/lib/firebase/fn-call'
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
import { Row } from '../layout/row'
import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button'
@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(true)
const totalProb = sum(Object.values(chosenAnswers))
const normalizedProbs = mapValues(
chosenAnswers,
(prob) => (100 * prob) / totalProb
)
const resolutions = Object.entries(chosenAnswers).map(([i, p]) => {
return { answer: parseInt(i), pct: (100 * p) / totalProb }
})
const resolutionProps = removeUndefinedProps({
outcome:
resolveOption === 'CHOOSE'
? answers[0]
? parseInt(answers[0])
: resolveOption === 'CHOOSE_MULTIPLE'
? 'MKT'
: 'CANCEL',
resolutions:
resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined,
resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined,
contractId: contract.id,
})
const result = await resolveMarket(resolutionProps).then((r) => r.data)
console.log('resolved', resolutionProps, 'result:', result)
if (result?.status !== 'success') {
setError(result?.message || 'Error resolving market')
try {
const result = await resolveMarket(resolutionProps)
console.log('resolved', resolutionProps, 'result:', result)
} catch (e) {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error resolving market')
}
}
setResolveOption(undefined)
setIsSubmitting(false)
}

View File

@ -58,6 +58,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
setText('')
setBetAmount(10)
setAmountError(undefined)
setPossibleDuplicateAnswer(undefined)
} else setAmountError(result.message)
}
}

View File

@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react'
import { partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { BinaryContract, CPMMBinaryContract } from 'common/contract'
import {
BinaryContract,
CPMMBinaryContract,
PseudoNumericContract,
} from 'common/contract'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call'
import { sellShares } from 'web/lib/firebase/api-call'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label'
import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label'
import {
calculatePayoutAfterCorrectBet,
calculateShares,
@ -35,6 +39,7 @@ import {
getCpmmProbability,
getCpmmLiquidityFee,
} from 'common/calculate-cpmm'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row'
import { useSaveShares } from './use-save-shares'
import { SignUpPrompt } from './sign-up-prompt'
@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device'
import { track } from 'web/lib/service/analytics'
export function BetPanel(props: {
contract: BinaryContract
contract: BinaryContract | PseudoNumericContract
className?: string
}) {
const { contract, className } = props
@ -81,7 +86,7 @@ export function BetPanel(props: {
}
export function BetPanelSwitcher(props: {
contract: BinaryContract
contract: BinaryContract | PseudoNumericContract
className?: string
title?: string // Set if BetPanel is on a feed modal
selected?: 'YES' | 'NO'
@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: {
}) {
const { contract, className, title, selected, onBetSuccess } = props
const { mechanism } = contract
const { mechanism, outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: {
<Row className="items-center justify-between gap-2">
<div>
You have {formatWithCommas(floorShares)}{' '}
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
{isPseudoNumeric ? (
<PseudoNumericOutcomeLabel outcome={sharesOutcome} />
) : (
<BinaryOutcomeLabel outcome={sharesOutcome} />
)}{' '}
shares
</div>
{tradeType === 'BUY' && (
@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: {
}
function BuyPanel(props: {
contract: BinaryContract
contract: BinaryContract | PseudoNumericContract
user: User | null | undefined
selected?: 'YES' | 'NO'
onBuySuccess?: () => void
}) {
const { contract, user, selected, onBuySuccess } = props
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
@ -302,6 +314,9 @@ function BuyPanel(props: {
: 0)
)} ${betChoice ?? 'YES'} shares`
: undefined
const format = getFormattedMappedValue(contract)
return (
<>
<YesNoSelector
@ -309,6 +324,7 @@ function BuyPanel(props: {
btnClassName="flex-1"
selected={betChoice}
onSelect={(choice) => onBetChoice(choice)}
isPseudoNumeric={isPseudoNumeric}
/>
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
<BuyAmountInput
@ -323,11 +339,13 @@ function BuyPanel(props: {
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div>
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
<div>
{formatPercent(initialProb)}
{format(initialProb)}
<span className="mx-2"></span>
{formatPercent(resultProb)}
{format(resultProb)}
</div>
</Row>
@ -340,6 +358,8 @@ function BuyPanel(props: {
<br /> payout if{' '}
<BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
</>
) : isPseudoNumeric ? (
'Max payout'
) : (
<>
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
@ -389,7 +409,7 @@ function BuyPanel(props: {
}
export function SellPanel(props: {
contract: CPMMBinaryContract
contract: CPMMBinaryContract | PseudoNumericContract
userBets: Bet[]
shares: number
sharesOutcome: 'YES' | 'NO'
@ -488,6 +508,10 @@ export function SellPanel(props: {
}
}
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const format = getFormattedMappedValue(contract)
return (
<>
<AmountInput
@ -511,11 +535,13 @@ export function SellPanel(props: {
<span className="text-neutral">{formatMoney(saleValue)}</span>
</Row>
<Row className="items-center justify-between">
<div className="text-gray-500">Probability</div>
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
<div>
{formatPercent(initialProb)}
{format(initialProb)}
<span className="mx-2"></span>
{formatPercent(resultProb)}
{format(resultProb)}
</div>
</Row>
</Col>

View File

@ -3,7 +3,7 @@ import clsx from 'clsx'
import { BetPanelSwitcher } from './bet-panel'
import { YesNoSelector } from './yes-no-selector'
import { BinaryContract } from 'common/contract'
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { Modal } from './layout/modal'
import { SellButton } from './sell-button'
import { useUser } from 'web/hooks/use-user'
@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares'
// Inline version of a bet panel. Opens BetPanel in a new modal.
export default function BetRow(props: {
contract: BinaryContract
contract: BinaryContract | PseudoNumericContract
className?: string
btnClassName?: string
betPanelClassName?: string
@ -32,6 +32,7 @@ export default function BetRow(props: {
return (
<>
<YesNoSelector
isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'}
className={clsx('justify-end', className)}
btnClassName={clsx('btn-sm w-24', btnClassName)}
onSelect={(choice) => {

View File

@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets'
import { Bet } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users'
import {
formatLargeNumber,
formatMoney,
formatPercent,
formatWithCommas,
@ -40,6 +41,7 @@ import {
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { SellSharesModal } from './sell-modal'
@ -366,6 +368,7 @@ export function BetsSummary(props: {
const { contract, isYourBets, className } = props
const { resolution, closeTime, outcomeType, mechanism } = contract
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isCpmm = mechanism === 'cpmm-1'
const isClosed = closeTime && Date.now() > closeTime
@ -427,6 +430,25 @@ export function BetsSummary(props: {
</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">
@ -507,13 +529,15 @@ export function ContractBetsTable(props: {
const { isResolved, mechanism, outcomeType } = contract
const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
return (
<div className={clsx('overflow-x-auto', className)}>
{amountRedeemed > 0 && (
<>
<div className="pl-2 text-sm text-gray-500">
{amountRedeemed} YES shares and {amountRedeemed} NO shares
{amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '}
{amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares
automatically redeemed for {formatMoney(amountRedeemed)}.
</div>
<Spacer h={4} />
@ -541,7 +565,7 @@ export function ContractBetsTable(props: {
)}
{!isCPMM && !isResolved && <th>Payout if chosen</th>}
<th>Shares</th>
<th>Probability</th>
{!isPseudoNumeric && <th>Probability</th>}
<th>Date</th>
</tr>
</thead>
@ -585,6 +609,7 @@ function BetRow(props: {
const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const saleAmount = saleBet?.sale?.amount
@ -628,14 +653,18 @@ function BetRow(props: {
truncate="short"
/>
)}
{isPseudoNumeric &&
' than ' + formatNumericProbability(bet.probAfter, contract)}
</td>
<td>{formatMoney(Math.abs(amount))}</td>
{!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
<td>{formatWithCommas(Math.abs(shares))}</td>
<td>
{formatPercent(probBefore)} {formatPercent(probAfter)}
</td>
{!isPseudoNumeric && (
<td>
{formatPercent(probBefore)} {formatPercent(probAfter)}
</td>
)}
<td>{dayjs(createdTime).format('MMM D, h:mma')}</td>
</tr>
)

View File

@ -9,7 +9,7 @@ import {
useSortBy,
} from 'react-instantsearch-hooks-web'
import { Contract } from '../../common/contract'
import { Contract } from 'common/contract'
import {
Sort,
useInitialQueryAndSort,
@ -58,15 +58,24 @@ export function ContractSearch(props: {
additionalFilter?: {
creatorId?: string
tag?: string
excludeContractIds?: string[]
}
showCategorySelector: boolean
onContractClick?: (contract: Contract) => void
showPlaceHolder?: boolean
hideOrderSelector?: boolean
overrideGridClassName?: string
hideQuickBet?: boolean
}) {
const {
querySortOptions,
additionalFilter,
showCategorySelector,
onContractClick,
overrideGridClassName,
hideOrderSelector,
showPlaceHolder,
hideQuickBet,
} = props
const user = useUser()
@ -122,7 +131,7 @@ export function ContractSearch(props: {
const indexName = `${indexPrefix}contracts-${sort}`
if (IS_PRIVATE_MANIFOLD) {
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return (
<ContractSearchFirestore
querySortOptions={querySortOptions}
@ -136,6 +145,7 @@ export function ContractSearch(props: {
<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]',
@ -153,13 +163,15 @@ export function ContractSearch(props: {
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort')}
/>
{!hideOrderSelector && (
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort')}
/>
)}
<Configure
facetFilters={filters}
numericFilters={numericFilters}
@ -187,6 +199,9 @@ export function ContractSearch(props: {
<ContractSearchInner
querySortOptions={querySortOptions}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
excludeContractIds={additionalFilter?.excludeContractIds}
/>
)}
</InstantSearch>
@ -199,8 +214,17 @@ export function ContractSearchInner(props: {
shouldLoadFromStorage?: boolean
}
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
hideQuickBet?: boolean
excludeContractIds?: string[]
}) {
const { querySortOptions, onContractClick } = props
const {
querySortOptions,
onContractClick,
overrideGridClassName,
hideQuickBet,
excludeContractIds,
} = props
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
const { query, setQuery, setSort } = useUpdateQueryAndSort({
@ -239,7 +263,7 @@ export function ContractSearchInner(props: {
}, [])
const { showMore, hits, isLastPage } = useInfiniteHits()
const contracts = hits as any as Contract[]
let contracts = hits as any as Contract[]
if (isInitialLoad && contracts.length === 0) return <></>
@ -249,6 +273,9 @@ export function ContractSearchInner(props: {
? 'resolve-date'
: undefined
if (excludeContractIds)
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
return (
<ContractsGrid
contracts={contracts}
@ -256,6 +283,8 @@ export function ContractSearchInner(props: {
hasMore={!isLastPage}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
/>
)
}

View File

@ -9,6 +9,7 @@ import {
BinaryContract,
FreeResponseContract,
NumericContract,
PseudoNumericContract,
} from 'common/contract'
import {
AnswerLabel,
@ -16,7 +17,11 @@ import {
CancelLabel,
FreeResponseOutcomeLabel,
} from '../outcome-label'
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
import {
getOutcomeProbability,
getProbability,
getTopAnswer,
} from 'common/calculate'
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { QuickBet, ProbBar, getColor } from './quick-bet'
@ -24,6 +29,7 @@ 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'
export function ContractCard(props: {
contract: Contract
@ -131,6 +137,13 @@ export function ContractCard(props: {
/>
)}
{outcomeType === 'PSEUDO_NUMERIC' && (
<PseudoNumericResolutionOrExpectation
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
className="items-center"
@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: {
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div className="text-blue-400">{resolutionValue}</div>
<div className="text-blue-400">
{formatLargeNumber(resolutionValue)}
</div>
)}
</>
) : (
@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: {
</Col>
)
}
export function PseudoNumericResolutionOrExpectation(props: {
contract: PseudoNumericContract
className?: string
}) {
const { contract, className } = props
const { resolution, resolutionValue, resolutionProbability } = contract
const textColor = `text-blue-400`
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
<>
<div className={clsx('text-base text-gray-500')}>Resolved</div>
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div className="text-blue-400">
{resolutionValue
? formatLargeNumber(resolutionValue)
: formatNumericProbability(
resolutionProbability ?? 0,
contract
)}
</div>
)}
</>
) : (
<>
<div className={clsx('text-3xl', textColor)}>
{formatNumericProbability(getProbability(contract), contract)}
</div>
<div className={clsx('text-base', textColor)}>expected</div>
</>
)}
</Col>
)
}

View File

@ -29,6 +29,8 @@ 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'
export type ShowTime = 'resolve-date' | 'close-date'
@ -128,8 +130,32 @@ export function ContractDetails(props: {
const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId } = contract
const { volumeLabel, resolvedDate } = contractMetrics(contract)
// Find a group that this contract id is in
const groups = useGroupsWithContract(contract.id)
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
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">
@ -150,14 +176,15 @@ export function ContractDetails(props: {
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row>
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/}
{groups && groups.length > 0 && (
{groupToDisplay ? (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
<SiteLink href={`${groupPath(groups[0].slug)}`}>
<SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groups[0].name}</span>
<span>{groupToDisplay.name}</span>
</SiteLink>
</Row>
) : (
<div />
)}
{(!!closeTime || !!resolvedDate) && (
@ -192,6 +219,11 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
<ShareIconButton
contract={contract}
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
username={user?.username}
/>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</Row>

View File

@ -13,7 +13,6 @@ import {
getBinaryProbPercent,
} from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel'
import { CopyLinkButton } from '../copy-link-button'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
@ -22,6 +21,10 @@ 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'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
@ -48,13 +51,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
return (
<>
<button
className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"
className={contractDetailsButtonClassName}
onClick={() => setOpen(true)}
>
<DotsHorizontalIcon
className={clsx(
'h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500'
)}
className={clsx('h-6 w-6 flex-shrink-0')}
aria-hidden="true"
/>
</button>
@ -66,15 +67,12 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<div>Share</div>
<Row className="justify-start gap-4">
<CopyLinkButton
contract={contract}
toastClassName={'sm:-left-10 -left-4 min-w-[250%]'}
/>
<TweetButton
className="self-start"
tweetText={getTweetText(contract, false)}
/>
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
<DuplicateContractButton contract={contract} />
</Row>
<div />

View File

@ -11,6 +11,7 @@ import {
FreeResponseResolutionOrChance,
BinaryResolutionOrChance,
NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation,
} from './contract-card'
import { Bet } from 'common/bet'
import BetRow from '../bet-row'
@ -32,6 +33,7 @@ export const ContractOverview = (props: {
const user = useUser()
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
return (
<Col className={clsx('mb-6', className)}>
@ -49,6 +51,13 @@ export const ContractOverview = (props: {
/>
)}
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract}
@ -61,6 +70,11 @@ export const ContractOverview = (props: {
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />}
</Row>
) : (
@ -86,7 +100,9 @@ export const ContractOverview = (props: {
/>
</Col>
<Spacer h={4} />
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '}
{(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} />
)}{' '}
{outcomeType === 'FREE_RESPONSE' && (
<AnswersGraph contract={contract} bets={bets} />
)}

View File

@ -5,16 +5,20 @@ import dayjs from 'dayjs'
import { memo } from 'react'
import { Bet } from 'common/bet'
import { getInitialProbability } from 'common/calculate'
import { BinaryContract } from 'common/contract'
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: {
contract: BinaryContract
contract: BinaryContract | PseudoNumericContract
bets: Bet[]
height?: number
}) {
const { contract, height } = props
const { resolutionTime, closeTime } = contract
const { resolutionTime, closeTime, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
contract.createdTime,
...bets.map((bet) => bet.createdTime),
].map((time) => new Date(time))
const probs = [startProb, ...bets.map((bet) => bet.probAfter)]
const f = getMappedValue(contract)
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
const isClosed = !!closeTime && Date.now() > closeTime
const latestTime = dayjs(
@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
times.push(latestTime.toDate())
probs.push(probs[probs.length - 1])
const yTickValues = [0, 25, 50, 75, 100]
const quartiles = [0, 25, 50, 75, 100]
const yTickValues = isBinary
? quartiles
: quartiles.map((x) => x / 100).map(f)
const { width } = useWindowSize()
@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const totalPoints = width ? (width > 800 ? 300 : 50) : 1
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
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: probs[i] * 100 }
points[points.length] = { x: times[i], y: s * probs[i] + c }
const numPoints: number = Math.floor(
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
)
@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
x: dayjs(times[i])
.add(thisTimeStep * n, 'ms')
.toDate(),
y: probs[i] * 100,
y: s * probs[i] + c,
}
}
}
}
const data = [{ id: 'Yes', data: points, color: '#11b981' }]
const data = [
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
]
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime)
const formatter = isBinary
? formatPercent
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
return (
<div
className="w-full overflow-visible"
@ -87,12 +108,20 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: 100, type: 'linear' }}
yFormat={formatPercent}
yScale={
isBinary
? { min: 0, max: 100, type: 'linear' }
: {
min: contract.min + c,
max: contract.max + c,
type: contract.isLogScale ? 'log' : 'linear',
}
}
yFormat={formatter}
gridYValues={yTickValues}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
format: formatter,
}}
xScale={{
type: 'time',

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import {
getOutcomeProbability,
getOutcomeProbabilityAfterBet,
getProbability,
getTopAnswer,
} from 'common/calculate'
import { getExpectedValue } from 'common/calculate-dpm'
@ -25,18 +26,18 @@ import { useSaveShares } from '../use-save-shares'
import { sellShares } from 'web/lib/firebase/api-call'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
const BET_SIZE = 10
export function QuickBet(props: { contract: Contract; user: User }) {
const { contract, user } = props
const isCpmm = contract.mechanism === 'cpmm-1'
const { mechanism, outcomeType } = contract
const isCpmm = mechanism === 'cpmm-1'
const userBets = useUserContractBets(user.id, contract.id)
const topAnswer =
contract.outcomeType === 'FREE_RESPONSE'
? getTopAnswer(contract)
: undefined
outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined
// TODO: yes/no from useSaveShares doesn't work on numeric contracts
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) {
topAnswer?.number.toString() || undefined
)
const hasUpShares =
yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC')
yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC')
const hasDownShares =
noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC'
noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC'
const [upHover, setUpHover] = useState(false)
const [downHover, setDownHover] = useState(false)
@ -130,25 +131,6 @@ export function QuickBet(props: { contract: Contract; user: User }) {
})
}
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
if (contract.outcomeType === 'BINARY') {
return direction === 'UP' ? 'YES' : 'NO'
}
if (contract.outcomeType === 'FREE_RESPONSE') {
// TODO: Implement shorting of free response answers
if (direction === 'DOWN') {
throw new Error("Can't bet against free response answers")
}
return getTopAnswer(contract)?.id
}
if (contract.outcomeType === 'NUMERIC') {
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
throw new Error("Can't quick bet on numeric markets")
}
}
const textColor = `text-${getColor(contract)}`
return (
<Col
className={clsx(
@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) {
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? textColor : 'text-gray-400'
upHover ? 'text-green-500' : 'text-gray-400'
)}
/>
) : (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? textColor : 'text-gray-200'
upHover ? 'text-green-500' : 'text-gray-200'
)}
/>
)}
@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) {
<QuickOutcomeView contract={contract} previewProb={previewProb} />
{/* Down bet triangle */}
{contract.outcomeType !== 'BINARY' ? (
{outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
<div>
<div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div>
<TriangleDownFillIcon
@ -254,6 +236,25 @@ export function ProbBar(props: { contract: Contract; previewProb?: number }) {
)
}
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
const { outcomeType } = contract
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
return direction === 'UP' ? 'YES' : 'NO'
}
if (outcomeType === 'FREE_RESPONSE') {
// TODO: Implement shorting of free response answers
if (direction === 'DOWN') {
throw new Error("Can't bet against free response answers")
}
return getTopAnswer(contract)?.id
}
if (outcomeType === 'NUMERIC') {
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
throw new Error("Can't quick bet on numeric markets")
}
}
function QuickOutcomeView(props: {
contract: Contract
previewProb?: number
@ -261,9 +262,16 @@ function QuickOutcomeView(props: {
}) {
const { contract, previewProb, caption } = props
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
// If there's a preview prob, display that instead of the current prob
const override =
previewProb === undefined ? undefined : formatPercent(previewProb)
previewProb === undefined
? undefined
: isPseudoNumeric
? formatNumericProbability(previewProb, contract)
: formatPercent(previewProb)
const textColor = `text-${getColor(contract)}`
let display: string | undefined
@ -271,6 +279,9 @@ function QuickOutcomeView(props: {
case 'BINARY':
display = getBinaryProbPercent(contract)
break
case 'PSEUDO_NUMERIC':
display = formatNumericProbability(getProbability(contract), contract)
break
case 'NUMERIC':
display = formatLargeNumber(getExpectedValue(contract))
break
@ -295,11 +306,15 @@ function QuickOutcomeView(props: {
// Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) {
const { outcomeType, resolution } = contract
return resolution
const { outcomeType, resolution, resolutionProbability } = contract
return resolutionProbability
? resolutionProbability
: resolution
? 1
: outcomeType === 'BINARY'
? getBinaryProb(contract)
: outcomeType === 'PSEUDO_NUMERIC'
? getProbability(contract)
: outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC'
@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) {
export function getColor(contract: Contract) {
// TODO: Try injecting a gradient here
// return 'primary'
const { resolution } = contract
const { resolution, outcomeType } = contract
if (resolution) {
return (
OUTCOME_TO_COLOR[resolution as resolution] ??
@ -325,6 +341,8 @@ export function getColor(contract: Contract) {
)
}
if (outcomeType === 'PSEUDO_NUMERIC') return 'blue-400'
if ((contract.closeTime ?? Infinity) < Date.now()) {
return 'gray-400'
}

View File

@ -0,0 +1,54 @@
import { DuplicateIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { getMappedValue } from 'common/pseudo-numeric'
import { trackCallback } from 'web/lib/service/analytics'
export function DuplicateContractButton(props: {
contract: Contract
className?: string
}) {
const { contract, className } = props
return (
<a
className={clsx('btn btn-xs flex-nowrap normal-case', className)}
style={{
backgroundColor: 'white',
border: '2px solid #a78bfa',
// violet-400
color: '#a78bfa',
}}
href={duplicateContractHref(contract)}
onClick={trackCallback('duplicate market')}
target="_blank"
>
<DuplicateIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
<div>Duplicate</div>
</a>
)
}
// Pass along the Uri to create a new contract
function duplicateContractHref(contract: Contract) {
const params = {
q: contract.question,
closeTime: contract.closeTime || 0,
description: contract.description,
outcomeType: contract.outcomeType,
} as Record<string, any>
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
params.min = contract.min
params.max = contract.max
params.isLogScale = contract.isLogScale
params.initValue = getMappedValue(contract)(contract.initialProbability)
}
return (
`/create?` +
Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&')
)
}

View File

@ -7,13 +7,14 @@ import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx'
import { UsersIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { Fragment } from 'react'
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
import { JoinSpans } from 'web/components/join-spans'
import { UserLink } from '../user-page'
import { formatNumericProbability } from 'common/pseudo-numeric'
export function FeedBet(props: {
contract: Contract
@ -75,6 +76,8 @@ export function BetStatusText(props: {
hideOutcome?: boolean
}) {
const { bet, contract, bettor, isSelf, hideOutcome } = props
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const { amount, outcome, createdTime } = bet
const bought = amount >= 0 ? 'bought' : 'sold'
@ -97,7 +100,10 @@ export function BetStatusText(props: {
value={(bet as any).value}
contract={contract}
truncate="short"
/>
/>{' '}
{isPseudoNumeric
? ' than ' + formatNumericProbability(bet.probAfter, contract)
: ' at ' + formatPercent(bet.probAfter)}
</>
)}
<RelativeTimestamp time={createdTime} />

View File

@ -1,4 +1,4 @@
import { UserIcon } from '@heroicons/react/outline'
import { UserIcon, XIcon } from '@heroicons/react/outline'
import { useUsers } from 'web/hooks/use-users'
import { User } from 'common/user'
import { Fragment, useMemo, useState } from 'react'
@ -6,13 +6,24 @@ import clsx from 'clsx'
import { Menu, Transition } from '@headlessui/react'
import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
export function FilterSelectUsers(props: {
setSelectedUsers: (users: User[]) => void
selectedUsers: User[]
ignoreUserIds: string[]
showSelectedUsersTitle?: boolean
selectedUsersClassName?: string
maxUsers?: number
}) {
const { ignoreUserIds, selectedUsers, setSelectedUsers } = props
const {
ignoreUserIds,
selectedUsers,
setSelectedUsers,
showSelectedUsersTitle,
selectedUsersClassName,
maxUsers,
} = props
const users = useUsers()
const [query, setQuery] = useState('')
const [filteredUsers, setFilteredUsers] = useState<User[]>([])
@ -24,94 +35,124 @@ export function FilterSelectUsers(props: {
return (
!selectedUsers.map((user) => user.name).includes(user.name) &&
!ignoreUserIds.includes(user.id) &&
user.name.toLowerCase().includes(query.toLowerCase())
(user.name.toLowerCase().includes(query.toLowerCase()) ||
user.username.toLowerCase().includes(query.toLowerCase()))
)
})
)
}, [beginQuerying, users, selectedUsers, ignoreUserIds, query])
const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true
return (
<div>
<div className="relative mt-1 rounded-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="user name"
id="user name"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
placeholder="Austin Chen"
/>
</div>
<Menu
as="div"
className={clsx(
'relative inline-block w-full overflow-y-scroll text-right',
beginQuerying && 'h-36'
)}
>
{({}) => (
<Transition
show={beginQuerying}
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"
{shouldShow && (
<>
<div className="relative mt-1 rounded-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="user name"
id="user name"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
placeholder="Austin Chen"
/>
</div>
<Menu
as="div"
className={clsx(
'relative inline-block w-full overflow-y-scroll text-right',
beginQuerying && 'h-36'
)}
>
<Menu.Items
static={true}
className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
{filteredUsers.map((user: User) => (
<Menu.Item key={user.id}>
{({ active }) => (
<span
className={clsx(
active
? 'bg-gray-100 text-gray-900'
: 'text-gray-700',
'group flex items-center px-4 py-2 text-sm'
{({}) => (
<Transition
show={beginQuerying}
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
static={true}
className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
{filteredUsers.map((user: User) => (
<Menu.Item key={user.id}>
{({ active }) => (
<span
className={clsx(
active
? 'bg-gray-100 text-gray-900'
: 'text-gray-700',
'group flex items-center px-4 py-2 text-sm'
)}
onClick={() => {
setQuery('')
setSelectedUsers([...selectedUsers, user])
}}
>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={'xs'}
className={'mr-2'}
/>
{user.name}
</span>
)}
onClick={() => {
setQuery('')
setSelectedUsers([...selectedUsers, user])
}}
>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={'xs'}
className={'mr-2'}
/>
{user.name}
</span>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
)}
</Menu>
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
)}
</Menu>
</>
)}
{selectedUsers.length > 0 && (
<>
<div className={'mb-2'}>Added members:</div>
<Row className="mt-0 grid grid-cols-6 gap-2">
<div className={'mb-2'}>
{showSelectedUsersTitle && 'Added members:'}
</div>
<Row
className={clsx(
'mt-0 grid grid-cols-6 gap-2',
selectedUsersClassName
)}
>
{selectedUsers.map((user: User) => (
<div key={user.id} className="col-span-2 flex items-center">
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={'sm'}
<div
key={user.id}
className="col-span-2 flex flex-row items-center justify-between"
>
<Row className={'items-center'}>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={'sm'}
/>
<UserLink
username={user.username}
className="ml-2"
name={user.name}
/>
</Row>
<XIcon
onClick={() =>
setSelectedUsers([
...selectedUsers.filter((u) => u.id != user.id),
])
}
className=" h-5 w-5 cursor-pointer text-gray-400"
aria-hidden="true"
/>
<span className="ml-2">{user.name}</span>
</div>
))}
</Row>

View File

@ -9,6 +9,7 @@ import { useRouter } from 'next/router'
import { Modal } from 'web/components/layout/modal'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user'
import { uniq } from 'lodash'
export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props
@ -35,7 +36,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
await updateGroup(group, {
name,
about,
memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)],
memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
})
setIsSubmitting(false)
@ -46,7 +47,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<div className={clsx('flex p-1', className)}>
<div
className={clsx(
'btn-ghost cursor-pointer whitespace-nowrap rounded-full text-sm text-white'
'btn-ghost cursor-pointer whitespace-nowrap rounded-md p-1 text-sm text-gray-700'
)}
onClick={() => updateOpen(!open)}
>

View File

@ -91,6 +91,9 @@ export function GroupChat(props: {
setReplyToUsername('')
inputRef?.focus()
}
function focusInput() {
inputRef?.focus()
}
return (
<Col className={'flex-1'}>
@ -117,7 +120,13 @@ export function GroupChat(props: {
))}
{messages.length === 0 && (
<div className="p-2 text-gray-500">
No messages yet. 🦗... Why not say something?
No messages yet. Why not{' '}
<button
className={'cursor-pointer font-bold text-gray-700'}
onClick={() => focusInput()}
>
add one?
</button>
</div>
)}
</Col>

View File

@ -22,7 +22,7 @@ export function GroupSelector(props: {
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
const [query, setQuery] = useState('')
const memberGroups = useMemberGroups(creator)
const memberGroups = useMemberGroups(creator?.id)
const filteredGroups = memberGroups
? query === ''
? memberGroups

View File

@ -0,0 +1,144 @@
import clsx from 'clsx'
import { User } from 'common/user'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { withTracking } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import { useMemberGroups } from 'web/hooks/use-group'
import { TextButton } from 'web/components/text-button'
import { Group } from 'common/group'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { addUserToGroup, leaveGroup } from 'web/lib/firebase/groups'
import { firebaseLogin } from 'web/lib/firebase/users'
import { GroupLink } from 'web/pages/groups'
export function GroupsButton(props: { user: User }) {
const { user } = props
const [isOpen, setIsOpen] = useState(false)
const groups = useMemberGroups(user.id)
return (
<>
<TextButton onClick={() => setIsOpen(true)}>
<span className="font-semibold">{groups?.length ?? ''}</span> Groups
</TextButton>
<GroupsDialog
user={user}
groups={groups ?? []}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
</>
)
}
function GroupsDialog(props: {
user: User
groups: Group[]
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}) {
const { user, groups, isOpen, setIsOpen } = props
return (
<Modal open={isOpen} setOpen={setIsOpen}>
<Col className="rounded bg-white p-6">
<div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<GroupsList groups={groups} />
</Col>
</Modal>
)
}
function GroupsList(props: { groups: Group[] }) {
const { groups } = props
return (
<Col className="gap-2">
{groups.length === 0 && (
<div className="text-gray-500">No groups yet...</div>
)}
{groups
.sort((group1, group2) => group2.createdTime - group1.createdTime)
.map((group) => (
<GroupItem key={group.id} group={group} />
))}
</Col>
)
}
function GroupItem(props: { group: Group; className?: string }) {
const { group, className } = props
return (
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
<Row className="line-clamp-1 items-center gap-2">
<GroupLink group={group} />
</Row>
<JoinOrLeaveGroupButton group={group} />
</Row>
)
}
export function JoinOrLeaveGroupButton(props: {
group: Group
small?: boolean
className?: string
}) {
const { group, small, className } = props
const currentUser = useUser()
const isFollowing = currentUser
? group.memberIds.includes(currentUser.id)
: false
const onJoinGroup = () => {
if (!currentUser) return
addUserToGroup(group, currentUser.id)
}
const onLeaveGroup = () => {
if (!currentUser) return
leaveGroup(group, currentUser.id)
}
const smallStyle =
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
if (!currentUser || isFollowing === undefined) {
if (!group.anyoneCanJoin)
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
return (
<button
onClick={firebaseLogin}
className={clsx('btn btn-sm', small && smallStyle, className)}
>
Login to Join
</button>
)
}
if (isFollowing) {
return (
<button
className={clsx(
'btn btn-outline btn-sm',
small && smallStyle,
className
)}
onClick={withTracking(onLeaveGroup, 'leave group')}
>
Leave
</button>
)
}
if (!group.anyoneCanJoin)
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
return (
<button
className={clsx('btn btn-sm', small && smallStyle, className)}
onClick={withTracking(onJoinGroup, 'join group')}
>
Join
</button>
)
}

View File

@ -1,13 +1,15 @@
import { Fragment, ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import clsx from 'clsx'
// From https://tailwindui.com/components/application-ui/overlays/modals
export function Modal(props: {
children: ReactNode
open: boolean
setOpen: (open: boolean) => void
className?: string
}) {
const { children, open, setOpen } = props
const { children, open, setOpen, className } = props
return (
<Transition.Root show={open} as={Fragment}>
@ -45,7 +47,12 @@ export function Modal(props: {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle">
<div
className={clsx(
'inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle',
className
)}
>
{children}
</div>
</Transition.Child>

View File

@ -14,16 +14,16 @@ type Tab = {
export function Tabs(props: {
tabs: Tab[]
defaultIndex?: number
className?: string
labelClassName?: string
onClick?: (tabTitle: string, index: number) => void
}) {
const { tabs, defaultIndex, className, onClick } = props
const { tabs, defaultIndex, labelClassName, onClick } = props
const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0)
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
return (
<div>
<div className="border-b border-gray-200">
<>
<div className="mb-4 border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
{tabs.map((tab, i) => (
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
@ -42,7 +42,7 @@ export function Tabs(props: {
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium',
className
labelClassName
)}
aria-current={activeIndex === i ? 'page' : undefined}
>
@ -56,7 +56,7 @@ export function Tabs(props: {
</nav>
</div>
<div className="mt-4">{activeTab?.content}</div>
</div>
{activeTab?.content}
</>
)
}

View File

@ -3,7 +3,6 @@ import Link from 'next/link'
import {
HomeIcon,
MenuAlt3Icon,
PresentationChartLineIcon,
SearchIcon,
XIcon,
} from '@heroicons/react/outline'
@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics'
function getNavigation(username: string) {
function getNavigation() {
return [
{ name: 'Home', href: '/home', icon: HomeIcon },
{
name: 'Portfolio',
href: `/${username}?tab=bets`,
icon: PresentationChartLineIcon,
},
{
name: 'Notifications',
href: `/notifications`,
@ -55,38 +49,40 @@ export function BottomNavBar() {
}
const navigationOptions =
user === null
? signedOutNavigation
: getNavigation(user?.username || 'error')
user === null ? signedOutNavigation : getNavigation()
return (
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
{navigationOptions.map((item) => (
<NavBarItem key={item.name} item={item} currentPage={currentPage} />
))}
{user && (
<NavBarItem
key={'profile'}
currentPage={currentPage}
item={{
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=bets`,
icon: () => (
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
),
}}
/>
)}
<div
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700"
onClick={() => setSidebarOpen(true)}
>
{user === null ? (
<>
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
More
</>
) : user ? (
<>
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
{formatMoney(user.balance)}
</>
) : (
<></>
)}
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
More
</div>
<MobileSidebar
@ -99,6 +95,7 @@ export function BottomNavBar() {
function NavBarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
return (
<Link href={item.href}>
@ -107,9 +104,9 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
currentPage === item.href && 'bg-gray-200 text-indigo-700'
)}
onClick={trackCallback('navbar: ' + item.name)}
onClick={track}
>
<item.icon className="my-1 mx-auto h-6 w-6" />
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
{item.name}
</a>
</Link>

View File

@ -6,8 +6,8 @@ import {
CashIcon,
HeartIcon,
UserGroupIcon,
ChevronDownIcon,
TrendingUpIcon,
ChatIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
import Link from 'next/link'
@ -18,13 +18,16 @@ import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon'
import React from 'react'
import React, { useEffect } from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group } from 'common/group'
import { Spacer } from '../layout/spacer'
import { usePreferredNotifications } from 'web/hooks/use-notifications'
import { setNotificationsAsSeen } from 'web/pages/notifications'
function getNavigation() {
return [
@ -82,8 +85,20 @@ const signedOutNavigation = [
]
const signedOutMobileNavigation = [
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
icon: BookOpenIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
]
const signedInMobileNavigation = [
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
@ -91,17 +106,12 @@ const signedOutMobileNavigation = [
},
]
const signedInMobileNavigation = [
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
...signedOutMobileNavigation,
]
function getMoreMobileNav() {
return [
{ name: 'Send M$', href: '/links' },
{ name: 'Charity', href: '/charity' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Statistics', href: '/stats' },
{ name: 'Leaderboards', href: '/leaderboards' },
{
name: 'Sign out',
href: '#',
@ -112,8 +122,9 @@ function getMoreMobileNav() {
export type Item = {
name: string
trackingEventName?: string
href: string
icon: React.ComponentType<{ className?: string }>
icon?: React.ComponentType<{ className?: string }>
}
function SidebarItem(props: { item: Item; currentPage: string }) {
@ -130,15 +141,17 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
)}
aria-current={item.href == currentPage ? 'page' : undefined}
>
<item.icon
className={clsx(
item.href == currentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
{item.icon && (
<item.icon
className={clsx(
item.href == currentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
</Link>
@ -167,14 +180,6 @@ function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
}
function GroupsButton() {
return (
<SidebarButton icon={UserGroupIcon} text={'Groups'}>
<ChevronDownIcon className=" mt-0.5 ml-2 h-5 w-5" aria-hidden="true" />
</SidebarButton>
)
}
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
@ -185,7 +190,7 @@ export default function Sidebar(props: { className?: string }) {
const mobileNavigationOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({
const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({
name: group.name,
href: groupPath(group.slug),
}))
@ -193,31 +198,20 @@ export default function Sidebar(props: { className?: string }) {
return (
<nav aria-label="Sidebar" className={className}>
<ManifoldLogo className="pb-6" twoLine />
<CreateQuestionButton user={user} />
<Spacer h={4} />
{user && (
<div className="mb-2" style={{ minHeight: 80 }}>
<div className="w-full" style={{ minHeight: 80 }}>
<ProfileSummary user={user} />
</div>
)}
{/* Mobile navigation */}
<div className="space-y-1 lg:hidden">
{user && (
<MenuButton
buttonContent={<GroupsButton />}
menuItems={[{ name: 'Explore', href: '/groups' }, ...memberItems]}
className={'relative z-50 flex-shrink-0'}
/>
)}
{mobileNavigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{!user && (
<SidebarItem
key={'Groups'}
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
currentPage={currentPage}
/>
)}
{user && (
<MenuButton
@ -225,41 +219,83 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />}
/>
)}
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
user={user}
/>
</div>
{/* Desktop navigation */}
<div className="hidden space-y-1 lg:block">
{navigationOptions.map((item) =>
item.name === 'Notifications' ? (
<div key={item.href}>
<SidebarItem item={item} currentPage={currentPage} />
{user && (
<MenuButton
key={'groupsdropdown'}
buttonContent={<GroupsButton />}
menuItems={[
{ name: 'Explore', href: '/groups' },
...memberItems,
]}
className={'relative z-50 flex-shrink-0'}
/>
)}
</div>
) : (
<SidebarItem
key={item.href}
item={item}
currentPage={currentPage}
/>
)
)}
{navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
<MenuButton
menuItems={getMoreNavigation(user)}
buttonContent={<MoreButton />}
/>
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<div className="py-3">
<div className="h-[1px] bg-gray-300" />
</div>
)}
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
user={user}
/>
</div>
<CreateQuestionButton user={user} />
</nav>
)
}
function GroupsList(props: {
currentPage: string
memberItems: Item[]
user: User | null | undefined
}) {
const { currentPage, memberItems, user } = props
const preferredNotifications = usePreferredNotifications(user?.id, {
unseenOnly: true,
customHref: '/group/',
})
// Set notification as seen if our current page is equal to the isSeenOnHref property
useEffect(() => {
preferredNotifications.forEach((notification) => {
if (notification.isSeenOnHref === currentPage) {
setNotificationsAsSeen([notification])
}
})
}, [currentPage, preferredNotifications])
return (
<>
<SidebarItem
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
currentPage={currentPage}
/>
<div className="mt-1 space-y-0.5">
{memberItems.map((item) => (
<a
key={item.href}
href={item.href}
className={clsx(
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900',
preferredNotifications.some(
(n) => !n.isSeen && n.isSeenOnHref === item.href
) && 'font-bold'
)}
>
<span className="truncate">{item.name}</span>
</a>
))}
</div>
</>
)
}

View File

@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router'
import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
import { requestBonuses } from 'web/lib/firebase/api-call'
export default function NotificationsIcon(props: { className?: string }) {
const user = useUser()
const notifications = usePreferredGroupedNotifications(user?.id, {
const privateUser = usePrivateUser(user?.id)
const notifications = usePreferredGroupedNotifications(privateUser?.id, {
unseenOnly: true,
})
const [seen, setSeen] = useState(false)
useEffect(() => {
if (!privateUser) return
if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000)
requestBonuses({}).catch((error) => {
console.log("couldn't get bonuses:", error.message)
})
}, [privateUser])
const router = useRouter()
useEffect(() => {
if (router.pathname.endsWith('notifications')) return setSeen(true)
@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
<div className={'relative'}>
{!seen && notifications && notifications.length > 0 && (
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
{notifications.length}
{notifications.length > NOTIFICATIONS_PER_PAGE
? `${NOTIFICATIONS_PER_PAGE}+`
: notifications.length}
</div>
)}
<BellIcon className={clsx(props.className)} />

View File

@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users'
import { NumberCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button'
import { resolveMarket } from 'web/lib/firebase/fn-call'
import { NumericContract } from 'common/contract'
import { NumericContract, PseudoNumericContract } from 'common/contract'
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
import { BucketInput } from './bucket-input'
import { getPseudoProbability } from 'common/pseudo-numeric'
export function NumericResolutionPanel(props: {
creator: User
contract: NumericContract
contract: NumericContract | PseudoNumericContract
className?: string
}) {
useEffect(() => {
@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: {
}, [])
const { contract, className } = props
const { min, max, outcomeType } = contract
const [outcomeMode, setOutcomeMode] = useState<
'NUMBER' | 'CANCEL' | undefined
@ -32,22 +34,44 @@ export function NumericResolutionPanel(props: {
const [error, setError] = useState<string | undefined>(undefined)
const resolve = async () => {
const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL'
const finalOutcome =
outcomeMode === 'CANCEL'
? 'CANCEL'
: outcomeType === 'PSEUDO_NUMERIC'
? 'MKT'
: 'NUMBER'
if (outcomeMode === undefined || finalOutcome === undefined) return
setIsSubmitting(true)
const result = await resolveMarket({
outcome: finalOutcome,
value,
contractId: contract.id,
}).then((r) => r.data)
const boundedValue = Math.max(Math.min(max, value ?? 0), min)
console.log('resolved', outcome, 'result:', result)
const probabilityInt =
100 *
getPseudoProbability(
boundedValue,
min,
max,
outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
)
if (result?.status !== 'success') {
setError(result?.message || 'Error resolving market')
try {
const result = await resolveMarket({
outcome: finalOutcome,
value,
probabilityInt,
contractId: contract.id,
})
console.log('resolved', outcome, 'result:', result)
} catch (e) {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error resolving market')
}
}
setIsSubmitting(false)
}
@ -72,7 +96,7 @@ export function NumericResolutionPanel(props: {
{outcomeMode === 'NUMBER' && (
<BucketInput
contract={contract}
contract={contract as any}
isSubmitting={isSubmitting}
onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
/>

View File

@ -19,11 +19,15 @@ export function OutcomeLabel(props: {
value?: number
}) {
const { outcome, contract, truncate, value } = props
const { outcomeType } = contract
if (contract.outcomeType === 'BINARY')
if (outcomeType === 'PSEUDO_NUMERIC')
return <PseudoNumericOutcomeLabel outcome={outcome as any} />
if (outcomeType === 'BINARY')
return <BinaryOutcomeLabel outcome={outcome as any} />
if (contract.outcomeType === 'NUMERIC')
if (outcomeType === 'NUMERIC')
return (
<span className="text-blue-500">
{value ?? getValueFromBucket(outcome, contract)}
@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) {
return <CancelLabel />
}
export function PseudoNumericOutcomeLabel(props: { outcome: resolution }) {
const { outcome } = props
if (outcome === 'YES') return <HigherLabel />
if (outcome === 'NO') return <LowerLabel />
if (outcome === 'MKT') return <ProbLabel />
return <CancelLabel />
}
export function BinaryContractOutcomeLabel(props: {
contract: BinaryContract
resolution: resolution
@ -74,7 +87,7 @@ export function FreeResponseOutcomeLabel(props: {
if (resolution === 'CANCEL') return <CancelLabel />
if (resolution === 'MKT') return <MultiLabel />
const chosen = contract.answers.find((answer) => answer.id === resolution)
const chosen = contract.answers?.find((answer) => answer.id === resolution)
if (!chosen) return <AnswerNumberLabel number={resolution} />
return (
<FreeResponseAnswerToolTip text={chosen.text}>
@ -98,6 +111,14 @@ export function YesLabel() {
return <span className="text-primary">YES</span>
}
export function HigherLabel() {
return <span className="text-primary">HIGHER</span>
}
export function LowerLabel() {
return <span className="text-red-400">LOWER</span>
}
export function NoLabel() {
return <span className="text-red-400">NO</span>
}

View File

@ -52,7 +52,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
xScale={{
type: 'time',
min: points[0].x,
min: points[0]?.x,
max: endDate,
}}
yScale={{
@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
enableGridY={true}
enableSlices="x"
animate={false}
yFormat={(value) => formatMoney(+value)}
></ResponsiveLine>
</div>
)

View File

@ -13,7 +13,7 @@ export const PortfolioValueSection = memo(
}) {
const { portfolioHistory } = props
const lastPortfolioMetrics = last(portfolioHistory)
const [portfolioPeriod] = useState<Period>('allTime')
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
return <div> No portfolio history data yet </div>
@ -33,9 +33,16 @@ export const PortfolioValueSection = memo(
</div>
</Col>
</div>
{
//TODO: enable day/week/monthly as data becomes available
}
<select
className="select select-bordered self-start"
onChange={(e) => {
setPortfolioPeriod(e.target.value as Period)
}}
>
<option value="allTime">All time</option>
<option value="weekly">Weekly</option>
<option value="daily">Daily</option>
</select>
</Row>
<PortfolioValueGraph
portfolioHistory={portfolioHistory}

View File

@ -0,0 +1,178 @@
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { prefetchUsers, useUserById } from 'web/hooks/use-user'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Tabs } from './layout/tabs'
import { TextButton } from './text-button'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { useReferrals } from 'web/hooks/use-referrals'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { getUser, updateUser } from 'web/lib/firebase/users'
export function ReferralsButton(props: { user: User; currentUser?: User }) {
const { user, currentUser } = props
const [isOpen, setIsOpen] = useState(false)
const referralIds = useReferrals(user.id)
return (
<>
<TextButton onClick={() => setIsOpen(true)}>
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
Referrals
</TextButton>
<ReferralsDialog
user={user}
referralIds={referralIds ?? []}
isOpen={isOpen}
setIsOpen={setIsOpen}
currentUser={currentUser}
/>
</>
)
}
function ReferralsDialog(props: {
user: User
referralIds: string[]
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
currentUser?: User
}) {
const { user, referralIds, isOpen, setIsOpen, currentUser } = props
const [referredBy, setReferredBy] = useState<User[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [errorText, setErrorText] = useState('')
const [referredByUser, setReferredByUser] = useState<User | null>()
useEffect(() => {
if (isOpen && !referredByUser && user?.referredByUserId) {
getUser(user.referredByUserId).then((user) => {
setReferredByUser(user)
})
}
}, [isOpen, referredByUser, user.referredByUserId])
useEffect(() => {
prefetchUsers(referralIds)
}, [referralIds])
return (
<Modal open={isOpen} setOpen={setIsOpen}>
<Col className="rounded bg-white p-6">
<div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<Tabs
tabs={[
{
title: 'Referrals',
content: <ReferralsList userIds={referralIds} />,
},
{
title: 'Referred by',
content: (
<>
{user.id === currentUser?.id && !referredByUser ? (
<>
<FilterSelectUsers
setSelectedUsers={setReferredBy}
selectedUsers={referredBy}
ignoreUserIds={[currentUser.id]}
showSelectedUsersTitle={false}
selectedUsersClassName={'grid-cols-2 '}
maxUsers={1}
/>
<Row className={'mt-0 justify-end'}>
<button
className={
referredBy.length === 0
? 'hidden'
: 'btn btn-primary btn-md my-2 w-24 normal-case'
}
disabled={referredBy.length === 0 || isSubmitting}
onClick={() => {
setIsSubmitting(true)
updateUser(currentUser.id, {
referredByUserId: referredBy[0].id,
})
.then(async () => {
setErrorText('')
setIsSubmitting(false)
setReferredBy([])
setIsOpen(false)
})
.catch((error) => {
setIsSubmitting(false)
setErrorText(error.message)
})
}}
>
Save
</button>
</Row>
<span className={'text-warning'}>
{referredBy.length > 0 &&
'Careful: you can only set who referred you once!'}
</span>
<span className={'text-error'}>{errorText}</span>
</>
) : (
<div className="justify-center text-gray-700">
{referredByUser ? (
<Row className={'items-center gap-2 p-2'}>
<Avatar
username={referredByUser.username}
avatarUrl={referredByUser.avatarUrl}
/>
<UserLink
username={referredByUser.username}
name={referredByUser.name}
/>
</Row>
) : (
<span className={'text-gray-500'}>No one...</span>
)}
</div>
)}
</>
),
},
]}
/>
</Col>
</Modal>
)
}
function ReferralsList(props: { userIds: string[] }) {
const { userIds } = props
return (
<Col className="gap-2">
{userIds.length === 0 && (
<div className="text-gray-500">No users yet...</div>
)}
{userIds.map((userId) => (
<UserReferralItem key={userId} userId={userId} />
))}
</Col>
)
}
function UserReferralItem(props: { userId: string; className?: string }) {
const { userId, className } = props
const user = useUserById(userId)
return (
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
<Row className="items-center gap-2">
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} />
{user && <UserLink name={user.name} username={user.username} />}
</Row>
</Row>
)
}

View File

@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users'
import { YesNoCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button'
import { resolveMarket } from 'web/lib/firebase/fn-call'
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
import { ProbabilitySelector } from './probability-selector'
import { DPM_CREATOR_FEE } from 'common/fees'
import { getProbability } from 'common/calculate'
@ -42,17 +42,22 @@ export function ResolutionPanel(props: {
setIsSubmitting(true)
const result = await resolveMarket({
outcome,
contractId: contract.id,
probabilityInt: prob,
}).then((r) => r.data)
console.log('resolved', outcome, 'result:', result)
if (result?.status !== 'success') {
setError(result?.message || 'Error resolving market')
try {
const result = await resolveMarket({
outcome,
contractId: contract.id,
probabilityInt: prob,
})
console.log('resolved', outcome, 'result:', result)
} catch (e) {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error resolving market')
}
}
setIsSubmitting(false)
}

View File

@ -1,4 +1,4 @@
import { BinaryContract } from 'common/contract'
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { User } from 'common/user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useState } from 'react'
@ -7,7 +7,7 @@ import clsx from 'clsx'
import { SellSharesModal } from './sell-modal'
export function SellButton(props: {
contract: BinaryContract
contract: BinaryContract | PseudoNumericContract
user: User | null | undefined
sharesOutcome: 'YES' | 'NO' | undefined
shares: number
@ -16,7 +16,8 @@ export function SellButton(props: {
const { contract, user, sharesOutcome, shares, panelClassName } = props
const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract
const { mechanism, outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
if (sharesOutcome && user && mechanism === 'cpmm-1') {
return (
@ -32,7 +33,10 @@ export function SellButton(props: {
)}
onClick={() => setShowSellModal(true)}
>
{'Sell ' + sharesOutcome}
Sell{' '}
{isPseudoNumeric
? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome]
: sharesOutcome}
</button>
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{'(' + Math.floor(shares) + ' shares)'}

View File

@ -1,4 +1,4 @@
import { CPMMBinaryContract } from 'common/contract'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { Bet } from 'common/bet'
import { User } from 'common/user'
import { Modal } from './layout/modal'
@ -11,7 +11,7 @@ import clsx from 'clsx'
export function SellSharesModal(props: {
className?: string
contract: CPMMBinaryContract
contract: CPMMBinaryContract | PseudoNumericContract
userBets: Bet[]
shares: number
sharesOutcome: 'YES' | 'NO'

View File

@ -1,4 +1,4 @@
import { BinaryContract } from 'common/contract'
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { User } from 'common/user'
import { useState } from 'react'
import { Col } from './layout/col'
@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares'
import { SellSharesModal } from './sell-modal'
export function SellRow(props: {
contract: BinaryContract
contract: BinaryContract | PseudoNumericContract
user: User | null | undefined
className?: string
}) {

View File

@ -0,0 +1,70 @@
import React, { useState } from 'react'
import { ShareIcon } from '@heroicons/react/outline'
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'
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
import { Group } from 'common/group'
import { groupPath } from 'web/lib/firebase/groups'
function copyContractWithReferral(contract: Contract, username?: string) {
const postFix =
username && contract.creatorUsername !== username
? '?referrer=' + username
: ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}`
)
}
// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically
function copyGroupWithReferral(group: Group, username?: string) {
const postFix = username ? '?referrer=' + username : ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}`
)
}
export function ShareIconButton(props: {
contract?: Contract
group?: Group
buttonClassName?: string
toastClassName?: string
username?: string
children?: React.ReactNode
}) {
const {
contract,
buttonClassName,
toastClassName,
username,
group,
children,
} = props
const [showToast, setShowToast] = useState(false)
return (
<div className="relative z-10 flex-shrink-0">
<button
className={clsx(contractDetailsButtonClassName, buttonClassName)}
onClick={() => {
if (contract) copyContractWithReferral(contract, username)
if (group) copyGroupWithReferral(group, username)
track('copy share link')
setShowToast(true)
setTimeout(() => setShowToast(false), 2000)
}}
>
<ShareIcon className="h-[24px] w-5" aria-hidden="true" />
{children}
</button>
{showToast && <ToastClipboard className={toastClassName} />}
</div>
)
}

View File

@ -36,6 +36,9 @@ import { FollowersButton, FollowingButton } from './following-button'
import { useFollows } from 'web/hooks/use-follows'
import { FollowButton } from './follow-button'
import { PortfolioMetrics } from 'common/user'
import { ReferralsButton } from 'web/components/referrals-button'
import { GroupsButton } from 'web/components/groups/groups-button'
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
export function UserLink(props: {
name: string
@ -73,7 +76,9 @@ export function UserPage(props: {
'loading'
)
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([])
const [portfolioHistory, setUsersPortfolioHistory] = useState<
PortfolioMetrics[]
>([])
const [commentsByContract, setCommentsByContract] = useState<
Map<Contract, Comment[]> | 'loading'
>('loading')
@ -154,7 +159,7 @@ export function UserPage(props: {
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={20}
size={24}
className="bg-white ring-4 ring-white"
/>
</div>
@ -193,10 +198,12 @@ export function UserPage(props: {
</>
)}
<Col className="gap-2 sm:flex-row sm:items-center sm:gap-4">
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
<Row className="gap-4">
<FollowingButton user={user} />
<FollowersButton user={user} />
<ReferralsButton user={user} currentUser={currentUser} />
<GroupsButton user={user} />
</Row>
{user.website && (
@ -254,7 +261,7 @@ export function UserPage(props: {
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
<Tabs
className={'pb-2 pt-1 '}
labelClassName={'pb-2 pt-1 '}
defaultIndex={
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
}
@ -293,9 +300,9 @@ export function UserPage(props: {
title: 'Bets',
content: (
<div>
{
// TODO: add portfolio-value-section here
}
<PortfolioValueSection
portfolioHistory={portfolioHistory}
/>
<BetsList
user={user}
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}

View File

@ -12,6 +12,7 @@ export function YesNoSelector(props: {
btnClassName?: string
replaceYesButton?: React.ReactNode
replaceNoButton?: React.ReactNode
isPseudoNumeric?: boolean
}) {
const {
selected,
@ -20,6 +21,7 @@ export function YesNoSelector(props: {
btnClassName,
replaceNoButton,
replaceYesButton,
isPseudoNumeric,
} = props
const commonClassNames =
@ -41,7 +43,7 @@ export function YesNoSelector(props: {
)}
onClick={() => onSelect('YES')}
>
Bet YES
{isPseudoNumeric ? 'HIGHER' : 'Bet YES'}
</button>
)}
{replaceNoButton ? (
@ -58,7 +60,7 @@ export function YesNoSelector(props: {
)}
onClick={() => onSelect('NO')}
>
Bet NO
{isPseudoNumeric ? 'LOWER' : 'Bet NO'}
</button>
)}
</Row>

View File

@ -2,16 +2,16 @@ import { useEffect } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import {
Contract,
contractDocRef,
contracts,
listenForContract,
} from 'web/lib/firebase/contracts'
import { useStateCheckEquality } from './use-state-check-equality'
import { DocumentData } from 'firebase/firestore'
import { doc, DocumentData } from 'firebase/firestore'
export const useContract = (contractId: string) => {
const result = useFirestoreDocumentData<DocumentData, Contract>(
['contracts', contractId],
contractDocRef(contractId),
doc(contracts, contractId),
{ subscribe: true, includeMetadataChanges: true }
)

View File

@ -29,11 +29,11 @@ export const useGroups = () => {
return groups
}
export const useMemberGroups = (user: User | null | undefined) => {
export const useMemberGroups = (userId: string | null | undefined) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
useEffect(() => {
if (user) return listenForMemberGroups(user.id, setMemberGroups)
}, [user])
if (userId) return listenForMemberGroups(userId, setMemberGroups)
}, [userId])
return memberGroups
}

View File

@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash'
export type NotificationGroup = {
notifications: Notification[]
sourceContractId: string
groupedById: string
isSeen: boolean
timePeriod: string
type: 'income' | 'normal'
}
export function usePreferredGroupedNotifications(
@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) {
new Date(notification.createdTime).toDateString()
)
Object.keys(notificationGroupsByDay).forEach((day) => {
// Group notifications by contract:
const notificationsGroupedByDay = notificationGroupsByDay[day]
const bonusNotifications = notificationsGroupedByDay.filter(
(notification) => notification.sourceType === 'bonus'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
(notification) => notification.sourceType !== 'bonus'
)
if (bonusNotifications.length > 0) {
notificationGroups = notificationGroups.concat({
notifications: bonusNotifications,
groupedById: 'income' + day,
isSeen: bonusNotifications[0].isSeen,
timePeriod: day,
type: 'income',
})
}
// Group notifications by contract, filtering out bonuses:
const groupedNotificationsByContractId = groupBy(
notificationGroupsByDay[day],
normalNotificationsGroupedByDay,
(notification) => {
return notification.sourceContractId
}
)
notificationGroups = notificationGroups.concat(
map(groupedNotificationsByContractId, (notifications, contractId) => {
const notificationsForContractId = groupedNotificationsByContractId[
contractId
].sort((a, b) => {
return b.createdTime - a.createdTime
})
// Create a notification group for each contract within each day
const notificationGroup: NotificationGroup = {
notifications: groupedNotificationsByContractId[contractId].sort(
(a, b) => {
return b.createdTime - a.createdTime
}
),
sourceContractId: contractId,
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
notifications: notificationsForContractId,
groupedById: contractId,
isSeen: notificationsForContractId[0].isSeen,
timePeriod: day,
type: 'normal',
}
return notificationGroup
})
@ -64,11 +83,11 @@ export function groupNotifications(notifications: Notification[]) {
return notificationGroups
}
function usePreferredNotifications(
export function usePreferredNotifications(
userId: string | undefined,
options: { unseenOnly: boolean }
options: { unseenOnly: boolean; customHref?: string }
) {
const { unseenOnly } = options
const { unseenOnly, customHref } = options
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [notifications, setNotifications] = useState<Notification[]>([])
const [userAppropriateNotifications, setUserAppropriateNotifications] =
@ -93,9 +112,11 @@ function usePreferredNotifications(
const notificationsToShow = getAppropriateNotifications(
notifications,
privateUser.notificationPreferences
).filter((n) =>
customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref
)
setUserAppropriateNotifications(notificationsToShow)
}, [privateUser, notifications])
}, [privateUser, notifications, customHref])
return userAppropriateNotifications
}
@ -117,7 +138,7 @@ function getAppropriateNotifications(
return notifications.filter(
(n) =>
n.reason &&
// Show all contract notifications
// Show all contract notifications and any that aren't in the above list:
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
)
if (notificationPreferences === 'none') return []

View File

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
import { listenForReferrals } from 'web/lib/firebase/users'
export const useReferrals = (userId: string | null | undefined) => {
const [referralIds, setReferralIds] = useState<string[] | undefined>()
useEffect(() => {
if (userId) return listenForReferrals(userId, setReferralIds)
}, [userId])
return referralIds
}

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query'
import { DocumentData } from 'firebase/firestore'
import { doc, DocumentData } from 'firebase/firestore'
import { PrivateUser } from 'common/user'
import {
getUser,
@ -10,7 +10,7 @@ import {
listenForPrivateUser,
listenForUser,
User,
userDocRef,
users,
} from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
@ -49,7 +49,7 @@ export const usePrivateUser = (userId?: string) => {
export const useUserById = (userId: string) => {
const result = useFirestoreDocumentData<DocumentData, User>(
['users', userId],
userDocRef(userId),
doc(users, userId),
{ subscribe: true, includeMetadataChanges: true }
)

View File

@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => {
'Origin',
])
const hasBody = req.method != 'HEAD' && req.method != 'GET'
const opts = { headers, method: req.method, body: hasBody ? req : undefined }
const body = req.body ? JSON.stringify(req.body) : req
const opts = {
headers,
method: req.method,
body: hasBody ? body : undefined,
}
return fetch(url, opts)
}

View File

@ -41,14 +41,23 @@ export async function call(url: string, method: string, params: any) {
// one less hop
export function getFunctionUrl(name: string) {
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
const { projectId, region } = ENV_CONFIG.firebaseConfig
return `http://localhost:5001/${projectId}/${region}/${name}`
} else {
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
}
}
export function createMarket(params: any) {
return call(getFunctionUrl('createmarket'), 'POST', params)
}
export function resolveMarket(params: any) {
return call(getFunctionUrl('resolvemarket'), 'POST', params)
}
export function placeBet(params: any) {
return call(getFunctionUrl('placebet'), 'POST', params)
}
@ -64,3 +73,7 @@ export function sellBet(params: any) {
export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params)
}
export function requestBonuses(params: any) {
return call(getFunctionUrl('getdailybonuses'), 'POST', params)
}

View File

@ -1,6 +1,5 @@
import dayjs from 'dayjs'
import {
getFirestore,
doc,
setDoc,
deleteDoc,
@ -16,8 +15,7 @@ import {
} from 'firebase/firestore'
import { sortBy, sum } from 'lodash'
import { app } from './init'
import { getValues, listenForValue, listenForValues } from './utils'
import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract } from 'common/contract'
import { getDpmProbability } from 'common/calculate-dpm'
import { createRNG, shuffle } from 'common/util/random'
@ -28,6 +26,9 @@ import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants'
export const contracts = coll<Contract>('contracts')
export type { Contract }
export function contractPath(contract: Contract) {
@ -86,83 +87,72 @@ export function tradingAllowed(contract: Contract) {
)
}
const db = getFirestore(app)
export const contractCollection = collection(db, 'contracts')
export const contractDocRef = (contractId: string) =>
doc(db, 'contracts', contractId)
// Push contract to Firestore
export async function setContract(contract: Contract) {
const docRef = doc(db, 'contracts', contract.id)
await setDoc(docRef, contract)
await setDoc(doc(contracts, contract.id), contract)
}
export async function updateContract(
contractId: string,
update: Partial<Contract>
) {
const docRef = doc(db, 'contracts', contractId)
await updateDoc(docRef, update)
await updateDoc(doc(contracts, contractId), update)
}
export async function getContractFromId(contractId: string) {
const docRef = doc(db, 'contracts', contractId)
const result = await getDoc(docRef)
return result.exists() ? (result.data() as Contract) : undefined
const result = await getDoc(doc(contracts, contractId))
return result.exists() ? result.data() : undefined
}
export async function getContractFromSlug(slug: string) {
const q = query(contractCollection, where('slug', '==', slug))
const q = query(contracts, where('slug', '==', slug))
const snapshot = await getDocs(q)
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
return snapshot.empty ? undefined : snapshot.docs[0].data()
}
export async function deleteContract(contractId: string) {
const docRef = doc(db, 'contracts', contractId)
await deleteDoc(docRef)
await deleteDoc(doc(contracts, contractId))
}
export async function listContracts(creatorId: string): Promise<Contract[]> {
const q = query(
contractCollection,
contracts,
where('creatorId', '==', creatorId),
orderBy('createdTime', 'desc')
)
const snapshot = await getDocs(q)
return snapshot.docs.map((doc) => doc.data() as Contract)
return snapshot.docs.map((doc) => doc.data())
}
export async function listTaggedContractsCaseInsensitive(
tag: string
): Promise<Contract[]> {
const q = query(
contractCollection,
contracts,
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
orderBy('createdTime', 'desc')
)
const snapshot = await getDocs(q)
return snapshot.docs.map((doc) => doc.data() as Contract)
return snapshot.docs.map((doc) => doc.data())
}
export async function listAllContracts(
n: number,
before?: string
): Promise<Contract[]> {
let q = query(contractCollection, orderBy('createdTime', 'desc'), limit(n))
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
if (before != null) {
const snap = await getDoc(doc(db, 'contracts', before))
const snap = await getDoc(doc(contracts, before))
q = query(q, startAfter(snap))
}
const snapshot = await getDocs(q)
return snapshot.docs.map((doc) => doc.data() as Contract)
return snapshot.docs.map((doc) => doc.data())
}
export function listenForContracts(
setContracts: (contracts: Contract[]) => void
) {
const q = query(contractCollection, orderBy('createdTime', 'desc'))
const q = query(contracts, orderBy('createdTime', 'desc'))
return listenForValues<Contract>(q, setContracts)
}
@ -171,7 +161,7 @@ export function listenForUserContracts(
setContracts: (contracts: Contract[]) => void
) {
const q = query(
contractCollection,
contracts,
where('creatorId', '==', creatorId),
orderBy('createdTime', 'desc')
)
@ -179,7 +169,7 @@ export function listenForUserContracts(
}
const activeContractsQuery = query(
contractCollection,
contracts,
where('isResolved', '==', false),
where('visibility', '==', 'public'),
where('volume7Days', '>', 0)
@ -196,7 +186,7 @@ export function listenForActiveContracts(
}
const inactiveContractsQuery = query(
contractCollection,
contracts,
where('isResolved', '==', false),
where('closeTime', '>', Date.now()),
where('visibility', '==', 'public'),
@ -214,7 +204,7 @@ export function listenForInactiveContracts(
}
const newContractsQuery = query(
contractCollection,
contracts,
where('isResolved', '==', false),
where('volume7Days', '==', 0),
where('createdTime', '>', Date.now() - 7 * DAY_MS)
@ -230,7 +220,7 @@ export function listenForContract(
contractId: string,
setContract: (contract: Contract | null) => void
) {
const contractRef = doc(contractCollection, contractId)
const contractRef = doc(contracts, contractId)
return listenForValue<Contract>(contractRef, setContract)
}
@ -242,7 +232,7 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
}
const hotContractsQuery = query(
contractCollection,
contracts,
where('isResolved', '==', false),
where('visibility', '==', 'public'),
orderBy('volume24Hours', 'desc'),
@ -262,22 +252,22 @@ export function listenForHotContracts(
}
export async function getHotContracts() {
const contracts = await getValues<Contract>(hotContractsQuery)
const data = await getValues<Contract>(hotContractsQuery)
return sortBy(
chooseRandomSubset(contracts, 10),
chooseRandomSubset(data, 10),
(contract) => -1 * contract.volume24Hours
)
}
export async function getContractsBySlugs(slugs: string[]) {
const q = query(contractCollection, where('slug', 'in', slugs))
const q = query(contracts, where('slug', 'in', slugs))
const snapshot = await getDocs(q)
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
return sortBy(contracts, (contract) => -1 * contract.volume24Hours)
const data = snapshot.docs.map((doc) => doc.data())
return sortBy(data, (contract) => -1 * contract.volume24Hours)
}
const topWeeklyQuery = query(
contractCollection,
contracts,
where('isResolved', '==', false),
orderBy('volume7Days', 'desc'),
limit(MAX_FEED_CONTRACTS)
@ -287,7 +277,7 @@ export async function getTopWeeklyContracts() {
}
const closingSoonQuery = query(
contractCollection,
contracts,
where('isResolved', '==', false),
where('visibility', '==', 'public'),
where('closeTime', '>', Date.now()),
@ -296,15 +286,12 @@ const closingSoonQuery = query(
)
export async function getClosingSoonContracts() {
const contracts = await getValues<Contract>(closingSoonQuery)
return sortBy(
chooseRandomSubset(contracts, 2),
(contract) => contract.closeTime
)
const data = await getValues<Contract>(closingSoonQuery)
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
}
export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(db, 'contracts', contract.id)
const contractDoc = doc(contracts, contract.id)
const [recentBets, recentComments] = await Promise.all([
getValues<Bet>(

View File

@ -29,17 +29,6 @@ export const createAnswer = cloudFunction<
}
>('createAnswer')
export const resolveMarket = cloudFunction<
{
outcome: string
value?: number
contractId: string
probabilityInt?: number
resolutions?: { [outcome: string]: number }
},
{ status: 'error' | 'success'; message?: string }
>('resolveMarket')
export const createUser: () => Promise<User | null> = () => {
const local = safeLocalStorage()
let deviceToken = local?.getItem('device-token')

View File

@ -1,19 +1,24 @@
import {
collection,
deleteDoc,
doc,
getDocs,
query,
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy } from 'lodash'
import { sortBy, uniq } from 'lodash'
import { Group } from 'common/group'
import { getContractFromId } from './contracts'
import { db } from './init'
import { getValue, getValues, listenForValue, listenForValues } from './utils'
import {
coll,
getValue,
getValues,
listenForValue,
listenForValues,
} from './utils'
import { filterDefined } from 'common/util/array'
const groupCollection = collection(db, 'groups')
export const groups = coll<Group>('groups')
export function groupPath(
groupSlug: string,
@ -23,30 +28,29 @@ export function groupPath(
}
export function updateGroup(group: Group, updates: Partial<Group>) {
return updateDoc(doc(groupCollection, group.id), updates)
return updateDoc(doc(groups, group.id), updates)
}
export function deleteGroup(group: Group) {
return deleteDoc(doc(groupCollection, group.id))
return deleteDoc(doc(groups, group.id))
}
export async function listAllGroups() {
return getValues<Group>(groupCollection)
return getValues<Group>(groups)
}
export function listenForGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(groupCollection, setGroups)
return listenForValues(groups, setGroups)
}
export function getGroup(groupId: string) {
return getValue<Group>(doc(groupCollection, groupId))
return getValue<Group>(doc(groups, groupId))
}
export async function getGroupBySlug(slug: string) {
const q = query(groupCollection, where('slug', '==', slug))
const groups = await getValues<Group>(q)
return groups.length === 0 ? null : groups[0]
const q = query(groups, where('slug', '==', slug))
const docs = (await getDocs(q)).docs
return docs.length === 0 ? null : docs[0].data()
}
export async function getGroupContracts(group: Group) {
@ -68,14 +72,14 @@ export function listenForGroup(
groupId: string,
setGroup: (group: Group | null) => void
) {
return listenForValue(doc(groupCollection, groupId), setGroup)
return listenForValue(doc(groups, groupId), setGroup)
}
export function listenForMemberGroups(
userId: string,
setGroups: (groups: Group[]) => void
) {
const q = query(groupCollection, where('memberIds', 'array-contains', userId))
const q = query(groups, where('memberIds', 'array-contains', userId))
return listenForValues<Group>(q, (groups) => {
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
@ -87,10 +91,51 @@ export async function getGroupsWithContractId(
contractId: string,
setGroups: (groups: Group[]) => void
) {
const q = query(
groupCollection,
where('contractIds', 'array-contains', contractId)
)
const groups = await getValues<Group>(q)
setGroups(groups)
const q = query(groups, where('contractIds', 'array-contains', contractId))
setGroups(await getValues<Group>(q))
}
export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
// get group to get the member ids
const group = await getGroupBySlug(groupSlug)
if (!group) {
console.error(`Group not found: ${groupSlug}`)
return
}
return await addUserToGroup(group, userId)
}
export async function addUserToGroup(
group: Group,
userId: string
): Promise<Group> {
const { memberIds } = group
if (memberIds.includes(userId)) {
return group
}
const newMemberIds = [...memberIds, userId]
const newGroup = { ...group, memberIds: newMemberIds }
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
return newGroup
}
export async function leaveGroup(group: Group, userId: string): Promise<Group> {
const { memberIds } = group
if (!memberIds.includes(userId)) {
return group
}
const newMemberIds = memberIds.filter((id) => id !== userId)
const newGroup = { ...group, memberIds: newMemberIds }
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
return newGroup
}
export async function addContractToGroup(group: Group, contractId: string) {
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contractId]),
})
.then(() => group)
.catch((err) => {
console.error('error adding contract to group', err)
return err
})
}

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