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

View File

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

View File

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

View File

@ -3,9 +3,10 @@ import { Fees } from './fees'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
export type AnyMechanism = DPM | CPMM export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | FreeResponse | Numeric export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric)
| (DPM & Binary) | (DPM & Binary)
| (DPM & FreeResponse) | (DPM & FreeResponse)
| (DPM & Numeric) | (DPM & Numeric)
@ -45,7 +46,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
collectedFees: Fees collectedFees: Fees
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse export type FreeResponseContract = Contract & FreeResponse
export type DPMContract = Contract & DPM export type DPMContract = Contract & DPM
@ -76,6 +78,18 @@ export type Binary = {
resolution?: resolution 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 = { export type FreeResponse = {
outcomeType: 'FREE_RESPONSE' outcomeType: 'FREE_RESPONSE'
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
@ -95,7 +109,7 @@ export type Numeric = {
export type outcomeType = AnyOutcomeType['outcomeType'] export type outcomeType = AnyOutcomeType['outcomeType']
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const 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_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000 export const MAX_DESCRIPTION_LENGTH = 10000

View File

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

View File

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

View File

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

View File

@ -22,6 +22,8 @@ export type Notification = {
sourceSlug?: string sourceSlug?: string
sourceTitle?: string sourceTitle?: string
isSeenOnHref?: string
} }
export type notification_source_types = export type notification_source_types =
| 'contract' | 'contract'
@ -33,6 +35,8 @@ export type notification_source_types =
| 'tip' | 'tip'
| 'admin_message' | 'admin_message'
| 'group' | 'group'
| 'user'
| 'bonus'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -53,3 +57,7 @@ export type notification_reason_types =
| 'on_new_follow' | 'on_new_follow'
| 'you_follow_user' | 'you_follow_user'
| 'added_you_to_group' | '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_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500' 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 { sumBy, groupBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet' import { Bet, NumericBet } from './bet'
import { Contract, CPMMBinaryContract, DPMContract } from './contract' import {
Contract,
CPMMBinaryContract,
DPMContract,
PseudoNumericContract,
} from './contract'
import { Fees } from './fees' import { Fees } from './fees'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { import {
@ -48,15 +53,19 @@ export type PayoutInfo = {
export const getPayouts = ( export const getPayouts = (
outcome: string | undefined, outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: Contract, contract: Contract,
bets: Bet[], bets: Bet[],
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number resolutionProbability?: number
): PayoutInfo => { ): PayoutInfo => {
if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { if (
contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
) {
return getFixedPayouts( return getFixedPayouts(
outcome, outcome,
contract, contract,
@ -67,16 +76,16 @@ export const getPayouts = (
} }
return getDpmPayouts( return getDpmPayouts(
outcome, outcome,
resolutions,
contract, contract,
bets, bets,
resolutions,
resolutionProbability resolutionProbability
) )
} }
export const getFixedPayouts = ( export const getFixedPayouts = (
outcome: string | undefined, outcome: string | undefined,
contract: CPMMBinaryContract, contract: CPMMBinaryContract | PseudoNumericContract,
bets: Bet[], bets: Bet[],
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[],
resolutionProbability?: number resolutionProbability?: number
@ -100,11 +109,11 @@ export const getFixedPayouts = (
export const getDpmPayouts = ( export const getDpmPayouts = (
outcome: string | undefined, outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: DPMContract, contract: DPMContract,
bets: Bet[], bets: Bet[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number resolutionProbability?: number
): PayoutInfo => { ): PayoutInfo => {
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
@ -115,8 +124,8 @@ export const getDpmPayouts = (
return getDpmStandardPayouts(outcome, contract, openBets) return getDpmStandardPayouts(outcome, contract, openBets)
case 'MKT': case 'MKT':
return contract.outcomeType === 'FREE_RESPONSE' return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? getPayoutsMultiOutcome(resolutions, contract, openBets) ? getPayoutsMultiOutcome(resolutions!, contract, openBets)
: getDpmMktPayouts(contract, openBets, resolutionProbability) : getDpmMktPayouts(contract, openBets, resolutionProbability)
case 'CANCEL': case 'CANCEL':
case undefined: 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( const { payouts: resolvePayouts } = getPayouts(
resolution as string, resolution as string,
{},
contract, contract,
openBets, openBets,
[], [],
{},
resolutionProb resolutionProb
) )

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold // A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) // 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' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number amount: number
token: 'M$' // | 'USD' | MarketOutcome token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
// Any extra data // Any extra data
data?: { [key: string]: any } data?: { [key: string]: any }
@ -46,6 +47,19 @@ type Manalink = {
category: '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 DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink 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 = { export type User = {
id: string id: string
createdTime: number createdTime: number
@ -33,11 +35,15 @@ export type User = {
followerCountCached: number followerCountCached: number
followedCategories?: string[] followedCategories?: string[]
referredByUserId?: string
referredByContractId?: string
} }
export const STARTING_BALANCE = 1000 export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person // 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 = { export type PrivateUser = {
id: string // same as User.id id: string // same as User.id
username: string // denormalized from User username: string // denormalized from User
@ -51,6 +57,7 @@ export type PrivateUser = {
initialIpAddress?: string initialIpAddress?: string
apiKey?: string apiKey?: string
notificationPreferences?: notification_subscribe_types notificationPreferences?: notification_subscribe_types
lastTimeCheckedBonuses?: number
} }
export type notification_subscribe_types = 'all' | 'less' | 'none' export type notification_subscribe_types = 'all' | 'less' | 'none'

View File

@ -456,7 +456,6 @@ Requires no authorization.
} }
``` ```
### `POST /v0/bet` ### `POST /v0/bet`
Places a new bet on behalf of the authorized user. 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}' "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 ## Changelog
- 2022-06-08: Add paging to markets endpoint - 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. - 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. - 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. - 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 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. - 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. - 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" "order": "DESCENDING"
} }
] ]
},
{
"collectionGroup": "portfolioHistory",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "timestamp",
"order": "ASCENDING"
}
]
} }
], ],
"fieldOverrides": [ "fieldOverrides": [

View File

@ -20,7 +20,16 @@ service cloud.firestore {
allow read; allow read;
allow update: if resource.data.id == request.auth.uid allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && 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} { 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 = { module.exports = {
plugins: ['lodash'], plugins: ['lodash'],
extends: ['eslint:recommended'], extends: ['eslint:recommended'],
ignorePatterns: ['lib'], ignorePatterns: ['dist', 'lib'],
env: { env: {
node: true, node: true,
}, },
@ -30,6 +30,7 @@ module.exports = {
}, },
], ],
rules: { rules: {
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'], 'lodash/import-scope': [2, 'member'],
}, },
} }

View File

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

View File

@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
### For local development ### For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI 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. 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. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
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 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) 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 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" "firestore": "dev-mantic-markets.appspot.com"
}, },
"scripts": { "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", "compile": "tsc -b",
"watch": "tsc -w", "watch": "tsc -w",
"shell": "yarn build && firebase functions:shell", "shell": "yarn build && firebase functions:shell",
"start": "yarn shell", "start": "yarn shell",
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"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: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:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db: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", "main": "functions/src/index.js",
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "^2.0.0-beta.181", "@tiptap/core": "^2.0.0-beta.181",
"@tiptap/starter-kit": "^2.0.0-beta.190", "@tiptap/starter-kit": "^2.0.0-beta.190",
"fetch": "1.1.0",
"firebase-admin": "10.0.0", "firebase-admin": "10.0.0",
"firebase-functions": "3.21.2", "firebase-functions": "3.21.2",
"lodash": "4.17.21", "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 const contract = contractSnap.data() as Contract
if ( if (
contract.mechanism !== 'cpmm-1' || contract.mechanism !== 'cpmm-1' ||
contract.outcomeType !== 'BINARY' (contract.outcomeType !== 'BINARY' &&
contract.outcomeType !== 'PSEUDO_NUMERIC')
) )
return { status: 'error', message: 'Invalid contract' } 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, minInstances: 1,
concurrency: 100, concurrency: 100,
memory: '2GiB', memory: '2GiB',
@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = {
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
} }
export const newEndpoint = (methods: [string], fn: Handler) => export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
onRequest(DEFAULT_OPTS, async (req, res) => { const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
return onRequest(opts, async (req, res) => {
log('Request processing started.') log('Request processing started.')
try { try {
if (!methods.includes(req.method)) { if (!opts.methods.includes(req.method)) {
const allowed = methods.join(', ') const allowed = opts.methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`) throw new APIError(405, `This endpoint supports only ${allowed}.`)
} }
const authedUser = await lookupUser(await parseCredentials(req)) 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 functions from 'firebase-functions'
import * as firestore from '@google-cloud/firestore' 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 export const backupDb = functions.pubsub
.schedule('every 24 hours') .schedule('every 24 hours')
.onRun((_context) => { .onRun(async (_context) => {
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT try {
if (projectId == null) { const client = new firestore.v1.FirestoreAdminClient()
throw new Error('No project ID environment variable set.') 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 { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group' import { Group, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
const descScehma: z.ZodType<JSONContent> = z.lazy(() => const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
@ -68,19 +69,31 @@ const binarySchema = z.object({
initialProb: z.number().min(1).max(99), 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({ const numericSchema = z.object({
min: z.number(), min: finite(),
max: z.number(), 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 } = const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body) validate(bodySchema, req.body)
let min, max, initialProb let min, max, initialProb, isLogScale
if (outcomeType === 'NUMERIC') {
;({ min, max } = validate(numericSchema, req.body)) if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') 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') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, req.body))
@ -144,7 +157,8 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
tags ?? [], tags ?? [],
NUMERIC_BUCKET_COUNT, NUMERIC_BUCKET_COUNT,
min ?? 0, min ?? 0,
max ?? 0 max ?? 0,
isLogScale ?? false
) )
if (ante) await chargeUser(user.id, ante, true) if (ante) await chargeUser(user.id, ante, true)
@ -153,7 +167,7 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
const providerId = user.id const providerId = user.id
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`) .collection(`contracts/${contract.id}/liquidity`)
.doc() .doc()

View File

@ -20,7 +20,7 @@ const bodySchema = z.object({
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), 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( const { name, about, memberIds, anyoneCanJoin } = validate(
bodySchema, bodySchema,
req.body req.body

View File

@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type user_to_reason_texts = {
[userId: string]: { reason: notification_reason_types } [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string }
} }
export const createNotification = async ( export const createNotification = async (
@ -68,9 +68,11 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractCreatorUsername: sourceContract?.creatorUsername,
// TODO: move away from sourceContractTitle to sourceTitle // TODO: move away from sourceContractTitle to sourceTitle
sourceContractTitle: sourceContract?.question, sourceContractTitle: sourceContract?.question,
// TODO: move away from sourceContractSlug to sourceSlug
sourceContractSlug: sourceContract?.slug, sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
} }
await notificationRef.set(removeUndefinedProps(notification)) 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 getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
if (sourceContract) { if (sourceType === 'follow' && relatedUserId) {
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) {
await notifyFollowedUser(userToReasonTexts, relatedUserId) await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) { } else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created') if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) 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 return userToReasonTexts
} }

View File

@ -6,8 +6,13 @@ import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees' import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' 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 { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email' import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser } from './utils'
@ -101,6 +106,17 @@ const toDisplayResolution = (
return display || resolution 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 === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A' 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' import { newEndpoint } from './api'
export const health = newEndpoint(['GET'], async (_req, auth) => { export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
return { return {
message: 'Server is working.', message: 'Server is working.',
uid: auth.uid, uid: auth.uid,

View File

@ -6,12 +6,11 @@ admin.initializeApp()
// export * from './keep-awake' // export * from './keep-awake'
export * from './claim-manalink' export * from './claim-manalink'
export * from './transact' export * from './transact'
export * from './resolve-market'
export * from './stripe' export * from './stripe'
export * from './create-user' export * from './create-user'
export * from './create-answer' export * from './create-answer'
export * from './on-create-bet' export * from './on-create-bet'
export * from './on-create-comment' export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export * from './unsubscribe' export * from './unsubscribe'
export * from './update-metrics' export * from './update-metrics'
@ -28,6 +27,8 @@ export * from './on-unfollow-user'
export * from './on-create-liquidity-provision' export * from './on-create-liquidity-provision'
export * from './on-update-group' export * from './on-update-group'
export * from './on-create-group' export * from './on-create-group'
export * from './on-update-user'
export * from './on-create-comment-on-group'
// v2 // v2
export * from './health' export * from './health'
@ -37,3 +38,5 @@ export * from './sell-shares'
export * from './create-contract' export * from './create-contract'
export * from './withdraw-liquidity' export * from './withdraw-liquidity'
export * from './create-group' 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() const firestore = admin.firestore()
export const onCreateComment = functions export const onCreateCommentOnContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/comments/{commentId}') .firestore.document('contracts/{contractId}/comments/{commentId}')
.onCreate(async (change, context) => { .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 // ignore the update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return return
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
await firestore await firestore
.collection('groups') .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(), value: z.number(),
}) })
export const placebet = newEndpoint(['POST'], async (req, auth) => { export const placebet = newEndpoint({}, async (req, auth) => {
log('Inside endpoint handler.') log('Inside endpoint handler.')
const { amount, contractId } = validate(bodySchema, req.body) const { amount, contractId } = validate(bodySchema, req.body)
@ -41,10 +41,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
log('Inside main transaction.') log('Inside main transaction.')
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const [contractSnap, userSnap] = await Promise.all([ const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
trans.get(contractDoc),
trans.get(userDoc),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
log('Loaded user and contract snapshots.') log('Loaded user and contract snapshots.')
@ -70,7 +67,10 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
const { outcome } = validate(binarySchema, req.body) const { outcome } = validate(binarySchema, req.body)
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) 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) const { outcome } = validate(binarySchema, req.body)
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {

View File

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

View File

@ -1,8 +1,12 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' 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 { User } from '../../common/user'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils' import { getUser, isProd, payUser } from './utils'
@ -15,156 +19,162 @@ import {
} from '../../common/payouts' } from '../../common/payouts'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
export const resolveMarket = functions const bodySchema = z.object({
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) contractId: z.string(),
.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 { 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 freeResponseSchema = z.union([
const contractSnap = await contractDoc.get() z.object({
if (!contractSnap.exists) outcome: z.literal('CANCEL'),
return { status: 'error', message: 'Invalid contract' } }),
const contract = contractSnap.data() as Contract z.object({
const { creatorId, outcomeType, closeTime } = contract 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') { const numericSchema = z.object({
if (!RESOLUTIONS.includes(outcome)) outcome: z.union([z.literal('CANCEL'), z.string()]),
return { status: 'error', message: 'Invalid outcome' } value: z.number().optional(),
} 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' }
}
if (value !== undefined && !isFinite(value)) const pseudoNumericSchema = z.union([
return { status: 'error', message: 'Invalid value' } z.object({
outcome: z.literal('CANCEL'),
}),
z.object({
outcome: z.literal('MKT'),
value: z.number(),
probabilityInt: z.number().gte(0).lte(100),
}),
])
if ( const opts = { secrets: ['MAILGUN_KEY'] }
outcomeType === 'BINARY' &&
probabilityInt !== undefined &&
(probabilityInt < 0 ||
probabilityInt > 100 ||
!isFinite(probabilityInt))
)
return { status: 'error', message: 'Invalid probability' }
if (creatorId !== userId) export const resolvemarket = newEndpoint(opts, async (req, auth) => {
return { status: 'error', message: 'User not creator of contract' } const { contractId } = validate(bodySchema, req.body)
const userId = auth.uid
if (contract.resolution) const contractDoc = firestore.doc(`contracts/${contractId}`)
return { status: 'error', message: 'Contract already resolved' } 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) const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
if (!creator) return { status: 'error', message: 'Creator not found' } contract,
req.body
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
}
) )
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 processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts) 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() 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( const { payouts } = getPayouts(
resolution, resolution,
resolutions,
contract, contract,
openBets, openBets,
[], [],
resolutions,
resolutionProbability 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()) env = env || getFirebaseActiveProject(process.cwd())
if (env == null) { if (env == null) {
console.error( throw new Error(
"Couldn't find active Firebase project; did you do `firebase use <alias>?`" "Couldn't find active Firebase project; did you do `firebase use <alias>?`"
) )
return
} }
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
const keyPath = process.env[envVar] const keyPath = process.env[envVar]
if (keyPath == null) { if (keyPath == null) {
console.error( throw new Error(
`Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` `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 */ /* eslint-disable-next-line @typescript-eslint/no-var-requires */
const serviceAccount = require(keyPath) return require(keyPath)
admin.initializeApp({ }
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), 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(), 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) const { contractId, betId } = validate(bodySchema, req.body)
// run as transaction to prevent race conditions // 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 contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
const [contractSnap, userSnap, betSnap] = await Promise.all([ const [contractSnap, userSnap, betSnap] = await transaction.getAll(
transaction.get(contractDoc), contractDoc,
transaction.get(userDoc), userDoc,
transaction.get(betDoc), betDoc
]) )
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
if (!betSnap.exists) throw new APIError(400, 'Bet 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']), 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) const { contractId, shares, outcome } = validate(bodySchema, req.body)
// Run as transaction to prevent race conditions. // 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 contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [contractSnap, userSnap, userBets] = await Promise.all([ const [[contractSnap, userSnap], userBets] = await Promise.all([
transaction.get(contractDoc), transaction.getAll(contractDoc, userDoc),
transaction.get(userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction?? getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
]) ])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') 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 outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares) 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.`) throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( 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 functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { CPMMContract } from '../../common/contract' import { CPMMContract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { subtractObjects } from '../../common/util/object' import { subtractObjects } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { getUserLiquidityShares } from '../../common/calculate-cpmm' import { getUserLiquidityShares } from '../../common/calculate-cpmm'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { noFees } from '../../common/fees' import { noFees } from '../../common/fees'
import { APIError } from './api' import { APIError } from './api'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
export const withdrawLiquidity = functions export const withdrawLiquidity = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
.https.onCall( .https.onCall(
async ( async (
data: { data: {
contractId: string contractId: string
}, },
context context
) => { ) => {
const userId = context?.auth?.uid const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' } if (!userId) return { status: 'error', message: 'Not authorized' }
const { contractId } = data const { contractId } = data
if (!contractId) if (!contractId)
return { status: 'error', message: 'Missing contract id' } return { status: 'error', message: 'Missing contract id' }
return await firestore return await firestore
.runTransaction(async (trans) => { .runTransaction(async (trans) => {
const lpDoc = firestore.doc(`users/${userId}`) const lpDoc = firestore.doc(`users/${userId}`)
const lpSnap = await trans.get(lpDoc) const lpSnap = await trans.get(lpDoc)
if (!lpSnap.exists) throw new APIError(400, 'User not found.') if (!lpSnap.exists) throw new APIError(400, 'User not found.')
const lp = lpSnap.data() as User const lp = lpSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await trans.get(contractDoc) const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists) if (!contractSnap.exists)
throw new APIError(400, 'Contract not found.') throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as CPMMContract const contract = contractSnap.data() as CPMMContract
const liquidityCollection = firestore.collection( const liquidityCollection = firestore.collection(
`contracts/${contractId}/liquidity` `contracts/${contractId}/liquidity`
) )
const liquiditiesSnap = await trans.get(liquidityCollection) const liquiditiesSnap = await trans.get(liquidityCollection)
const liquidities = liquiditiesSnap.docs.map( const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision (doc) => doc.data() as LiquidityProvision
) )
const userShares = getUserLiquidityShares( const userShares = getUserLiquidityShares(
userId, userId,
contract, contract,
liquidities liquidities
) )
// zero all added amounts for now // zero all added amounts for now
// can add support for partial withdrawals in the future // can add support for partial withdrawals in the future
liquiditiesSnap.docs liquiditiesSnap.docs
.filter( .filter(
(_, i) => (_, i) =>
!liquidities[i].isAnte && liquidities[i].userId === userId !liquidities[i].isAnte && liquidities[i].userId === userId
) )
.forEach((doc) => trans.update(doc.ref, { amount: 0 })) .forEach((doc) => trans.update(doc.ref, { amount: 0 }))
const payout = Math.min(...Object.values(userShares)) const payout = Math.min(...Object.values(userShares))
if (payout <= 0) return {} if (payout <= 0) return {}
const newBalance = lp.balance + payout const newBalance = lp.balance + payout
const newTotalDeposits = lp.totalDeposits + payout const newTotalDeposits = lp.totalDeposits + payout
trans.update(lpDoc, { trans.update(lpDoc, {
balance: newBalance, balance: newBalance,
totalDeposits: newTotalDeposits, totalDeposits: newTotalDeposits,
} as Partial<User>) } as Partial<User>)
const newPool = subtractObjects(contract.pool, userShares) const newPool = subtractObjects(contract.pool, userShares)
const minPoolShares = Math.min(...Object.values(newPool)) const minPoolShares = Math.min(...Object.values(newPool))
const adjustedTotal = contract.totalLiquidity - payout const adjustedTotal = contract.totalLiquidity - payout
// total liquidity is a bogus number; use minPoolShares to prevent from going negative // total liquidity is a bogus number; use minPoolShares to prevent from going negative
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
trans.update(contractDoc, { trans.update(contractDoc, {
pool: newPool, pool: newPool,
totalLiquidity: newTotalLiquidity, totalLiquidity: newTotalLiquidity,
}) })
const prob = getProbability(contract) const prob = getProbability(contract)
// surplus shares become user's bets // surplus shares become user's bets
const bets = Object.entries(userShares) const bets = Object.entries(userShares)
.map(([outcome, shares]) => .map(([outcome, shares]) =>
shares - payout < 1 // don't create bet if less than 1 share shares - payout < 1 // don't create bet if less than 1 share
? undefined ? undefined
: ({ : ({
userId: userId, userId: userId,
contractId: contract.id, contractId: contract.id,
amount: amount:
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout), (outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
shares: shares - payout, shares: shares - payout,
outcome, outcome,
probBefore: prob, probBefore: prob,
probAfter: prob, probAfter: prob,
createdTime: Date.now(), createdTime: Date.now(),
isLiquidityProvision: true, isLiquidityProvision: true,
fees: noFees, fees: noFees,
} as Omit<Bet, 'id'>) } as Omit<Bet, 'id'>)
) )
.filter((x) => x !== undefined) .filter((x) => x !== undefined)
for (const bet of bets) { for (const bet of bets) {
const doc = firestore const doc = firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
.doc() .doc()
trans.create(doc, { id: doc.id, ...bet }) trans.create(doc, { id: doc.id, ...bet })
} }
return userShares return userShares
}) })
.then(async (result) => { .then(async (result) => {
// redeem surplus bet with pre-existing bets // redeem surplus bet with pre-existing bets
await redeemShares(userId, contractId) await redeemShares(userId, contractId)
console.log('userid', userId, 'withdraws', result) console.log('userid', userId, 'withdraws', result)
return { status: 'success', userShares: result } return { status: 'success', userShares: result }
}) })
.catch((e) => { .catch((e) => {
return { status: 'error', message: e.message } return { status: 'error', message: e.message }
}) })
} }
) )
const firestore = admin.firestore() const firestore = admin.firestore()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import {
BinaryContract, BinaryContract,
FreeResponseContract, FreeResponseContract,
NumericContract, NumericContract,
PseudoNumericContract,
} from 'common/contract' } from 'common/contract'
import { import {
AnswerLabel, AnswerLabel,
@ -16,7 +17,11 @@ import {
CancelLabel, CancelLabel,
FreeResponseOutcomeLabel, FreeResponseOutcomeLabel,
} from '../outcome-label' } from '../outcome-label'
import { getOutcomeProbability, getTopAnswer } from 'common/calculate' import {
getOutcomeProbability,
getProbability,
getTopAnswer,
} from 'common/calculate'
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { QuickBet, ProbBar, getColor } from './quick-bet' 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 { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -131,6 +137,13 @@ export function ContractCard(props: {
/> />
)} )}
{outcomeType === 'PSEUDO_NUMERIC' && (
<PseudoNumericResolutionOrExpectation
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && ( {outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation <NumericResolutionOrExpectation
className="items-center" className="items-center"
@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: {
{resolution === 'CANCEL' ? ( {resolution === 'CANCEL' ? (
<CancelLabel /> <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> </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 { SiteLink } from 'web/components/site-link'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { useGroupsWithContract } from 'web/hooks/use-group' 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' export type ShowTime = 'resolve-date' | 'close-date'
@ -128,8 +130,32 @@ export function ContractDetails(props: {
const { contract, bets, isCreator, disabled } = props const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId } = contract const { closeTime, creatorName, creatorUsername, creatorId } = contract
const { volumeLabel, resolvedDate } = contractMetrics(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 ( return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <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"> <Row className="items-center gap-2">
@ -150,14 +176,15 @@ export function ContractDetails(props: {
)} )}
{!disabled && <UserFollowButton userId={creatorId} small />} {!disabled && <UserFollowButton userId={creatorId} small />}
</Row> </Row>
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/} {groupToDisplay ? (
{groups && groups.length > 0 && (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}> <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" /> <UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groups[0].name}</span> <span>{groupToDisplay.name}</span>
</SiteLink> </SiteLink>
</Row> </Row>
) : (
<div />
)} )}
{(!!closeTime || !!resolvedDate) && ( {(!!closeTime || !!resolvedDate) && (
@ -192,6 +219,11 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div> <div className="whitespace-nowrap">{volumeLabel}</div>
</Row> </Row>
<ShareIconButton
contract={contract}
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
username={user?.username}
/>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />} {!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</Row> </Row>

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { import {
getOutcomeProbability, getOutcomeProbability,
getOutcomeProbabilityAfterBet, getOutcomeProbabilityAfterBet,
getProbability,
getTopAnswer, getTopAnswer,
} from 'common/calculate' } from 'common/calculate'
import { getExpectedValue } from 'common/calculate-dpm' 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 { sellShares } from 'web/lib/firebase/api-call'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
const BET_SIZE = 10 const BET_SIZE = 10
export function QuickBet(props: { contract: Contract; user: User }) { export function QuickBet(props: { contract: Contract; user: User }) {
const { contract, user } = props 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 userBets = useUserContractBets(user.id, contract.id)
const topAnswer = const topAnswer =
contract.outcomeType === 'FREE_RESPONSE' outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined
? getTopAnswer(contract)
: undefined
// TODO: yes/no from useSaveShares doesn't work on numeric contracts // TODO: yes/no from useSaveShares doesn't work on numeric contracts
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) {
topAnswer?.number.toString() || undefined topAnswer?.number.toString() || undefined
) )
const hasUpShares = const hasUpShares =
yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC') yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC')
const hasDownShares = const hasDownShares =
noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC' noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC'
const [upHover, setUpHover] = useState(false) const [upHover, setUpHover] = useState(false)
const [downHover, setDownHover] = 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 ( return (
<Col <Col
className={clsx( className={clsx(
@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) {
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
upHover ? textColor : 'text-gray-400' upHover ? 'text-green-500' : 'text-gray-400'
)} )}
/> />
) : ( ) : (
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', '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} /> <QuickOutcomeView contract={contract} previewProb={previewProb} />
{/* Down bet triangle */} {/* Down bet triangle */}
{contract.outcomeType !== 'BINARY' ? ( {outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
<div> <div>
<div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div> <div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div>
<TriangleDownFillIcon <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: { function QuickOutcomeView(props: {
contract: Contract contract: Contract
previewProb?: number previewProb?: number
@ -261,9 +262,16 @@ function QuickOutcomeView(props: {
}) { }) {
const { contract, previewProb, caption } = props const { contract, previewProb, caption } = props
const { outcomeType } = contract const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
// If there's a preview prob, display that instead of the current prob // If there's a preview prob, display that instead of the current prob
const override = const override =
previewProb === undefined ? undefined : formatPercent(previewProb) previewProb === undefined
? undefined
: isPseudoNumeric
? formatNumericProbability(previewProb, contract)
: formatPercent(previewProb)
const textColor = `text-${getColor(contract)}` const textColor = `text-${getColor(contract)}`
let display: string | undefined let display: string | undefined
@ -271,6 +279,9 @@ function QuickOutcomeView(props: {
case 'BINARY': case 'BINARY':
display = getBinaryProbPercent(contract) display = getBinaryProbPercent(contract)
break break
case 'PSEUDO_NUMERIC':
display = formatNumericProbability(getProbability(contract), contract)
break
case 'NUMERIC': case 'NUMERIC':
display = formatLargeNumber(getExpectedValue(contract)) display = formatLargeNumber(getExpectedValue(contract))
break break
@ -295,11 +306,15 @@ function QuickOutcomeView(props: {
// Return a number from 0 to 1 for this contract // Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO) // Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) { function getProb(contract: Contract) {
const { outcomeType, resolution } = contract const { outcomeType, resolution, resolutionProbability } = contract
return resolution return resolutionProbability
? resolutionProbability
: resolution
? 1 ? 1
: outcomeType === 'BINARY' : outcomeType === 'BINARY'
? getBinaryProb(contract) ? getBinaryProb(contract)
: outcomeType === 'PSEUDO_NUMERIC'
? getProbability(contract)
: outcomeType === 'FREE_RESPONSE' : outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '') ? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC' : outcomeType === 'NUMERIC'
@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) {
export function getColor(contract: Contract) { export function getColor(contract: Contract) {
// TODO: Try injecting a gradient here // TODO: Try injecting a gradient here
// return 'primary' // return 'primary'
const { resolution } = contract const { resolution, outcomeType } = contract
if (resolution) { if (resolution) {
return ( return (
OUTCOME_TO_COLOR[resolution as resolution] ?? 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()) { if ((contract.closeTime ?? Infinity) < Date.now()) {
return 'gray-400' 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 { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx' import clsx from 'clsx'
import { UsersIcon } from '@heroicons/react/solid' 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 { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { uniqBy, partition, sumBy, groupBy } from 'lodash'
import { JoinSpans } from 'web/components/join-spans' import { JoinSpans } from 'web/components/join-spans'
import { UserLink } from '../user-page' import { UserLink } from '../user-page'
import { formatNumericProbability } from 'common/pseudo-numeric'
export function FeedBet(props: { export function FeedBet(props: {
contract: Contract contract: Contract
@ -75,6 +76,8 @@ export function BetStatusText(props: {
hideOutcome?: boolean hideOutcome?: boolean
}) { }) {
const { bet, contract, bettor, isSelf, hideOutcome } = props const { bet, contract, bettor, isSelf, hideOutcome } = props
const { outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const { amount, outcome, createdTime } = bet const { amount, outcome, createdTime } = bet
const bought = amount >= 0 ? 'bought' : 'sold' const bought = amount >= 0 ? 'bought' : 'sold'
@ -97,7 +100,10 @@ export function BetStatusText(props: {
value={(bet as any).value} value={(bet as any).value}
contract={contract} contract={contract}
truncate="short" truncate="short"
/> />{' '}
{isPseudoNumeric
? ' than ' + formatNumericProbability(bet.probAfter, contract)
: ' at ' + formatPercent(bet.probAfter)}
</> </>
)} )}
<RelativeTimestamp time={createdTime} /> <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 { useUsers } from 'web/hooks/use-users'
import { User } from 'common/user' import { User } from 'common/user'
import { Fragment, useMemo, useState } from 'react' import { Fragment, useMemo, useState } from 'react'
@ -6,13 +6,24 @@ import clsx from 'clsx'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
export function FilterSelectUsers(props: { export function FilterSelectUsers(props: {
setSelectedUsers: (users: User[]) => void setSelectedUsers: (users: User[]) => void
selectedUsers: User[] selectedUsers: User[]
ignoreUserIds: string[] 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 users = useUsers()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [filteredUsers, setFilteredUsers] = useState<User[]>([]) const [filteredUsers, setFilteredUsers] = useState<User[]>([])
@ -24,94 +35,124 @@ export function FilterSelectUsers(props: {
return ( return (
!selectedUsers.map((user) => user.name).includes(user.name) && !selectedUsers.map((user) => user.name).includes(user.name) &&
!ignoreUserIds.includes(user.id) && !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]) }, [beginQuerying, users, selectedUsers, ignoreUserIds, query])
const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true
return ( return (
<div> <div>
<div className="relative mt-1 rounded-md"> {shouldShow && (
<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 className="relative mt-1 rounded-md">
</div> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<input <UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
type="text" </div>
name="user name" <input
id="user name" type="text"
value={query} name="user name"
onChange={(e) => setQuery(e.target.value)} id="user name"
className="input input-bordered block w-full pl-10 focus:border-gray-300 " value={query}
placeholder="Austin Chen" onChange={(e) => setQuery(e.target.value)}
/> className="input input-bordered block w-full pl-10 focus:border-gray-300 "
</div> placeholder="Austin Chen"
<Menu />
as="div" </div>
className={clsx( <Menu
'relative inline-block w-full overflow-y-scroll text-right', as="div"
beginQuerying && 'h-36' 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"
> >
<Menu.Items {({}) => (
static={true} <Transition
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" show={beginQuerying}
> as={Fragment}
<div className="py-1"> enter="transition ease-out duration-100"
{filteredUsers.map((user: User) => ( enterFrom="transform opacity-0 scale-95"
<Menu.Item key={user.id}> enterTo="transform opacity-100 scale-100"
{({ active }) => ( leave="transition ease-in duration-75"
<span leaveFrom="transform opacity-100 scale-100"
className={clsx( leaveTo="transform opacity-0 scale-95"
active >
? 'bg-gray-100 text-gray-900' <Menu.Items
: 'text-gray-700', static={true}
'group flex items-center px-4 py-2 text-sm' 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={() => { </Menu.Item>
setQuery('') ))}
setSelectedUsers([...selectedUsers, user]) </div>
}} </Menu.Items>
> </Transition>
<Avatar )}
username={user.username} </Menu>
avatarUrl={user.avatarUrl} </>
size={'xs'} )}
className={'mr-2'}
/>
{user.name}
</span>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
)}
</Menu>
{selectedUsers.length > 0 && ( {selectedUsers.length > 0 && (
<> <>
<div className={'mb-2'}>Added members:</div> <div className={'mb-2'}>
<Row className="mt-0 grid grid-cols-6 gap-2"> {showSelectedUsersTitle && 'Added members:'}
</div>
<Row
className={clsx(
'mt-0 grid grid-cols-6 gap-2',
selectedUsersClassName
)}
>
{selectedUsers.map((user: User) => ( {selectedUsers.map((user: User) => (
<div key={user.id} className="col-span-2 flex items-center"> <div
<Avatar key={user.id}
username={user.username} className="col-span-2 flex flex-row items-center justify-between"
avatarUrl={user.avatarUrl} >
size={'sm'} <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> </div>
))} ))}
</Row> </Row>

View File

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

View File

@ -91,6 +91,9 @@ export function GroupChat(props: {
setReplyToUsername('') setReplyToUsername('')
inputRef?.focus() inputRef?.focus()
} }
function focusInput() {
inputRef?.focus()
}
return ( return (
<Col className={'flex-1'}> <Col className={'flex-1'}>
@ -117,7 +120,13 @@ export function GroupChat(props: {
))} ))}
{messages.length === 0 && ( {messages.length === 0 && (
<div className="p-2 text-gray-500"> <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> </div>
)} )}
</Col> </Col>

View File

@ -22,7 +22,7 @@ export function GroupSelector(props: {
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const memberGroups = useMemberGroups(creator) const memberGroups = useMemberGroups(creator?.id)
const filteredGroups = memberGroups const filteredGroups = memberGroups
? query === '' ? query === ''
? memberGroups ? 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 { Fragment, ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from '@headlessui/react'
import clsx from 'clsx'
// From https://tailwindui.com/components/application-ui/overlays/modals // From https://tailwindui.com/components/application-ui/overlays/modals
export function Modal(props: { export function Modal(props: {
children: ReactNode children: ReactNode
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
className?: string
}) { }) {
const { children, open, setOpen } = props const { children, open, setOpen, className } = props
return ( return (
<Transition.Root show={open} as={Fragment}> <Transition.Root show={open} as={Fragment}>
@ -45,7 +47,12 @@ export function Modal(props: {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 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} {children}
</div> </div>
</Transition.Child> </Transition.Child>

View File

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

View File

@ -3,7 +3,6 @@ import Link from 'next/link'
import { import {
HomeIcon, HomeIcon,
MenuAlt3Icon, MenuAlt3Icon,
PresentationChartLineIcon,
SearchIcon, SearchIcon,
XIcon, XIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
function getNavigation(username: string) { function getNavigation() {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{
name: 'Portfolio',
href: `/${username}?tab=bets`,
icon: PresentationChartLineIcon,
},
{ {
name: 'Notifications', name: 'Notifications',
href: `/notifications`, href: `/notifications`,
@ -55,38 +49,40 @@ export function BottomNavBar() {
} }
const navigationOptions = const navigationOptions =
user === null user === null ? signedOutNavigation : getNavigation()
? signedOutNavigation
: getNavigation(user?.username || 'error')
return ( 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"> <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) => ( {navigationOptions.map((item) => (
<NavBarItem key={item.name} item={item} currentPage={currentPage} /> <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 <div
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700" 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)} onClick={() => setSidebarOpen(true)}
> >
{user === null ? ( <MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
<> More
<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)}
</>
) : (
<></>
)}
</div> </div>
<MobileSidebar <MobileSidebar
@ -99,6 +95,7 @@ export function BottomNavBar() {
function NavBarItem(props: { item: Item; currentPage: string }) { function NavBarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props const { item, currentPage } = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
return ( return (
<Link href={item.href}> <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', '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' 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} {item.name}
</a> </a>
</Link> </Link>

View File

@ -6,8 +6,8 @@ import {
CashIcon, CashIcon,
HeartIcon, HeartIcon,
UserGroupIcon, UserGroupIcon,
ChevronDownIcon,
TrendingUpIcon, TrendingUpIcon,
ChatIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
@ -18,13 +18,16 @@ import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu' import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon' 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 { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button' import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics' import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group } from 'common/group' 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() { function getNavigation() {
return [ return [
@ -82,8 +85,20 @@ const signedOutNavigation = [
] ]
const signedOutMobileNavigation = [ const signedOutMobileNavigation = [
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
icon: BookOpenIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, { 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', name: 'About',
href: 'https://docs.manifold.markets/$how-to', 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() { function getMoreMobileNav() {
return [ return [
{ name: 'Send M$', href: '/links' },
{ name: 'Charity', href: '/charity' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Statistics', href: '/stats' }, { name: 'Leaderboards', href: '/leaderboards' },
{ {
name: 'Sign out', name: 'Sign out',
href: '#', href: '#',
@ -112,8 +122,9 @@ function getMoreMobileNav() {
export type Item = { export type Item = {
name: string name: string
trackingEventName?: string
href: string href: string
icon: React.ComponentType<{ className?: string }> icon?: React.ComponentType<{ className?: string }>
} }
function SidebarItem(props: { item: Item; currentPage: 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} aria-current={item.href == currentPage ? 'page' : undefined}
> >
<item.icon {item.icon && (
className={clsx( <item.icon
item.href == currentPage className={clsx(
? 'text-gray-500' item.href == currentPage
: 'text-gray-400 group-hover:text-gray-500', ? 'text-gray-500'
'-ml-1 mr-3 h-6 w-6 flex-shrink-0' : 'text-gray-400 group-hover:text-gray-500',
)} '-ml-1 mr-3 h-6 w-6 flex-shrink-0'
aria-hidden="true" )}
/> aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span> <span className="truncate">{item.name}</span>
</a> </a>
</Link> </Link>
@ -167,14 +180,6 @@ function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} /> 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 }) { export default function Sidebar(props: { className?: string }) {
const { className } = props const { className } = props
const router = useRouter() const router = useRouter()
@ -185,7 +190,7 @@ export default function Sidebar(props: { className?: string }) {
const mobileNavigationOptions = !user const mobileNavigationOptions = !user
? signedOutMobileNavigation ? signedOutMobileNavigation
: signedInMobileNavigation : signedInMobileNavigation
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({ const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({
name: group.name, name: group.name,
href: groupPath(group.slug), href: groupPath(group.slug),
})) }))
@ -193,31 +198,20 @@ export default function Sidebar(props: { className?: string }) {
return ( return (
<nav aria-label="Sidebar" className={className}> <nav aria-label="Sidebar" className={className}>
<ManifoldLogo className="pb-6" twoLine /> <ManifoldLogo className="pb-6" twoLine />
<CreateQuestionButton user={user} />
<Spacer h={4} />
{user && ( {user && (
<div className="mb-2" style={{ minHeight: 80 }}> <div className="w-full" style={{ minHeight: 80 }}>
<ProfileSummary user={user} /> <ProfileSummary user={user} />
</div> </div>
)} )}
{/* Mobile navigation */} {/* Mobile navigation */}
<div className="space-y-1 lg:hidden"> <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) => ( {mobileNavigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} /> <SidebarItem key={item.href} item={item} currentPage={currentPage} />
))} ))}
{!user && (
<SidebarItem
key={'Groups'}
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
currentPage={currentPage}
/>
)}
{user && ( {user && (
<MenuButton <MenuButton
@ -225,41 +219,83 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />} buttonContent={<MoreButton />}
/> />
)} )}
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
user={user}
/>
</div> </div>
{/* Desktop navigation */} {/* Desktop navigation */}
<div className="hidden space-y-1 lg:block"> <div className="hidden space-y-1 lg:block">
{navigationOptions.map((item) => {navigationOptions.map((item) => (
item.name === 'Notifications' ? ( <SidebarItem key={item.href} item={item} currentPage={currentPage} />
<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}
/>
)
)}
<MenuButton <MenuButton
menuItems={getMoreNavigation(user)} menuItems={getMoreNavigation(user)}
buttonContent={<MoreButton />} 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> </div>
<CreateQuestionButton user={user} />
</nav> </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 clsx from 'clsx'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react' 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 { useRouter } from 'next/router'
import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications' 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 }) { export default function NotificationsIcon(props: { className?: string }) {
const user = useUser() const user = useUser()
const notifications = usePreferredGroupedNotifications(user?.id, { const privateUser = usePrivateUser(user?.id)
const notifications = usePreferredGroupedNotifications(privateUser?.id, {
unseenOnly: true, unseenOnly: true,
}) })
const [seen, setSeen] = useState(false) 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() const router = useRouter()
useEffect(() => { useEffect(() => {
if (router.pathname.endsWith('notifications')) return setSeen(true) if (router.pathname.endsWith('notifications')) return setSeen(true)
@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
<div className={'relative'}> <div className={'relative'}>
{!seen && notifications && notifications.length > 0 && ( {!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"> <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> </div>
)} )}
<BellIcon className={clsx(props.className)} /> <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 { NumberCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button' import { ResolveConfirmationButton } from './confirmation-button'
import { resolveMarket } from 'web/lib/firebase/fn-call' import { NumericContract, PseudoNumericContract } from 'common/contract'
import { NumericContract } from 'common/contract' import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
import { BucketInput } from './bucket-input' import { BucketInput } from './bucket-input'
import { getPseudoProbability } from 'common/pseudo-numeric'
export function NumericResolutionPanel(props: { export function NumericResolutionPanel(props: {
creator: User creator: User
contract: NumericContract contract: NumericContract | PseudoNumericContract
className?: string className?: string
}) { }) {
useEffect(() => { useEffect(() => {
@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: {
}, []) }, [])
const { contract, className } = props const { contract, className } = props
const { min, max, outcomeType } = contract
const [outcomeMode, setOutcomeMode] = useState< const [outcomeMode, setOutcomeMode] = useState<
'NUMBER' | 'CANCEL' | undefined 'NUMBER' | 'CANCEL' | undefined
@ -32,22 +34,44 @@ export function NumericResolutionPanel(props: {
const [error, setError] = useState<string | undefined>(undefined) const [error, setError] = useState<string | undefined>(undefined)
const resolve = async () => { 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 if (outcomeMode === undefined || finalOutcome === undefined) return
setIsSubmitting(true) setIsSubmitting(true)
const result = await resolveMarket({ const boundedValue = Math.max(Math.min(max, value ?? 0), min)
outcome: finalOutcome,
value,
contractId: contract.id,
}).then((r) => r.data)
console.log('resolved', outcome, 'result:', result) const probabilityInt =
100 *
getPseudoProbability(
boundedValue,
min,
max,
outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
)
if (result?.status !== 'success') { try {
setError(result?.message || 'Error resolving market') 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) setIsSubmitting(false)
} }
@ -72,7 +96,7 @@ export function NumericResolutionPanel(props: {
{outcomeMode === 'NUMBER' && ( {outcomeMode === 'NUMBER' && (
<BucketInput <BucketInput
contract={contract} contract={contract as any}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onBucketChange={(v, o) => (setValue(v), setOutcome(o))} onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
/> />

View File

@ -19,11 +19,15 @@ export function OutcomeLabel(props: {
value?: number value?: number
}) { }) {
const { outcome, contract, truncate, value } = props 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} /> return <BinaryOutcomeLabel outcome={outcome as any} />
if (contract.outcomeType === 'NUMERIC') if (outcomeType === 'NUMERIC')
return ( return (
<span className="text-blue-500"> <span className="text-blue-500">
{value ?? getValueFromBucket(outcome, contract)} {value ?? getValueFromBucket(outcome, contract)}
@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) {
return <CancelLabel /> 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: { export function BinaryContractOutcomeLabel(props: {
contract: BinaryContract contract: BinaryContract
resolution: resolution resolution: resolution
@ -74,7 +87,7 @@ export function FreeResponseOutcomeLabel(props: {
if (resolution === 'CANCEL') return <CancelLabel /> if (resolution === 'CANCEL') return <CancelLabel />
if (resolution === 'MKT') return <MultiLabel /> 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} /> if (!chosen) return <AnswerNumberLabel number={resolution} />
return ( return (
<FreeResponseAnswerToolTip text={chosen.text}> <FreeResponseAnswerToolTip text={chosen.text}>
@ -98,6 +111,14 @@ export function YesLabel() {
return <span className="text-primary">YES</span> 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() { export function NoLabel() {
return <span className="text-red-400">NO</span> 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 }} margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
xScale={{ xScale={{
type: 'time', type: 'time',
min: points[0].x, min: points[0]?.x,
max: endDate, max: endDate,
}} }}
yScale={{ yScale={{
@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
enableGridY={true} enableGridY={true}
enableSlices="x" enableSlices="x"
animate={false} animate={false}
yFormat={(value) => formatMoney(+value)}
></ResponsiveLine> ></ResponsiveLine>
</div> </div>
) )

View File

@ -13,7 +13,7 @@ export const PortfolioValueSection = memo(
}) { }) {
const { portfolioHistory } = props const { portfolioHistory } = props
const lastPortfolioMetrics = last(portfolioHistory) const lastPortfolioMetrics = last(portfolioHistory)
const [portfolioPeriod] = useState<Period>('allTime') const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
return <div> No portfolio history data yet </div> return <div> No portfolio history data yet </div>
@ -33,9 +33,16 @@ export const PortfolioValueSection = memo(
</div> </div>
</Col> </Col>
</div> </div>
{ <select
//TODO: enable day/week/monthly as data becomes available 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> </Row>
<PortfolioValueGraph <PortfolioValueGraph
portfolioHistory={portfolioHistory} 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 { YesNoCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button' 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 { ProbabilitySelector } from './probability-selector'
import { DPM_CREATOR_FEE } from 'common/fees' import { DPM_CREATOR_FEE } from 'common/fees'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
@ -42,17 +42,22 @@ export function ResolutionPanel(props: {
setIsSubmitting(true) setIsSubmitting(true)
const result = await resolveMarket({ try {
outcome, const result = await resolveMarket({
contractId: contract.id, outcome,
probabilityInt: prob, contractId: contract.id,
}).then((r) => r.data) probabilityInt: prob,
})
console.log('resolved', outcome, 'result:', result) console.log('resolved', outcome, 'result:', result)
} catch (e) {
if (result?.status !== 'success') { if (e instanceof APIError) {
setError(result?.message || 'Error resolving market') setError(e.toString())
} else {
console.error(e)
setError('Error resolving market')
}
} }
setIsSubmitting(false) 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 { User } from 'common/user'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useState } from 'react' import { useState } from 'react'
@ -7,7 +7,7 @@ import clsx from 'clsx'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
export function SellButton(props: { export function SellButton(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
sharesOutcome: 'YES' | 'NO' | undefined sharesOutcome: 'YES' | 'NO' | undefined
shares: number shares: number
@ -16,7 +16,8 @@ export function SellButton(props: {
const { contract, user, sharesOutcome, shares, panelClassName } = props const { contract, user, sharesOutcome, shares, panelClassName } = props
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const [showSellModal, setShowSellModal] = useState(false) const [showSellModal, setShowSellModal] = useState(false)
const { mechanism } = contract const { mechanism, outcomeType } = contract
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
if (sharesOutcome && user && mechanism === 'cpmm-1') { if (sharesOutcome && user && mechanism === 'cpmm-1') {
return ( return (
@ -32,7 +33,10 @@ export function SellButton(props: {
)} )}
onClick={() => setShowSellModal(true)} onClick={() => setShowSellModal(true)}
> >
{'Sell ' + sharesOutcome} Sell{' '}
{isPseudoNumeric
? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome]
: sharesOutcome}
</button> </button>
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{'(' + Math.floor(shares) + ' shares)'} {'(' + 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 { Bet } from 'common/bet'
import { User } from 'common/user' import { User } from 'common/user'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
@ -11,7 +11,7 @@ import clsx from 'clsx'
export function SellSharesModal(props: { export function SellSharesModal(props: {
className?: string className?: string
contract: CPMMBinaryContract contract: CPMMBinaryContract | PseudoNumericContract
userBets: Bet[] userBets: Bet[]
shares: number shares: number
sharesOutcome: 'YES' | 'NO' 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 { User } from 'common/user'
import { useState } from 'react' import { useState } from 'react'
import { Col } from './layout/col' import { Col } from './layout/col'
@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
export function SellRow(props: { export function SellRow(props: {
contract: BinaryContract contract: BinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
className?: string 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 { useFollows } from 'web/hooks/use-follows'
import { FollowButton } from './follow-button' import { FollowButton } from './follow-button'
import { PortfolioMetrics } from 'common/user' 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: { export function UserLink(props: {
name: string name: string
@ -73,7 +76,9 @@ export function UserPage(props: {
'loading' 'loading'
) )
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading') const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([]) const [portfolioHistory, setUsersPortfolioHistory] = useState<
PortfolioMetrics[]
>([])
const [commentsByContract, setCommentsByContract] = useState< const [commentsByContract, setCommentsByContract] = useState<
Map<Contract, Comment[]> | 'loading' Map<Contract, Comment[]> | 'loading'
>('loading') >('loading')
@ -154,7 +159,7 @@ export function UserPage(props: {
<Avatar <Avatar
username={user.username} username={user.username}
avatarUrl={user.avatarUrl} avatarUrl={user.avatarUrl}
size={20} size={24}
className="bg-white ring-4 ring-white" className="bg-white ring-4 ring-white"
/> />
</div> </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"> <Row className="gap-4">
<FollowingButton user={user} /> <FollowingButton user={user} />
<FollowersButton user={user} /> <FollowersButton user={user} />
<ReferralsButton user={user} currentUser={currentUser} />
<GroupsButton user={user} />
</Row> </Row>
{user.website && ( {user.website && (
@ -254,7 +261,7 @@ export function UserPage(props: {
{usersContracts !== 'loading' && commentsByContract != 'loading' ? ( {usersContracts !== 'loading' && commentsByContract != 'loading' ? (
<Tabs <Tabs
className={'pb-2 pt-1 '} labelClassName={'pb-2 pt-1 '}
defaultIndex={ defaultIndex={
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0 defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
} }
@ -293,9 +300,9 @@ export function UserPage(props: {
title: 'Bets', title: 'Bets',
content: ( content: (
<div> <div>
{ <PortfolioValueSection
// TODO: add portfolio-value-section here portfolioHistory={portfolioHistory}
} />
<BetsList <BetsList
user={user} user={user}
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}

View File

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

View File

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

View File

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

View File

@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash'
export type NotificationGroup = { export type NotificationGroup = {
notifications: Notification[] notifications: Notification[]
sourceContractId: string groupedById: string
isSeen: boolean isSeen: boolean
timePeriod: string timePeriod: string
type: 'income' | 'normal'
} }
export function usePreferredGroupedNotifications( export function usePreferredGroupedNotifications(
@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) {
new Date(notification.createdTime).toDateString() new Date(notification.createdTime).toDateString()
) )
Object.keys(notificationGroupsByDay).forEach((day) => { 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( const groupedNotificationsByContractId = groupBy(
notificationGroupsByDay[day], normalNotificationsGroupedByDay,
(notification) => { (notification) => {
return notification.sourceContractId return notification.sourceContractId
} }
) )
notificationGroups = notificationGroups.concat( notificationGroups = notificationGroups.concat(
map(groupedNotificationsByContractId, (notifications, contractId) => { 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 // Create a notification group for each contract within each day
const notificationGroup: NotificationGroup = { const notificationGroup: NotificationGroup = {
notifications: groupedNotificationsByContractId[contractId].sort( notifications: notificationsForContractId,
(a, b) => { groupedById: contractId,
return b.createdTime - a.createdTime isSeen: notificationsForContractId[0].isSeen,
}
),
sourceContractId: contractId,
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
timePeriod: day, timePeriod: day,
type: 'normal',
} }
return notificationGroup return notificationGroup
}) })
@ -64,11 +83,11 @@ export function groupNotifications(notifications: Notification[]) {
return notificationGroups return notificationGroups
} }
function usePreferredNotifications( export function usePreferredNotifications(
userId: string | undefined, 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 [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [userAppropriateNotifications, setUserAppropriateNotifications] = const [userAppropriateNotifications, setUserAppropriateNotifications] =
@ -93,9 +112,11 @@ function usePreferredNotifications(
const notificationsToShow = getAppropriateNotifications( const notificationsToShow = getAppropriateNotifications(
notifications, notifications,
privateUser.notificationPreferences privateUser.notificationPreferences
).filter((n) =>
customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref
) )
setUserAppropriateNotifications(notificationsToShow) setUserAppropriateNotifications(notificationsToShow)
}, [privateUser, notifications]) }, [privateUser, notifications, customHref])
return userAppropriateNotifications return userAppropriateNotifications
} }
@ -117,7 +138,7 @@ function getAppropriateNotifications(
return notifications.filter( return notifications.filter(
(n) => (n) =>
n.reason && 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)) (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
) )
if (notificationPreferences === 'none') return [] 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 { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query' import { QueryClient } from 'react-query'
import { DocumentData } from 'firebase/firestore' import { doc, DocumentData } from 'firebase/firestore'
import { PrivateUser } from 'common/user' import { PrivateUser } from 'common/user'
import { import {
getUser, getUser,
@ -10,7 +10,7 @@ import {
listenForPrivateUser, listenForPrivateUser,
listenForUser, listenForUser,
User, User,
userDocRef, users,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality' import { useStateCheckEquality } from './use-state-check-equality'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics' import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
@ -49,7 +49,7 @@ export const usePrivateUser = (userId?: string) => {
export const useUserById = (userId: string) => { export const useUserById = (userId: string) => {
const result = useFirestoreDocumentData<DocumentData, User>( const result = useFirestoreDocumentData<DocumentData, User>(
['users', userId], ['users', userId],
userDocRef(userId), doc(users, userId),
{ subscribe: true, includeMetadataChanges: true } { subscribe: true, includeMetadataChanges: true }
) )

View File

@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => {
'Origin', 'Origin',
]) ])
const hasBody = req.method != 'HEAD' && req.method != 'GET' 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) return fetch(url, opts)
} }

View File

@ -41,14 +41,23 @@ export async function call(url: string, method: string, params: any) {
// one less hop // one less hop
export function getFunctionUrl(name: string) { export function getFunctionUrl(name: string) {
const { cloudRunId, cloudRunRegion } = ENV_CONFIG if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app` 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) { export function createMarket(params: any) {
return call(getFunctionUrl('createmarket'), 'POST', params) return call(getFunctionUrl('createmarket'), 'POST', params)
} }
export function resolveMarket(params: any) {
return call(getFunctionUrl('resolvemarket'), 'POST', params)
}
export function placeBet(params: any) { export function placeBet(params: any) {
return call(getFunctionUrl('placebet'), 'POST', params) return call(getFunctionUrl('placebet'), 'POST', params)
} }
@ -64,3 +73,7 @@ export function sellBet(params: any) {
export function createGroup(params: any) { export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params) 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 dayjs from 'dayjs'
import { import {
getFirestore,
doc, doc,
setDoc, setDoc,
deleteDoc, deleteDoc,
@ -16,8 +15,7 @@ import {
} from 'firebase/firestore' } from 'firebase/firestore'
import { sortBy, sum } from 'lodash' import { sortBy, sum } from 'lodash'
import { app } from './init' import { coll, getValues, listenForValue, listenForValues } from './utils'
import { getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract } from 'common/contract' import { BinaryContract, Contract } from 'common/contract'
import { getDpmProbability } from 'common/calculate-dpm' import { getDpmProbability } from 'common/calculate-dpm'
import { createRNG, shuffle } from 'common/util/random' 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 { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
export const contracts = coll<Contract>('contracts')
export type { Contract } export type { Contract }
export function contractPath(contract: 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 // Push contract to Firestore
export async function setContract(contract: Contract) { export async function setContract(contract: Contract) {
const docRef = doc(db, 'contracts', contract.id) await setDoc(doc(contracts, contract.id), contract)
await setDoc(docRef, contract)
} }
export async function updateContract( export async function updateContract(
contractId: string, contractId: string,
update: Partial<Contract> update: Partial<Contract>
) { ) {
const docRef = doc(db, 'contracts', contractId) await updateDoc(doc(contracts, contractId), update)
await updateDoc(docRef, update)
} }
export async function getContractFromId(contractId: string) { export async function getContractFromId(contractId: string) {
const docRef = doc(db, 'contracts', contractId) const result = await getDoc(doc(contracts, contractId))
const result = await getDoc(docRef) return result.exists() ? result.data() : undefined
return result.exists() ? (result.data() as Contract) : undefined
} }
export async function getContractFromSlug(slug: string) { 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) const snapshot = await getDocs(q)
return snapshot.empty ? undefined : snapshot.docs[0].data()
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
} }
export async function deleteContract(contractId: string) { export async function deleteContract(contractId: string) {
const docRef = doc(db, 'contracts', contractId) await deleteDoc(doc(contracts, contractId))
await deleteDoc(docRef)
} }
export async function listContracts(creatorId: string): Promise<Contract[]> { export async function listContracts(creatorId: string): Promise<Contract[]> {
const q = query( const q = query(
contractCollection, contracts,
where('creatorId', '==', creatorId), where('creatorId', '==', creatorId),
orderBy('createdTime', 'desc') orderBy('createdTime', 'desc')
) )
const snapshot = await getDocs(q) 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( export async function listTaggedContractsCaseInsensitive(
tag: string tag: string
): Promise<Contract[]> { ): Promise<Contract[]> {
const q = query( const q = query(
contractCollection, contracts,
where('lowercaseTags', 'array-contains', tag.toLowerCase()), where('lowercaseTags', 'array-contains', tag.toLowerCase()),
orderBy('createdTime', 'desc') orderBy('createdTime', 'desc')
) )
const snapshot = await getDocs(q) 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( export async function listAllContracts(
n: number, n: number,
before?: string before?: string
): Promise<Contract[]> { ): Promise<Contract[]> {
let q = query(contractCollection, orderBy('createdTime', 'desc'), limit(n)) let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
if (before != null) { if (before != null) {
const snap = await getDoc(doc(db, 'contracts', before)) const snap = await getDoc(doc(contracts, before))
q = query(q, startAfter(snap)) q = query(q, startAfter(snap))
} }
const snapshot = await getDocs(q) const snapshot = await getDocs(q)
return snapshot.docs.map((doc) => doc.data() as Contract) return snapshot.docs.map((doc) => doc.data())
} }
export function listenForContracts( export function listenForContracts(
setContracts: (contracts: Contract[]) => void setContracts: (contracts: Contract[]) => void
) { ) {
const q = query(contractCollection, orderBy('createdTime', 'desc')) const q = query(contracts, orderBy('createdTime', 'desc'))
return listenForValues<Contract>(q, setContracts) return listenForValues<Contract>(q, setContracts)
} }
@ -171,7 +161,7 @@ export function listenForUserContracts(
setContracts: (contracts: Contract[]) => void setContracts: (contracts: Contract[]) => void
) { ) {
const q = query( const q = query(
contractCollection, contracts,
where('creatorId', '==', creatorId), where('creatorId', '==', creatorId),
orderBy('createdTime', 'desc') orderBy('createdTime', 'desc')
) )
@ -179,7 +169,7 @@ export function listenForUserContracts(
} }
const activeContractsQuery = query( const activeContractsQuery = query(
contractCollection, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
where('visibility', '==', 'public'), where('visibility', '==', 'public'),
where('volume7Days', '>', 0) where('volume7Days', '>', 0)
@ -196,7 +186,7 @@ export function listenForActiveContracts(
} }
const inactiveContractsQuery = query( const inactiveContractsQuery = query(
contractCollection, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
where('closeTime', '>', Date.now()), where('closeTime', '>', Date.now()),
where('visibility', '==', 'public'), where('visibility', '==', 'public'),
@ -214,7 +204,7 @@ export function listenForInactiveContracts(
} }
const newContractsQuery = query( const newContractsQuery = query(
contractCollection, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
where('volume7Days', '==', 0), where('volume7Days', '==', 0),
where('createdTime', '>', Date.now() - 7 * DAY_MS) where('createdTime', '>', Date.now() - 7 * DAY_MS)
@ -230,7 +220,7 @@ export function listenForContract(
contractId: string, contractId: string,
setContract: (contract: Contract | null) => void setContract: (contract: Contract | null) => void
) { ) {
const contractRef = doc(contractCollection, contractId) const contractRef = doc(contracts, contractId)
return listenForValue<Contract>(contractRef, setContract) return listenForValue<Contract>(contractRef, setContract)
} }
@ -242,7 +232,7 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
} }
const hotContractsQuery = query( const hotContractsQuery = query(
contractCollection, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
where('visibility', '==', 'public'), where('visibility', '==', 'public'),
orderBy('volume24Hours', 'desc'), orderBy('volume24Hours', 'desc'),
@ -262,22 +252,22 @@ export function listenForHotContracts(
} }
export async function getHotContracts() { export async function getHotContracts() {
const contracts = await getValues<Contract>(hotContractsQuery) const data = await getValues<Contract>(hotContractsQuery)
return sortBy( return sortBy(
chooseRandomSubset(contracts, 10), chooseRandomSubset(data, 10),
(contract) => -1 * contract.volume24Hours (contract) => -1 * contract.volume24Hours
) )
} }
export async function getContractsBySlugs(slugs: string[]) { 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 snapshot = await getDocs(q)
const contracts = snapshot.docs.map((doc) => doc.data() as Contract) const data = snapshot.docs.map((doc) => doc.data())
return sortBy(contracts, (contract) => -1 * contract.volume24Hours) return sortBy(data, (contract) => -1 * contract.volume24Hours)
} }
const topWeeklyQuery = query( const topWeeklyQuery = query(
contractCollection, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
orderBy('volume7Days', 'desc'), orderBy('volume7Days', 'desc'),
limit(MAX_FEED_CONTRACTS) limit(MAX_FEED_CONTRACTS)
@ -287,7 +277,7 @@ export async function getTopWeeklyContracts() {
} }
const closingSoonQuery = query( const closingSoonQuery = query(
contractCollection, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
where('visibility', '==', 'public'), where('visibility', '==', 'public'),
where('closeTime', '>', Date.now()), where('closeTime', '>', Date.now()),
@ -296,15 +286,12 @@ const closingSoonQuery = query(
) )
export async function getClosingSoonContracts() { export async function getClosingSoonContracts() {
const contracts = await getValues<Contract>(closingSoonQuery) const data = await getValues<Contract>(closingSoonQuery)
return sortBy( return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
chooseRandomSubset(contracts, 2),
(contract) => contract.closeTime
)
} }
export async function getRecentBetsAndComments(contract: Contract) { 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([ const [recentBets, recentComments] = await Promise.all([
getValues<Bet>( getValues<Bet>(

View File

@ -29,17 +29,6 @@ export const createAnswer = cloudFunction<
} }
>('createAnswer') >('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> = () => { export const createUser: () => Promise<User | null> = () => {
const local = safeLocalStorage() const local = safeLocalStorage()
let deviceToken = local?.getItem('device-token') let deviceToken = local?.getItem('device-token')

View File

@ -1,19 +1,24 @@
import { import {
collection,
deleteDoc, deleteDoc,
doc, doc,
getDocs,
query, query,
updateDoc, updateDoc,
where, where,
} from 'firebase/firestore' } from 'firebase/firestore'
import { sortBy } from 'lodash' import { sortBy, uniq } from 'lodash'
import { Group } from 'common/group' import { Group } from 'common/group'
import { getContractFromId } from './contracts' import { getContractFromId } from './contracts'
import { db } from './init' import {
import { getValue, getValues, listenForValue, listenForValues } from './utils' coll,
getValue,
getValues,
listenForValue,
listenForValues,
} from './utils'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
const groupCollection = collection(db, 'groups') export const groups = coll<Group>('groups')
export function groupPath( export function groupPath(
groupSlug: string, groupSlug: string,
@ -23,30 +28,29 @@ export function groupPath(
} }
export function updateGroup(group: Group, updates: Partial<Group>) { 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) { export function deleteGroup(group: Group) {
return deleteDoc(doc(groupCollection, group.id)) return deleteDoc(doc(groups, group.id))
} }
export async function listAllGroups() { export async function listAllGroups() {
return getValues<Group>(groupCollection) return getValues<Group>(groups)
} }
export function listenForGroups(setGroups: (groups: Group[]) => void) { export function listenForGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(groupCollection, setGroups) return listenForValues(groups, setGroups)
} }
export function getGroup(groupId: string) { export function getGroup(groupId: string) {
return getValue<Group>(doc(groupCollection, groupId)) return getValue<Group>(doc(groups, groupId))
} }
export async function getGroupBySlug(slug: string) { export async function getGroupBySlug(slug: string) {
const q = query(groupCollection, where('slug', '==', slug)) const q = query(groups, where('slug', '==', slug))
const groups = await getValues<Group>(q) const docs = (await getDocs(q)).docs
return docs.length === 0 ? null : docs[0].data()
return groups.length === 0 ? null : groups[0]
} }
export async function getGroupContracts(group: Group) { export async function getGroupContracts(group: Group) {
@ -68,14 +72,14 @@ export function listenForGroup(
groupId: string, groupId: string,
setGroup: (group: Group | null) => void setGroup: (group: Group | null) => void
) { ) {
return listenForValue(doc(groupCollection, groupId), setGroup) return listenForValue(doc(groups, groupId), setGroup)
} }
export function listenForMemberGroups( export function listenForMemberGroups(
userId: string, userId: string,
setGroups: (groups: Group[]) => void 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) => { return listenForValues<Group>(q, (groups) => {
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime]) const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
@ -87,10 +91,51 @@ export async function getGroupsWithContractId(
contractId: string, contractId: string,
setGroups: (groups: Group[]) => void setGroups: (groups: Group[]) => void
) { ) {
const q = query( const q = query(groups, where('contractIds', 'array-contains', contractId))
groupCollection, setGroups(await getValues<Group>(q))
where('contractIds', 'array-contains', contractId) }
)
const groups = await getValues<Group>(q) export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
setGroups(groups) // 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