Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-10-11 12:43:58 -07:00 committed by GitHub
commit 2e01e2d76b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
222 changed files with 6062 additions and 3334 deletions

View File

@ -0,0 +1,17 @@
name: Merge main into main2 on every commit
on:
push:
branches:
- 'main'
jobs:
merge-branch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Merge main -> main2
uses: devmasx/merge-branch@master
with:
type: now
target_branch: main2
github_token: ${{ github.token }}

View File

@ -1,4 +1,4 @@
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm' import { getCpmmLiquidity } from './calculate-cpmm'
import { CPMMContract } from './contract' import { CPMMContract } from './contract'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
@ -8,25 +8,23 @@ export const getNewLiquidityProvision = (
contract: CPMMContract, contract: CPMMContract,
newLiquidityProvisionId: string newLiquidityProvisionId: string
) => { ) => {
const { pool, p, totalLiquidity } = contract const { pool, p, totalLiquidity, subsidyPool } = contract
const { newPool, newP } = addCpmmLiquidity(pool, p, amount) const liquidity = getCpmmLiquidity(pool, p)
const liquidity =
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
const newLiquidityProvision: LiquidityProvision = { const newLiquidityProvision: LiquidityProvision = {
id: newLiquidityProvisionId, id: newLiquidityProvisionId,
userId: userId, userId: userId,
contractId: contract.id, contractId: contract.id,
amount, amount,
pool: newPool, pool,
p: newP, p,
liquidity, liquidity,
createdTime: Date.now(), createdTime: Date.now(),
} }
const newTotalLiquidity = (totalLiquidity ?? 0) + amount const newTotalLiquidity = (totalLiquidity ?? 0) + amount
const newSubsidyPool = (subsidyPool ?? 0) + amount
return { newLiquidityProvision, newPool, newP, newTotalLiquidity } return { newLiquidityProvision, newTotalLiquidity, newSubsidyPool }
} }

123
common/badge.ts Normal file
View File

@ -0,0 +1,123 @@
import { User } from './user'
export type Badge = {
type: BadgeTypes
createdTime: number
data: { [key: string]: any }
name: 'Proven Correct' | 'Streaker' | 'Market Creator'
}
export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR'
export type ProvenCorrectBadgeData = {
type: 'PROVEN_CORRECT'
data: {
contractSlug: string
contractCreatorUsername: string
contractTitle: string
commentId: string
betAmount: number
}
}
export type MarketCreatorBadgeData = {
type: 'MARKET_CREATOR'
data: {
totalContractsCreated: number
}
}
export type StreakerBadgeData = {
type: 'STREAKER'
data: {
totalBettingStreak: number
}
}
export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData
export type StreakerBadge = Badge & StreakerBadgeData
export type MarketCreatorBadge = Badge & MarketCreatorBadgeData
export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5
export const provenCorrectRarityThresholds = [1, 1000, 10000]
const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => {
const { betAmount } = badge.data
const thresholdArray = provenCorrectRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (betAmount >= thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export const streakerBadgeRarityThresholds = [1, 50, 250]
const calculateStreakerBadgeRarity = (badge: StreakerBadge) => {
const { totalBettingStreak } = badge.data
const thresholdArray = streakerBadgeRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (totalBettingStreak == thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export const marketCreatorBadgeRarityThresholds = [1, 75, 300]
const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => {
const { totalContractsCreated } = badge.data
const thresholdArray = marketCreatorBadgeRarityThresholds
let i = thresholdArray.length - 1
while (i >= 0) {
if (totalContractsCreated == thresholdArray[i]) {
return i + 1
}
i--
}
return 1
}
export type rarities = 'bronze' | 'silver' | 'gold'
const rarityRanks: { [key: number]: rarities } = {
1: 'bronze',
2: 'silver',
3: 'gold',
}
export const calculateBadgeRarity = (badge: Badge) => {
switch (badge.type) {
case 'PROVEN_CORRECT':
return rarityRanks[
calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge)
]
case 'MARKET_CREATOR':
return rarityRanks[
calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge)
]
case 'STREAKER':
return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
default:
return rarityRanks[0]
}
}
export const getBadgesByRarity = (user: User | null | undefined) => {
const rarities: { [key in rarities]: number } = {
bronze: 0,
silver: 0,
gold: 0,
}
if (!user) return rarities
Object.values(user.achievements).map((value) => {
value.badges.map((badge) => {
rarities[calculateBadgeRarity(badge)] =
(rarities[calculateBadgeRarity(badge)] ?? 0) + 1
})
})
return rarities
}

View File

@ -1,11 +1,10 @@
import { sum, groupBy, mapValues, sumBy } from 'lodash' import { groupBy, mapValues, sumBy } from 'lodash'
import { LimitBet } from './bet' import { LimitBet } from './bet'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet' import { computeFills } from './new-bet'
import { binarySearch } from './util/algos' import { binarySearch } from './util/algos'
import { addObjects } from './util/object'
export type CpmmState = { export type CpmmState = {
pool: { [outcome: string]: number } pool: { [outcome: string]: number }
@ -147,7 +146,8 @@ function calculateAmountToBuyShares(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
// Search for amount between bounds (0, shares). // Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each. // Min share price is M$0, and max is M$1 each.
@ -157,7 +157,8 @@ function calculateAmountToBuyShares(
amount, amount,
state, state,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
const totalShares = sumBy(takers, (taker) => taker.shares) const totalShares = sumBy(takers, (taker) => taker.shares)
@ -169,7 +170,8 @@ export function calculateCpmmSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
if (Math.round(shares) < 0) { if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares') throw new Error('Cannot sell non-positive shares')
@ -180,15 +182,17 @@ export function calculateCpmmSale(
state, state,
shares, shares,
oppositeOutcome, oppositeOutcome,
unfilledBets unfilledBets,
balanceByUserId
) )
const { cpmmState, makers, takers, totalFees } = computeFills( const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
oppositeOutcome, oppositeOutcome,
buyAmount, buyAmount,
state, state,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
// Transform buys of opposite outcome into sells. // Transform buys of opposite outcome into sells.
@ -211,6 +215,7 @@ export function calculateCpmmSale(
fees: totalFees, fees: totalFees,
makers, makers,
takers: saleTakers, takers: saleTakers,
ordersToCancel,
} }
} }
@ -218,9 +223,16 @@ export function getCpmmProbabilityAfterSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) const { cpmmState } = calculateCpmmSale(
state,
shares,
outcome,
unfilledBets,
balanceByUserId
)
return getCpmmProbability(cpmmState.pool, cpmmState.p) return getCpmmProbability(cpmmState.pool, cpmmState.p)
} }
@ -254,48 +266,22 @@ export function addCpmmLiquidity(
return { newPool, liquidity, newP } return { newPool, liquidity, newP }
} }
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => { export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) {
const oldLiquidity = getCpmmLiquidity(l.pool, p) const userAmounts = groupBy(liquidities, (w) => w.userId)
const totalAmount = sumBy(liquidities, (w) => w.amount)
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount }) return mapValues(
const newLiquidity = getCpmmLiquidity(newPool, p) userAmounts,
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
const liquidity = newLiquidity - oldLiquidity
return liquidity
}
export function getCpmmLiquidityPoolWeights(
state: CpmmState,
liquidities: LiquidityProvision[],
excludeAntes: boolean
) {
const calcLiqudity = calculateLiquidityDelta(state.p)
const liquidityShares = liquidities.map(calcLiqudity)
const shareSum = sum(liquidityShares)
const weights = liquidityShares.map((shares, i) => ({
weight: shares / shareSum,
providerId: liquidities[i].userId,
}))
const includedWeights = excludeAntes
? weights.filter((_, i) => !liquidities[i].isAnte)
: weights
const userWeights = groupBy(includedWeights, (w) => w.providerId)
const totalUserWeights = mapValues(userWeights, (userWeight) =>
sumBy(userWeight, (w) => w.weight)
) )
return totalUserWeights
} }
export function getUserLiquidityShares( export function getUserLiquidityShares(
userId: string, userId: string,
state: CpmmState, state: CpmmState,
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[]
excludeAntes: boolean
) { ) {
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes) const weights = getCpmmLiquidityPoolWeights(liquidities)
const userWeight = weights[userId] ?? 0 const userWeight = weights[userId] ?? 0
return mapValues(state.pool, (shares) => userWeight * shares) return mapValues(state.pool, (shares) => userWeight * shares)

View File

@ -1,9 +1,17 @@
import { last, sortBy, sum, sumBy } from 'lodash' import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash'
import { calculatePayout } from './calculate' import { calculatePayout, getContractBetMetrics } from './calculate'
import { Bet } from './bet' import { Bet, LimitBet } from './bet'
import { Contract } from './contract' import {
Contract,
CPMMBinaryContract,
CPMMContract,
DPMContract,
} from './contract'
import { PortfolioMetrics, User } from './user' import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time' import { DAY_MS } from './util/time'
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm'
import { removeUndefinedProps } from './util/object'
const computeInvestmentValue = ( const computeInvestmentValue = (
bets: Bet[], bets: Bet[],
@ -33,13 +41,81 @@ export const computeInvestmentValueCustomProb = (
const betP = outcome === 'YES' ? p : 1 - p const betP = outcome === 'YES' ? p : 1 - p
const payout = betP * shares const value = betP * shares
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0 if (isNaN(value)) return 0
return value return value
}) })
} }
export const computeElasticity = (
bets: Bet[],
contract: Contract,
betAmount = 50
) => {
const { mechanism, outcomeType } = contract
return mechanism === 'cpmm-1' &&
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC')
? computeBinaryCpmmElasticity(bets, contract, betAmount)
: computeDpmElasticity(contract, betAmount)
}
export const computeBinaryCpmmElasticity = (
bets: Bet[],
contract: CPMMContract,
betAmount: number
) => {
const limitBets = bets
.filter(
(b) =>
!b.isFilled &&
!b.isSold &&
!b.isRedemption &&
!b.sale &&
!b.isCancelled &&
b.limitProb !== undefined
)
.sort((a, b) => a.createdTime - b.createdTime) as LimitBet[]
const userIds = uniq(limitBets.map((b) => b.userId))
// Assume all limit orders are good.
const userBalances = Object.fromEntries(
userIds.map((id) => [id, Number.MAX_SAFE_INTEGER])
)
const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo(
'YES',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultYes = getCpmmProbability(poolY, pY)
const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo(
'NO',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultNo = getCpmmProbability(poolN, pN)
// handle AMM overflow
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
return safeYes - safeNo
}
export const computeDpmElasticity = (
contract: DPMContract,
betAmount: number
) => {
return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter( const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime (contract) => contract.createdTime >= startTime
@ -123,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
} }
const calculateProfitForPeriod = ( const calculateProfitForPeriod = (
startTime: number, startingPortfolio: PortfolioMetrics | undefined,
descendingPortfolio: PortfolioMetrics[],
currentProfit: number currentProfit: number
) => { ) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) { if (startingPortfolio === undefined) {
return currentProfit return currentProfit
} }
@ -145,33 +216,90 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
} }
export const calculateNewProfit = ( export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[], portfolioHistory: Record<
'current' | 'day' | 'week' | 'month',
PortfolioMetrics | undefined
>,
newPortfolio: PortfolioMetrics newPortfolio: PortfolioMetrics
) => { ) => {
const allTimeProfit = calculatePortfolioProfit(newPortfolio) const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = { const newProfit = {
daily: calculateProfitForPeriod( daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
Date.now() - 1 * DAY_MS, weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
descendingPortfolio, monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
allTime: allTimeProfit, allTime: allTimeProfit,
} }
return newProfit return newProfit
} }
export const calculateMetricsByContract = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const unresolvedContracts = Object.keys(betsByContract)
.map((cid) => contractsById[cid])
.filter((c) => c && !c.isResolved)
return unresolvedContracts.map((c) => {
const bets = betsByContract[c.id] ?? []
const current = getContractBetMetrics(c, bets)
let periodMetrics
if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') {
const periods = ['day', 'week', 'month'] as const
periodMetrics = Object.fromEntries(
periods.map((period) => [
period,
calculatePeriodProfit(c, bets, period),
])
)
}
return removeUndefinedProps({
contractId: c.id,
...current,
from: periodMetrics,
})
})
}
export type ContractMetrics = ReturnType<typeof calculateMetricsByContract>[number]
const calculatePeriodProfit = (
contract: CPMMBinaryContract,
bets: Bet[],
period: 'day' | 'week' | 'month'
) => {
const days = period === 'day' ? 1 : period === 'week' ? 7 : 30
const fromTime = Date.now() - days * DAY_MS
const previousBets = bets.filter((b) => b.createdTime < fromTime)
const prevProb = contract.prob - contract.probChanges[period]
const prob = contract.resolutionProbability
? contract.resolutionProbability
: contract.prob
const previousBetsValue = computeInvestmentValueCustomProb(
previousBets,
contract,
prevProb
)
const currentBetsValue = computeInvestmentValueCustomProb(
previousBets,
contract,
prob
)
const profit = currentBetsValue - previousBetsValue
const profitPercent =
previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue)
return {
profit,
profitPercent,
prevValue: previousBetsValue,
value: currentBetsValue,
}
}

View File

@ -78,7 +78,8 @@ export function calculateShares(
export function calculateSaleAmount( export function calculateSaleAmount(
contract: Contract, contract: Contract,
bet: Bet, bet: Bet,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' && return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' || (contract.outcomeType === 'BINARY' ||
@ -87,7 +88,8 @@ export function calculateSaleAmount(
contract, contract,
Math.abs(bet.shares), Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO', bet.outcome as 'YES' | 'NO',
unfilledBets unfilledBets,
balanceByUserId
).saleValue ).saleValue
: calculateDpmSaleAmount(contract, bet) : calculateDpmSaleAmount(contract, bet)
} }
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
contract: Contract, contract: Contract,
outcome: string, outcome: string,
shares: number, shares: number,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale( ? getCpmmProbabilityAfterSale(
contract, contract,
shares, shares,
outcome as 'YES' | 'NO', outcome as 'YES' | 'NO',
unfilledBets unfilledBets,
balanceByUserId
) )
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
} }
@ -174,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) {
}) })
} }
export type ContractBetMetrics = ReturnType<typeof getContractBetMetrics>
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const { resolution } = contract const { resolution } = contract
const isCpmm = contract.mechanism === 'cpmm-1' const isCpmm = contract.mechanism === 'cpmm-1'
@ -210,9 +216,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
} }
} }
const netPayout = payout - loan
const profit = payout + saleValue + redeemed - totalInvested const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100 const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets) const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
const hasShares = Object.values(totalShares).some( const hasShares = Object.values(totalShares).some(
@ -221,8 +226,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return { return {
invested, invested,
loan,
payout, payout,
netPayout,
profit, profit,
profitPercent, profitPercent,
totalShares, totalShares,
@ -233,8 +238,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() { export function getContractBetNullMetrics() {
return { return {
invested: 0, invested: 0,
loan: 0,
payout: 0, payout: 0,
netPayout: 0,
profit: 0, profit: 0,
profitPercent: 0, profitPercent: 0,
totalShares: {} as { [outcome: string]: number }, totalShares: {} as { [outcome: string]: number },

View File

@ -576,7 +576,7 @@ Work towards sustainable, systemic change.`,
If you would like to support our work, you can do so by getting involved or by donating.`, If you would like to support our work, you can do so by getting involved or by donating.`,
}, },
{ {
name: 'CaRLA', name: 'CaRLA',
website: 'https://carlaef.org/', website: 'https://carlaef.org/',
photo: 'https://i.imgur.com/IsNVTOY.png', photo: 'https://i.imgur.com/IsNVTOY.png',
@ -589,6 +589,14 @@ CaRLA uses legal advocacy and education to ensure all cities comply with their o
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`, In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
}, },
{
name: 'Mriya',
website: 'https://mriya-ua.org/',
photo:
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637',
preview: 'Donate supplies to soldiers in Ukraine',
description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
},
].map((charity) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return { return {

View File

@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
const { closeTime, groupLinks } = contract const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`) const groupHashtags = groupLinks?.map((g) => `#${g.name.replace(/ /g, '')}`)
return ( return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +

View File

@ -49,6 +49,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume: number volume: number
volume24Hours: number volume24Hours: number
volume7Days: number volume7Days: number
elasticity: number
collectedFees: Fees collectedFees: Fees
@ -62,7 +63,9 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
featuredOnHomeRank?: number featuredOnHomeRank?: number
likedByUserIds?: string[] likedByUserIds?: string[]
likedByUserCount?: number likedByUserCount?: number
flaggedByUsernames?: string[]
openCommentBounties?: number openCommentBounties?: number
unlistedById?: string
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary
@ -88,7 +91,8 @@ export type CPMM = {
mechanism: 'cpmm-1' mechanism: 'cpmm-1'
pool: { [outcome: string]: number } pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // in M$ totalLiquidity: number // for historical reasons, this the total subsidy amount added in M$
subsidyPool: number // current value of subsidy pool in M$
prob: number prob: number
probChanges: { probChanges: {
day: number day: number

View File

@ -11,8 +11,10 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
export const UNIQUE_BETTOR_LIQUIDITY = 20

View File

@ -1,3 +1,5 @@
export const FLAT_TRADE_FEE = 0.1 // M$0.1
export const PLATFORM_FEE = 0 export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0 export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0 export const LIQUIDITY_FEE = 0

View File

@ -39,3 +39,4 @@ export type GroupLink = {
createdTime: number createdTime: number
userId?: string userId?: string
} }
export type GroupContractDoc = { contractId: string; createdTime: number }

View File

@ -17,8 +17,7 @@ import {
import { import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, DPMContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
@ -144,7 +143,8 @@ export const computeFills = (
betAmount: number, betAmount: number,
state: CpmmState, state: CpmmState,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
if (isNaN(betAmount)) { if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}') throw new Error('Invalid bet amount: ${betAmount}')
@ -166,10 +166,12 @@ export const computeFills = (
shares: number shares: number
timestamp: number timestamp: number
}[] = [] }[] = []
const ordersToCancel: LimitBet[] = []
let amount = betAmount let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p } let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees let totalFees = noFees
const currentBalanceByUserId = { ...balanceByUserId }
let i = 0 let i = 0
while (true) { while (true) {
@ -186,9 +188,20 @@ export const computeFills = (
takers.push(taker) takers.push(taker)
} else { } else {
// Matched against bet. // Matched against bet.
i++
const { userId } = maker.bet
const makerBalance = currentBalanceByUserId[userId]
if (floatingGreaterEqual(makerBalance, maker.amount)) {
currentBalanceByUserId[userId] = makerBalance - maker.amount
} else {
// Insufficient balance. Cancel maker bet.
ordersToCancel.push(maker.bet)
continue
}
takers.push(taker) takers.push(taker)
makers.push(maker) makers.push(maker)
i++
} }
amount -= taker.amount amount -= taker.amount
@ -196,7 +209,7 @@ export const computeFills = (
if (floatingEqual(amount, 0)) break if (floatingEqual(amount, 0)) break
} }
return { takers, makers, totalFees, cpmmState } return { takers, makers, totalFees, cpmmState, ordersToCancel }
} }
export const getBinaryCpmmBetInfo = ( export const getBinaryCpmmBetInfo = (
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills( const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
outcome, outcome,
betAmount, betAmount,
{ pool, p }, { pool, p },
limitProb, limitProb,
unfilledBets unfilledBets,
balanceByUserId
) )
const probBefore = getCpmmProbability(contract.pool, contract.p) const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
newP: cpmmState.p, newP: cpmmState.p,
newTotalLiquidity, newTotalLiquidity,
makers, makers,
ordersToCancel,
} }
} }
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number, limitProb: number,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { newBet } = getBinaryCpmmBetInfo( const { newBet } = getBinaryCpmmBetInfo(
outcome, outcome,
betAmount ?? 0, betAmount ?? 0,
contract, contract,
limitProb, limitProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const remainingMatched = const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) / ((newBet.orderAmount ?? 0) - newBet.amount) /
@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract | MultipleChoiceContract contract: DPMContract
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -12,7 +12,6 @@ import {
visibility, visibility,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object' import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
@ -38,15 +37,6 @@ export function getNewContract(
answers: string[], answers: string[],
visibility: visibility visibility: visibility
) { ) {
const tags = parseTags(
[
question,
richTextToString(description),
...extraTags.map((tag) => `#${tag}`),
].join(' ')
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType = const propsByOutcomeType =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
@ -70,9 +60,10 @@ export function getNewContract(
question: question.trim(), question: question.trim(),
description, description,
tags, tags: [],
lowercaseTags, lowercaseTags: [],
visibility, visibility,
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
isResolved: false, isResolved: false,
createdTime: Date.now(), createdTime: Date.now(),
closeTime, closeTime,
@ -80,6 +71,7 @@ export function getNewContract(
volume: 0, volume: 0,
volume24Hours: 0, volume24Hours: 0,
volume7Days: 0, volume7Days: 0,
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
collectedFees: { collectedFees: {
creatorFee: 0, creatorFee: 0,
@ -120,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
mechanism: 'cpmm-1', mechanism: 'cpmm-1',
outcomeType: 'BINARY', outcomeType: 'BINARY',
totalLiquidity: ante, totalLiquidity: ante,
subsidyPool: 0,
initialProbability: p, initialProbability: p,
p, p,
pool: pool, pool: pool,

View File

@ -4,7 +4,7 @@ export type Notification = {
id: string id: string
userId: string userId: string
reasonText?: string reasonText?: string
reason?: notification_reason_types reason?: notification_reason_types | notification_preference
createdTime: number createdTime: number
viewTime?: number viewTime?: number
isSeen: boolean isSeen: boolean
@ -46,6 +46,7 @@ export type notification_source_types =
| 'loan' | 'loan'
| 'like' | 'like'
| 'tip_and_like' | 'tip_and_like'
| 'badge'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -96,6 +97,7 @@ type notification_descriptions = {
[key in notification_preference]: { [key in notification_preference]: {
simple: string simple: string
detailed: string detailed: string
necessary?: boolean
} }
} }
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
@ -208,8 +210,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets', detailed: 'Bonuses for unique predictors on your markets',
}, },
your_contract_closed: { your_contract_closed: {
simple: 'Your market has closed and you need to resolve it', simple: 'Your market has closed and you need to resolve it (necessary)',
detailed: 'Your market has closed and you need to resolve it', detailed: 'Your market has closed and you need to resolve it (necessary)',
necessary: true,
}, },
all_comments_on_watched_markets: { all_comments_on_watched_markets: {
simple: 'All new comments', simple: 'All new comments',
@ -235,6 +238,15 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`, simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`,
}, },
badges_awarded: {
simple: 'New badges awarded',
detailed: 'New badges you have earned',
},
opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)',
detailed:
'Opt out of all notifications excluding your own market closure notifications',
},
} }
export type BettingStreakData = { export type BettingStreakData = {

View File

@ -1,4 +1,3 @@
import { Bet } from './bet' import { Bet } from './bet'
import { getProbability } from './calculate' import { getProbability } from './calculate'
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm' import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
@ -56,10 +55,10 @@ export const getLiquidityPoolPayouts = (
outcome: string, outcome: string,
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool } = contract const { pool, subsidyPool } = contract
const finalPool = pool[outcome] const finalPool = pool[outcome] + subsidyPool
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) const weights = getCpmmLiquidityPoolWeights(liquidities)
return Object.entries(weights).map(([providerId, weight]) => ({ return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId, userId: providerId,
@ -95,10 +94,10 @@ export const getLiquidityPoolProbPayouts = (
p: number, p: number,
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool } = contract const { pool, subsidyPool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO const finalPool = p * pool.YES + (1 - p) * pool.NO + subsidyPool
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) const weights = getCpmmLiquidityPoolWeights(liquidities)
return Object.entries(weights).map(([providerId, weight]) => ({ return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId, userId: providerId,

View File

@ -3,6 +3,7 @@ import { JSONContent } from '@tiptap/core'
export type Post = { export type Post = {
id: string id: string
title: string title: string
subtitle: string
content: JSONContent content: JSONContent
creatorId: string // User id creatorId: string // User id
createdTime: number createdTime: number
@ -17,3 +18,4 @@ export type DateDoc = Post & {
} }
export const MAX_POST_TITLE_LENGTH = 480 export const MAX_POST_TITLE_LENGTH = 480
export const MAX_POST_SUBTITLE_LENGTH = 480

View File

@ -1,8 +1,9 @@
import { groupBy, sumBy, mapValues } from 'lodash' import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { getContractBetMetrics } from './calculate' import { getContractBetMetrics, resolvedPayout } from './calculate'
import { Contract } from './contract' import { Contract } from './contract'
import { ContractComment } from './comment'
export function scoreCreators(contracts: Contract[]) { export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues( const creatorScore = mapValues(
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
} }
export function scoreUsersByContract(contract: Contract, bets: Bet[]) { export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const betsByUser = groupBy(bets, bet => bet.userId) const betsByUser = groupBy(bets, (bet) => bet.userId)
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit) return mapValues(
betsByUser,
(bets) => getContractBetMetrics(contract, bets).profit
)
} }
export function addUserScores( export function addUserScores(
@ -43,3 +47,47 @@ export function addUserScores(
dest[userId] += score dest[userId] += score
} }
} }
export function scoreCommentorsAndBettors(
contract: Contract,
bets: Bet[],
comments: ContractComment[]
) {
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = betsById[topBetId]?.userName
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
const topCommentBetId = commentsById[topCommentId]?.betId
return {
topCommentId,
topBetId,
topBettor,
profitById,
commentsById,
betsById,
topCommentBetId,
}
}

View File

@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
contract: CPMMContract, contract: CPMMContract,
unfilledBets: LimitBet[], unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number },
loanPaid: number loanPaid: number
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
contract, contract,
shares, shares,
outcome, outcome,
unfilledBets unfilledBets,
balanceByUserId,
) )
const probBefore = getCpmmProbability(pool, p) const probBefore = getCpmmProbability(pool, p)
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
fees, fees,
makers, makers,
takers, takers,
ordersToCancel
} }
} }

View File

@ -53,6 +53,9 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[] profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[] onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[] thank_you_for_purchases: notification_destination_types[]
badges_awarded: notification_destination_types[]
opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
} }
export const getDefaultNotificationPreferences = ( export const getDefaultNotificationPreferences = (
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[] return filterDefined([browser, email]) as notification_destination_types[]
} }
return { const defaults: notification_preferences = {
// Watched Markets // Watched Markets
all_comments_on_watched_markets: constructPref(true, false), all_comments_on_watched_markets: constructPref(true, false),
all_answers_on_watched_markets: constructPref(true, false), all_answers_on_watched_markets: constructPref(true, false),
@ -107,7 +110,7 @@ export const getDefaultNotificationPreferences = (
loan_income: constructPref(true, false), loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false), betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true), referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, false), unique_bettors_on_your_contract: constructPref(true, true),
tipped_comments_on_watched_markets: constructPref(true, true), tipped_comments_on_watched_markets: constructPref(true, true),
tips_on_your_markets: constructPref(true, true), tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false), limit_order_fills: constructPref(true, false),
@ -121,7 +124,11 @@ export const getDefaultNotificationPreferences = (
probability_updates_on_watched_markets: constructPref(true, false), probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(false, false), thank_you_for_purchases: constructPref(false, false),
onboarding_flow: constructPref(false, false), onboarding_flow: constructPref(false, false),
} as notification_preferences
opt_out_all: [],
badges_awarded: constructPref(true, false),
}
return defaults
} }
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types // Adding a new key:value here is optional, you can just use a key of notification_subscription_types
@ -172,23 +179,44 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference reason: notification_reason_types | notification_preference
) => { ) => {
const notificationSettings = privateUser.notificationPreferences const notificationSettings = privateUser.notificationPreferences
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const unsubscribeEndpoint = getFunctionUrl('unsubscribe') const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return { try {
sendToEmail: destinations.includes('email'), let destinations
sendToBrowser: destinations.includes('browser'), let subscriptionType: notification_preference | undefined
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, if (Object.keys(notificationSettings).includes(reason)) {
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`, subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
} catch (e) {
// Fail safely
console.log(
`couldn't get notification destinations for type ${reason} for user ${privateUser.id}`
)
return {
sendToEmail: false,
sendToBrowser: false,
unsubscribeUrl: '',
urlToManageThisNotification: '',
}
} }
} }

View File

@ -1,5 +1,6 @@
import { notification_preferences } from './user-notification-preferences' import { notification_preferences } from './user-notification-preferences'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from './envs/constants'
import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
export type User = { export type User = {
id: string id: string
@ -11,7 +12,6 @@ export type User = {
// For their user page // For their user page
bio?: string bio?: string
bannerUrl?: string
website?: string website?: string
twitterHandle?: string twitterHandle?: string
discordHandle?: string discordHandle?: string
@ -33,6 +33,8 @@ export type User = {
allTime: number allTime: number
} }
fractionResolvedCorrectly: number
nextLoanCached: number nextLoanCached: number
followerCountCached: number followerCountCached: number
@ -49,6 +51,18 @@ export type User = {
hasSeenContractFollowModal?: boolean hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number freeMarketsCreated?: number
isBannedFromPosting?: boolean isBannedFromPosting?: boolean
achievements: {
provenCorrect?: {
badges: ProvenCorrectBadge[]
}
marketCreator?: {
badges: MarketCreatorBadge[]
}
streaker?: {
badges: StreakerBadge[]
}
}
} }
export type PrivateUser = { export type PrivateUser = {
@ -79,7 +93,8 @@ export type PortfolioMetrics = {
userId: string userId: string
} }
export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
// TODO: remove. Hardcoding the strings would be better. // TODO: remove. Hardcoding the strings would be better.

24
common/util/color.ts Normal file
View File

@ -0,0 +1,24 @@
export const interpolateColor = (color1: string, color2: string, p: number) => {
const rgb1 = parseInt(color1.replace('#', ''), 16)
const rgb2 = parseInt(color2.replace('#', ''), 16)
const [r1, g1, b1] = toArray(rgb1)
const [r2, g2, b2] = toArray(rgb2)
const q = 1 - p
const rr = Math.round(r1 * q + r2 * p)
const rg = Math.round(g1 * q + g2 * p)
const rb = Math.round(b1 * q + b2 * p)
const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16)
const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}`
return hex
}
function toArray(rgb: number) {
const r = rgb >> 16
const g = (rgb >> 8) % 256
const b = rgb % 256
return [r, g, b]
}

View File

@ -13,7 +13,9 @@ export function formatMoney(amount: number) {
Math.round(amount) === 0 Math.round(amount) === 0
? 0 ? 0
: // Handle 499.9999999999999 case : // Handle 499.9999999999999 case
Math.floor(amount + 0.00000000001 * Math.sign(amount)) (amount > 0 ? Math.floor : Math.ceil)(
amount + 0.00000000001 * Math.sign(amount)
)
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
} }
@ -58,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
return `${numStr}${suffix[i] ?? ''}` return `${numStr}${suffix[i] ?? ''}`
} }
export function shortFormatNumber(num: number): string {
if (num < 1000) return showPrecision(num, 3)
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const i = Math.floor(Math.log10(num) / 3)
const numStr = showPrecision(num / Math.pow(10, 3 * i), 2)
return `${numStr}${suffix[i] ?? ''}`
}
export function toCamelCase(words: string) { export function toCamelCase(words: string) {
const camelCase = words const camelCase = words
.split(' ') .split(' ')

View File

@ -1,4 +1,3 @@
import { MAX_TAG_LENGTH } from '../contract'
import { generateText, JSONContent } from '@tiptap/core' import { generateText, JSONContent } from '@tiptap/core'
// Tiptap starter extensions // Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote' import { Blockquote } from '@tiptap/extension-blockquote'
@ -24,7 +23,7 @@ import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe' import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs' import { find } from 'linkifyjs'
import { uniq } from 'lodash' import { cloneDeep, uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler' import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
@ -33,34 +32,6 @@ export function getUrl(text: string) {
return results.length ? results[0].href : null return results.length ? results[0].href : null
} }
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
const matches = (text.match(regex) || []).map((match) =>
match.trim().substring(1).substring(0, MAX_TAG_LENGTH)
)
const tagSet = new Set()
const uniqueTags: string[] = []
// Keep casing of last tag.
matches.reverse()
for (const tag of matches) {
const lowercase = tag.toLowerCase()
if (!tagSet.has(lowercase)) {
tagSet.add(lowercase)
uniqueTags.push(tag)
}
}
uniqueTags.reverse()
return uniqueTags
}
export function parseWordsAsTags(text: string) {
const taggedText = text
.split(/\s+/)
.map((tag) => (tag.startsWith('#') ? tag : `#${tag}`))
.join(' ')
return parseTags(taggedText)
}
// TODO: fuzzy matching // TODO: fuzzy matching
export const wordIn = (word: string, corpus: string) => export const wordIn = (word: string, corpus: string) =>
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase()) corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
@ -81,7 +52,7 @@ export function parseMentions(data: JSONContent): string[] {
} }
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports // can't just do [StarterKit, Image...] because it doesn't work with cjs imports
export const exhibitExts = [ const stringParseExts = [
Blockquote, Blockquote,
Bold, Bold,
BulletList, BulletList,
@ -101,12 +72,35 @@ export const exhibitExts = [
Image, Image,
Link, Link,
Mention, Mention, // user @mention
Mention.extend({ name: 'contract-mention' }), // market %mention
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler, TiptapSpoiler,
] ]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {
return !text ? '' : generateText(text, exhibitExts) if (!text) return ''
// remove spoiler tags.
const newText = cloneDeep(text)
dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]'
} else if (current.type === 'image') {
current.text = '[Image]'
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
} else if (current.type === 'iframe') {
const src = current.attrs?.['src'] ? current.attrs['src'] : ''
current.text = '[Iframe]' + (src ? ` url:${src}` : '')
// This is a hack, I've no idea how to change a tiptap extenstion's schema
current.type = 'text'
}
})
return generateText(newText, stringParseExts)
}
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
data.content?.forEach((d) => dfs(d, f))
f(data)
} }

View File

@ -39,7 +39,7 @@ export const TiptapSpoiler = Mark.create<SpoilerOptions>({
exitable: true, exitable: true,
content: 'inline*', content: 'inline*',
priority: 200, // higher priority than other formatting so they go inside priority: 1001, // higher priority than other formatting so they go inside
addOptions() { addOptions() {
return { return {

View File

@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties ## Awarded bounties
💥 *Awarded on 2022-10-07*
**[Pepe](https://manifold.markets/Pepe): M$10,000**
**[Jack](https://manifold.markets/jack): M$2,000**
**[Martin](https://manifold.markets/MartinRandall): M$2,000**
**[Yev](https://manifold.markets/Yev): M$2,000**
**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
**[Matt](https://manifold.markets/MattP): M$5,000**
**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
**[Yev](https://manifold.markets/Yev): M$5,000**
- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
🎈 *Awarded on 2022-06-14* 🎈 *Awarded on 2022-06-14*
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** **[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**

View File

@ -27,7 +27,7 @@ service cloud.firestore {
allow read; allow read;
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); .hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules // User referral rules
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
@ -44,6 +44,10 @@ service cloud.firestore {
allow read; allow read;
} }
match /{somePath=**}/contract-metrics/{contractId} {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{ match /{somePath=**}/challenges/{challengeId}{
allow read; allow read;
} }
@ -102,7 +106,7 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question']) .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid; && resource.data.creatorId == request.auth.uid;
allow update: if isAdmin(); allow update: if isAdmin();
match /comments/{commentId} { match /comments/{commentId} {

3
functions/.env.dev Normal file
View File

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

View File

@ -5,7 +5,7 @@
"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 && cp .env 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.prod dist && cp .env.dev 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",

View File

@ -3,24 +3,18 @@ import { z } from 'zod'
import { Contract, CPMMContract } from '../../common/contract' import { Contract, CPMMContract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { getNewLiquidityProvision } from '../../common/add-liquidity' import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
amount: z.number().gt(0), amount: z.number().gt(0),
}) })
export const addliquidity = newEndpoint({}, async (req, auth) => { export const addsubsidy = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body) const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') if (!isFinite(amount) || amount < 1) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions // run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => { return await firestore.runTransaction(async (transaction) => {
@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
.collection(`contracts/${contractId}/liquidity`) .collection(`contracts/${contractId}/liquidity`)
.doc() .doc()
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
getNewLiquidityProvision( getNewLiquidityProvision(
user.id, user.id,
amount, amount,
@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
newLiquidityProvisionDoc.id newLiquidityProvisionDoc.id
) )
if (newP !== undefined && !isFinite(newP)) { transaction.update(contractDoc, {
return { subsidyPool: newSubsidyPool,
status: 'error', totalLiquidity: newTotalLiquidity,
message: 'Liquidity injection rejected due to overflow error.', } as Partial<CPMMContract>)
}
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
const newBalance = user.balance - amount const newBalance = user.balance - amount
const newTotalDeposits = user.totalDeposits - amount const newTotalDeposits = user.totalDeposits - amount
@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
}) })
const firestore = admin.firestore() const firestore = admin.firestore()
export const addHouseLiquidity = (contract: CPMMContract, amount: number) => {
return firestore.runTransaction(async (transaction) => {
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
getNewLiquidityProvision(
providerId,
amount,
contract,
newLiquidityProvisionDoc.id
)
if (newP !== undefined && !isFinite(newP)) {
throw new APIError(
500,
'Liquidity injection rejected due to overflow error.'
)
}
transaction.update(
firestore.doc(`contracts/${contract.id}`),
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity,
})
)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
}, },
} as EndpointDefinition } as EndpointDefinition
} }
export const newEndpointNoAuth = (
endpointOpts: EndpointOptions,
fn: (req: Request) => Promise<Output>
) => {
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
return {
opts,
handler: async (req: Request, res: Response) => {
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
try {
if (opts.method !== req.method) {
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
}
res.status(200).json(await fn(req))
} catch (e) {
writeResponseError(e, res)
}
},
} as EndpointDefinition
}

View File

@ -6,7 +6,13 @@ import {
Notification, Notification,
notification_reason_types, notification_reason_types,
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USER_NAME,
MANIFOLD_USER_USERNAME,
PrivateUser,
User,
} from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils' import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
@ -30,27 +36,26 @@ import {
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow' import { ContractFollow } from '../../common/follow'
import { Badge } from 'common/badge'
const firestore = admin.firestore() const firestore = admin.firestore()
type recipients_to_reason_texts = { type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types } [userId: string]: { reason: notification_reason_types }
} }
export const createNotification = async ( export const createFollowOrMarketSubsidizedNotification = async (
sourceId: string, sourceId: string,
sourceType: 'contract' | 'liquidity' | 'follow', sourceType: 'liquidity' | 'follow',
sourceUpdateType: 'closed' | 'created', sourceUpdateType: 'created',
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
miscData?: { miscData?: {
contract?: Contract contract?: Contract
recipients?: string[] recipients?: string[]
slug?: string
title?: string
} }
) => { ) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {} const { contract: sourceContract, recipients } = miscData ?? {}
const shouldReceiveNotification = ( const shouldReceiveNotification = (
userId: string, userId: string,
@ -94,23 +99,15 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question, sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug, sourceContractSlug: sourceContract?.slug,
sourceSlug: slug ? slug : sourceContract?.slug, sourceSlug: sourceContract?.slug,
sourceTitle: title ? title : sourceContract?.question, sourceTitle: sourceContract?.question,
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
} }
if (!sendToEmail) continue if (!sendToEmail) continue
if (reason === 'your_contract_closed' && privateUser && sourceContract) { if (reason === 'subsidized_your_market') {
// TODO: include number and names of bettors waiting for creator to resolve their market
await sendMarketCloseEmail(
reason,
sourceUser,
privateUser,
sourceContract
)
} else if (reason === 'subsidized_your_market') {
// TODO: send email to creator of market that was subsidized // TODO: send email to creator of market that was subsidized
} else if (reason === 'on_new_follow') { } else if (reason === 'on_new_follow') {
// TODO: send email to user who was followed // TODO: send email to user who was followed
@ -127,20 +124,7 @@ export const createNotification = async (
reason: 'on_new_follow', reason: 'on_new_follow',
} }
return await sendNotificationsIfSettingsPermit(userToReasonTexts) return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if ( } else if (sourceType === 'liquidity' && sourceContract) {
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
userToReasonTexts[sourceContract.creatorId] = {
reason: 'your_contract_closed',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = { userToReasonTexts[sourceContract.creatorId] = {
reason: 'subsidized_your_market', reason: 'subsidized_your_market',
@ -1087,6 +1071,81 @@ export const createBountyNotification = async (
sourceTitle: contract.question, sourceTitle: contract.question,
} }
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
}
// maybe TODO: send email notification to comment creator
export const createBadgeAwardedNotification = async (
user: User,
badge: Badge
) => {
const privateUser = await getPrivateUser(user.id)
if (!privateUser) return
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'badges_awarded'
)
if (!sendToBrowser) return
const notificationRef = firestore
.collection(`/users/${user.id}/notifications`)
.doc()
const notification: Notification = {
id: notificationRef.id,
userId: user.id,
reason: 'badges_awarded',
createdTime: Date.now(),
isSeen: false,
sourceId: badge.type,
sourceType: 'badge',
sourceUpdateType: 'created',
sourceUserName: MANIFOLD_USER_NAME,
sourceUserUsername: MANIFOLD_USER_USERNAME,
sourceUserAvatarUrl: MANIFOLD_AVATAR_URL,
sourceText: `You earned a new ${badge.name} badge!`,
sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`,
sourceTitle: badge.name,
data: {
badge,
},
}
return await notificationRef.set(removeUndefinedProps(notification))
// TODO send email notification
}
export const createMarketClosedNotification = async (
contract: Contract,
creator: User,
privateUser: PrivateUser,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${creator.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: creator.id,
reason: 'your_contract_closed',
createdTime: Date.now(),
isSeen: false,
sourceId: contract.id,
sourceType: 'contract',
sourceUpdateType: 'closed',
sourceContractId: contract?.id,
sourceUserName: creator.name,
sourceUserUsername: creator.username,
sourceUserAvatarUrl: creator.avatarUrl,
sourceText: contract.closeTime?.toString() ?? new Date().toString(),
sourceContractCreatorUsername: creator.username,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
await sendMarketCloseEmail(
'your_contract_closed',
creator,
privateUser,
contract
)
} }

View File

@ -3,7 +3,11 @@ import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' import {
Post,
MAX_POST_TITLE_LENGTH,
MAX_POST_SUBTITLE_LENGTH,
} from '../../common/post'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { z } from 'zod' import { z } from 'zod'
@ -36,6 +40,7 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({ const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
content: contentSchema, content: contentSchema,
groupId: z.string().optional(), groupId: z.string().optional(),
@ -48,10 +53,8 @@ const postSchema = z.object({
export const createpost = newEndpoint({}, async (req, auth) => { export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const { title, content, groupId, question, ...otherProps } = validate( const { title, subtitle, content, groupId, question, ...otherProps } =
postSchema, validate(postSchema, req.body)
req.body
)
const creator = await getUser(auth.uid) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -68,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => {
if (question) { if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3 const closeTime = Date.now() + DAY_MS * 30 * 3
const result = await createMarketHelper( try {
{ const result = await createMarketHelper(
question, {
closeTime, question,
outcomeType: 'BINARY', closeTime,
visibility: 'unlisted', outcomeType: 'BINARY',
initialProb: 50, visibility: 'unlisted',
// Dating group! initialProb: 50,
groupId: 'j3ZE8fkeqiKmRGumy3O1', // Dating group!
}, groupId: 'j3ZE8fkeqiKmRGumy3O1',
auth },
) auth
contractSlug = result.slug )
contractSlug = result.slug
} catch (e) {
console.error(e)
}
} }
const post: Post = removeUndefinedProps({ const post: Post = removeUndefinedProps({
@ -89,6 +96,7 @@ export const createpost = newEndpoint({}, async (req, auth) => {
creatorId: creator.id, creatorId: creator.id,
slug, slug,
title, title,
subtitle,
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: content,
contractSlug, contractSlug,

View File

@ -69,6 +69,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followerCountCached: 0, followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true, shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
achievements: {},
} }
await firestore.collection('users').doc(auth.uid).create(user) await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -0,0 +1,69 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../common/contract'
import { batchedWaitAll } from '../../common/util/promise'
import { APIError } from '../../common/api'
import { addCpmmLiquidity } from '../../common/calculate-cpmm'
import { formatMoneyWithDecimals } from '../../common/util/format'
const firestore = admin.firestore()
export const drizzleLiquidity = async () => {
const snap = await firestore
.collection('contracts')
.where('subsidyPool', '>', 1e-7)
.get()
const contractIds = snap.docs.map((doc) => doc.id)
console.log('found', contractIds.length, 'markets to drizzle')
console.log()
await batchedWaitAll(
contractIds.map((cid) => () => drizzleMarket(cid)),
10
)
}
export const drizzleLiquidityScheduler = functions.pubsub
.schedule('* * * * *') // every minute
.onRun(drizzleLiquidity)
const drizzleMarket = async (contractId: string) => {
await firestore.runTransaction(async (trans) => {
const snap = await trans.get(firestore.doc(`contracts/${contractId}`))
const contract = snap.data() as CPMMContract
const { subsidyPool, pool, p, slug, popularityScore } = contract
if ((subsidyPool ?? 0) < 1e-7) return
const r = Math.random()
const logPopularity = Math.log10((popularityScore ?? 0) + 1)
const v = Math.max(1, Math.min(5, logPopularity))
const amount = subsidyPool <= 0.5 ? subsidyPool : r * v * 0.01 * subsidyPool
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
if (!isFinite(newP)) {
throw new APIError(
500,
'Liquidity injection rejected due to overflow error.'
)
}
await trans.update(firestore.doc(`contracts/${contract.id}`), {
pool: newPool,
p: newP,
subsidyPool: subsidyPool - amount,
})
console.log(
'added subsidy',
formatMoneyWithDecimals(amount),
'of',
formatMoneyWithDecimals(subsidyPool),
'pool to',
slug
)
console.log()
})
}

View File

@ -483,11 +483,7 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
">our Discord</a>! Or, ">our Discord</a>!
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { contractUrl, getUser } from './utils' import { contractUrl, getUser, log } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification' import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'onboarding_flow' 'onboarding_flow'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
sendTime: string sendTime: string
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'onboarding_flow' 'onboarding_flow'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Create your own prediction market', 'Create your own prediction market',
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email'
)
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'thank_you_for_purchases' 'thank_you_for_purchases'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Thanks for your Manifold purchase', 'Thanks for your Manifold purchase',
@ -315,12 +301,7 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
contract: Contract contract: Contract
) => { ) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( if (!privateUser.email) return
privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return
const { username, name, id: userId } = user const { username, name, id: userId } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -329,6 +310,7 @@ export const sendMarketCloseEmail = async (
const url = `https://${DOMAIN}/${username}/${slug}` const url = `https://${DOMAIN}/${username}/${slug}`
// We ignore if they were able to unsubscribe from market close emails, this is a necessary email
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Your market has closed', 'Your market has closed',
@ -336,7 +318,7 @@ export const sendMarketCloseEmail = async (
{ {
question, question,
url, url,
unsubscribeUrl, unsubscribeUrl: '',
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),
@ -466,17 +448,13 @@ export const sendInterestingMarketsEmail = async (
contractsToSend: Contract[], contractsToSend: Contract[],
deliveryTime?: string deliveryTime?: string
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.trending_markets.includes('email')
)
return
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'trending_markets' 'trending_markets'
) )
if (!sendToEmail) return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -620,18 +598,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
investments: PerContractInvestmentsData[], investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData overallPerformance: OverallPerformanceData
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
)
return
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'profit_loss_updates' 'profit_loss_updates'
) )
if (!sendToEmail) return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const templateData: Record<string, string> = { const templateData: Record<string, string> = {
@ -656,4 +631,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
: 'portfolio-update', : 'portfolio-update',
templateData templateData
) )
log('Sent portfolio update email to', privateUser.email)
} }

View File

@ -0,0 +1,42 @@
import * as admin from 'firebase-admin'
import { CPMMContract } from '../../../common/contract'
import { isProd } from '../utils'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../../common/antes'
import { getNewLiquidityProvision } from '../../../common/add-liquidity'
const firestore = admin.firestore()
export const addHouseSubsidy = (contractId: string, amount: number) => {
return firestore.runTransaction(async (transaction) => {
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contractId}/liquidity`)
.doc()
const providerId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const contractDoc = firestore.doc(`contracts/${contractId}`)
const snap = await contractDoc.get()
const contract = snap.data() as CPMMContract
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
getNewLiquidityProvision(
providerId,
amount,
contract,
newLiquidityProvisionDoc.id
)
transaction.update(contractDoc, {
subsidyPool: newSubsidyPool,
totalLiquidity: newTotalLiquidity,
} as Partial<CPMMContract>)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet' export * from './on-create-bet'
export * from './on-create-comment-on-contract' export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export * from './update-metrics' export { scheduleUpdateMetrics } from './update-metrics'
export * from './update-stats' export * from './update-stats'
export * from './update-loans' export * from './update-loans'
export * from './backup-db' export * from './backup-db'
@ -31,6 +31,7 @@ export * from './reset-weekly-emails-flags'
export * from './on-update-contract-follow' export * from './on-update-contract-follow'
export * from './on-update-like' export * from './on-update-like'
export * from './weekly-portfolio-emails' export * from './weekly-portfolio-emails'
export * from './drizzle-liquidity'
// v2 // v2
export * from './health' export * from './health'
@ -44,8 +45,6 @@ export * from './sell-bet'
export * from './sell-shares' export * from './sell-shares'
export * from './claim-manalink' export * from './claim-manalink'
export * from './create-market' export * from './create-market'
export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group' export * from './create-group'
export * from './resolve-market' export * from './resolve-market'
export * from './unsubscribe' export * from './unsubscribe'
@ -53,6 +52,7 @@ export * from './stripe'
export * from './mana-bonus-email' export * from './mana-bonus-email'
export * from './close-market' export * from './close-market'
export * from './update-comment-bounty' export * from './update-comment-bounty'
export * from './add-subsidy'
import { health } from './health' import { health } from './health'
import { transact } from './transact' import { transact } from './transact'
@ -65,9 +65,7 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market' import { closemarket } from './close-market'
@ -77,6 +75,8 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { updatemetrics } from './update-metrics'
import { addsubsidy } from './add-subsidy'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -92,10 +92,9 @@ const sellBetFunction = toCloudFunction(sellbet)
const sellSharesFunction = toCloudFunction(sellshares) const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink) const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket) const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity) const addSubsidyFunction = toCloudFunction(addsubsidy)
const addCommentBounty = toCloudFunction(addcommentbounty) const addCommentBounty = toCloudFunction(addcommentbounty)
const awardCommentBounty = toCloudFunction(awardcommentbounty) const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup) const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket) const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket) const closeMarketFunction = toCloudFunction(closemarket)
@ -106,6 +105,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost) const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const updateMetricsFunction = toCloudFunction(updatemetrics)
export { export {
healthFunction as health, healthFunction as health,
@ -119,8 +119,7 @@ export {
sellSharesFunction as sellshares, sellSharesFunction as sellshares,
claimManalinkFunction as claimmanalink, claimManalinkFunction as claimmanalink,
createMarketFunction as createmarket, createMarketFunction as createmarket,
addLiquidityFunction as addliquidity, addSubsidyFunction as addsubsidy,
withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup, createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket, resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket, closeMarketFunction as closemarket,
@ -133,4 +132,5 @@ export {
saveTwitchCredentials as savetwitchcredentials, saveTwitchCredentials as savetwitchcredentials,
addCommentBounty as addcommentbounty, addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty, awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics,
} }

View File

@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils' import { getPrivateUser, getUserByUsername } from './utils'
import { createNotification } from './create-notification' import { createMarketClosedNotification } from './create-notification'
import { DAY_MS } from '../../common/util/time'
const SEND_NOTIFICATIONS_EVERY_DAYS = 5
export const marketCloseNotifications = functions export const marketCloseNotifications = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('every 1 hours') .pubsub.schedule('every 1 hours')
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
const firestore = admin.firestore() const firestore = admin.firestore()
async function sendMarketCloseEmails() { export async function sendMarketCloseEmails() {
const contracts = await firestore.runTransaction(async (transaction) => { const contracts = await firestore.runTransaction(async (transaction) => {
const snap = await transaction.get( const snap = await transaction.get(
firestore.collection('contracts').where('isResolved', '!=', true) firestore.collection('contracts').where('isResolved', '!=', true)
) )
const contracts = snap.docs.map((doc) => doc.data() as Contract)
const now = Date.now()
const closeContracts = contracts.filter(
(contract) =>
contract.closeTime &&
contract.closeTime < now &&
shouldSendFirstOrFollowUpCloseNotification(contract)
)
return snap.docs await Promise.all(
.map((doc) => { closeContracts.map(async (contract) => {
const contract = doc.data() as Contract await transaction.update(
firestore.collection('contracts').doc(contract.id),
if ( {
contract.resolution || closeEmailsSent: admin.firestore.FieldValue.increment(1),
(contract.closeEmailsSent ?? 0) >= 1 || }
contract.closeTime === undefined ||
(contract.closeTime ?? 0) > Date.now()
) )
return undefined
transaction.update(doc.ref, {
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
})
return contract
}) })
.filter((x) => !!x) as Contract[] )
return closeContracts
}) })
for (const contract of contracts) { for (const contract of contracts) {
@ -55,14 +57,40 @@ async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id) const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue if (!privateUser) continue
await createNotification( await createMarketClosedNotification(
contract.id, contract,
'contract',
'closed',
user, user,
contract.id + '-closed-at-' + contract.closeTime, privateUser,
contract.closeTime?.toString() ?? new Date().toString(), contract.id + '-closed-at-' + contract.closeTime
{ contract }
) )
} }
} }
// The downside of this approach is if this function goes down for the entire
// day of a multiple of the time period after the market has closed, it won't
// keep sending them notifications bc when it comes back online the time period will have passed
function shouldSendFirstOrFollowUpCloseNotification(contract: Contract) {
if (!contract.closeEmailsSent || contract.closeEmailsSent === 0) return true
const { closedMultipleOfNDaysAgo, fullTimePeriodsSinceClose } =
marketClosedMultipleOfNDaysAgo(contract)
return (
contract.closeEmailsSent > 0 &&
closedMultipleOfNDaysAgo &&
contract.closeEmailsSent === fullTimePeriodsSinceClose
)
}
function marketClosedMultipleOfNDaysAgo(contract: Contract) {
const now = Date.now()
const closeTime = contract.closeTime
if (!closeTime)
return { closedMultipleOfNDaysAgo: false, fullTimePeriodsSinceClose: 0 }
const daysSinceClose = Math.floor((now - closeTime) / DAY_MS)
return {
closedMultipleOfNDaysAgo:
daysSinceClose % SEND_NOTIFICATIONS_EVERY_DAYS == 0,
fullTimePeriodsSinceClose: Math.floor(
daysSinceClose / SEND_NOTIFICATIONS_EVERY_DAYS
),
}
}

View File

@ -12,6 +12,7 @@ import {
revalidateStaticProps, revalidateStaticProps,
} from './utils' } from './utils'
import { import {
createBadgeAwardedNotification,
createBetFillNotification, createBetFillNotification,
createBettingStreakBonusNotification, createBettingStreakBonusNotification,
createUniqueBettorBonusNotification, createUniqueBettorBonusNotification,
@ -24,6 +25,7 @@ import {
BETTING_STREAK_BONUS_MAX, BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR, BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT,
UNIQUE_BETTOR_LIQUIDITY,
} from '../../common/economy' } from '../../common/economy'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -33,6 +35,11 @@ import { APIError } from '../../common/api'
import { User } from '../../common/user' import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
import { addHouseSubsidy } from './helpers/add-house-subsidy'
import {
StreakerBadge,
streakerBadgeRarityThresholds,
} from '../../common/badge'
const firestore = admin.firestore() const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -103,7 +110,7 @@ const updateBettingStreak = async (
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1 const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak // Otherwise, add 1 to their betting streak
await trans.update(userDoc, { trans.update(userDoc, {
currentBettingStreak: newBettingStreak, currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime, lastBetTime: bet.createdTime,
}) })
@ -143,7 +150,7 @@ const updateBettingStreak = async (
log('message:', result.message) log('message:', result.message)
return return
} }
if (result.txn) if (result.txn) {
await createBettingStreakBonusNotification( await createBettingStreakBonusNotification(
user, user,
result.txn.id, result.txn.id,
@ -153,6 +160,8 @@ const updateBettingStreak = async (
newBettingStreak, newBettingStreak,
eventId eventId
) )
await handleBettingStreakBadgeAward(user, newBettingStreak)
}
} }
const updateUniqueBettorsAndGiveCreatorBonus = async ( const updateUniqueBettorsAndGiveCreatorBonus = async (
@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`Got ${previousUniqueBettorIds} unique bettors`) log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await trans.update(contractDoc, { trans.update(contractDoc, {
uniqueBettorIds: newUniqueBettorIds, uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length, uniqueBettorCount: newUniqueBettorIds.length,
}) })
@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
return { newUniqueBettorIds } return { newUniqueBettorIds }
} }
) )
if (!newUniqueBettorIds) return if (!newUniqueBettorIds) return
if (oldContract.mechanism === 'cpmm-1') {
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
}
const bonusTxnDetails = { const bonusTxnDetails = {
contractId: oldContract.id, contractId: oldContract.id,
uniqueNewBettorId: bettor.id, uniqueNewBettorId: bettor.id,
@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID : DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get() const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.') if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User const fromUser = fromSnap.data() as User
const result = await firestore.runTransaction(async (trans) => { const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = { const bonusTxn: TxnData = {
fromId: fromUser.id, fromId: fromUser.id,
@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails), description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails, data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'> } as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
const { status, message, txn } = await runTxn(trans, bonusTxn) const { status, message, txn } = await runTxn(trans, bonusTxn)
return { status, newUniqueBettorIds, message, txn } return { status, newUniqueBettorIds, message, txn }
}) })
@ -296,3 +314,39 @@ const notifyFills = async (
const currentDateBettingStreakResetTime = () => { const currentDateBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
} }
async function handleBettingStreakBadgeAward(
user: User,
newBettingStreak: number
) {
const alreadyHasBadgeForFirstStreak =
user.achievements?.streaker?.badges.some(
(badge) => badge.data.totalBettingStreak === 1
)
// TODO: check if already awarded 50th streak as well
if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
if (streakerBadgeRarityThresholds.includes(newBettingStreak)) {
const badge = {
type: 'STREAKER',
name: 'Streaker',
data: {
totalBettingStreak: newBettingStreak,
},
createdTime: Date.now(),
} as StreakerBadge
// update user
await firestore
.collection('users')
.doc(user.id)
.update({
achievements: {
...user.achievements,
streaker: {
badges: [...(user.achievements?.streaker?.badges ?? []), badge],
},
},
})
await createBadgeAwardedNotification(user, badge)
}
}

View File

@ -1,11 +1,20 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser, getValues } from './utils'
import { createNewContractNotification } from './create-notification' import {
createBadgeAwardedNotification,
createNewContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { User } from '../../common/user'
import * as admin from 'firebase-admin'
import {
MarketCreatorBadge,
marketCreatorBadgeRarityThresholds,
} from '../../common/badge'
export const onCreateContract = functions export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
@ -28,4 +37,43 @@ export const onCreateContract = functions
richTextToString(desc), richTextToString(desc),
mentioned mentioned
) )
await handleMarketCreatorBadgeAward(contractCreator)
}) })
const firestore = admin.firestore()
async function handleMarketCreatorBadgeAward(contractCreator: User) {
// get all contracts by user and calculate size of array
const contracts = await getValues<Contract>(
firestore
.collection(`contracts`)
.where('creatorId', '==', contractCreator.id)
.where('resolution', '!=', 'CANCEL')
)
if (marketCreatorBadgeRarityThresholds.includes(contracts.length)) {
const badge = {
type: 'MARKET_CREATOR',
name: 'Market Creator',
data: {
totalContractsCreated: contracts.length,
},
createdTime: Date.now(),
} as MarketCreatorBadge
// update user
await firestore
.collection('users')
.doc(contractCreator.id)
.update({
achievements: {
...contractCreator.achievements,
marketCreator: {
badges: [
...(contractCreator.achievements?.marketCreator?.badges ?? []),
badge,
],
},
},
})
await createBadgeAwardedNotification(contractCreator, badge)
}
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser, log } from './utils' import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification' import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy' import { FIXED_ANTE } from '../../common/economy'
@ -36,7 +36,7 @@ export const onCreateLiquidityProvision = functions.firestore
if (!liquidityProvider) throw new Error('Could not find liquidity provider') if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id) await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification( await createFollowOrMarketSubsidizedNotification(
contract.id, contract.id,
'liquidity', 'liquidity',
'created', 'created',

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
export const onFollowUser = functions.firestore export const onFollowUser = functions.firestore
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
followerCountCached: FieldValue.increment(1), followerCountCached: FieldValue.increment(1),
}) })
await createNotification( await createFollowOrMarketSubsidizedNotification(
followingUser.id, followingUser.id,
'follow', 'follow',
'created', 'created',

View File

@ -1,11 +1,19 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser, getValues, log } from './utils' import { getUser, getValues } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import {
createBadgeAwardedNotification,
createCommentOrAnswerOrUpdatedContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { Txn } from '../../common/txn' import { Bet } from '../../common/bet'
import { partition, sortBy } from 'lodash'
import { runTxn, TxnData } from './transact'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { ContractComment } from '../../common/comment'
import { scoreCommentorsAndBettors } from '../../common/scoring'
import {
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
ProvenCorrectBadge,
} from '../../common/badge'
import { GroupContractDoc } from '../../common/group'
export const onUpdateContract = functions.firestore export const onUpdateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
@ -13,18 +21,14 @@ export const onUpdateContract = functions.firestore
const contract = change.after.data() as Contract const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract const previousContract = change.before.data() as Contract
const { eventId } = context const { eventId } = context
const { openCommentBounties, closeTime, question } = contract const { closeTime, question } = contract
if ( if (!previousContract.isResolved && contract.isResolved) {
!previousContract.isResolved &&
contract.isResolved &&
(openCommentBounties ?? 0) > 0
) {
await handleUnusedCommentBountyRefunds(contract)
// No need to notify users of resolution, that's handled in resolve-market // No need to notify users of resolution, that's handled in resolve-market
return return await handleResolvedContract(contract)
} } else if (previousContract.groupSlugs !== contract.groupSlugs) {
if ( await handleContractGroupUpdated(previousContract, contract)
} else if (
previousContract.closeTime !== closeTime || previousContract.closeTime !== closeTime ||
previousContract.question !== question previousContract.question !== question
) { ) {
@ -32,6 +36,63 @@ export const onUpdateContract = functions.firestore
} }
}) })
async function handleResolvedContract(contract: Contract) {
if (
(contract.uniqueBettorCount ?? 0) <
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE
)
return
// get all bets on this contract
const bets = await getValues<Bet>(
firestore.collection(`contracts/${contract.id}/bets`)
)
// get comments on this contract
const comments = await getValues<ContractComment>(
firestore.collection(`contracts/${contract.id}/comments`)
)
const { topCommentId, profitById, commentsById, betsById, topCommentBetId } =
scoreCommentorsAndBettors(contract, bets, comments)
if (topCommentBetId && profitById[topCommentBetId] > 0) {
// award proven correct badge to user
const comment = commentsById[topCommentId]
const bet = betsById[topCommentBetId]
const user = await getUser(comment.userId)
if (!user) return
const newProvenCorrectBadge = {
createdTime: Date.now(),
type: 'PROVEN_CORRECT',
name: 'Proven Correct',
data: {
contractSlug: contract.slug,
contractCreatorUsername: contract.creatorUsername,
commentId: comment.id,
betAmount: bet.amount,
contractTitle: contract.question,
},
} as ProvenCorrectBadge
// update user
await firestore
.collection('users')
.doc(user.id)
.update({
achievements: {
...user.achievements,
provenCorrect: {
badges: [
...(user.achievements?.provenCorrect?.badges ?? []),
newProvenCorrectBadge,
],
},
},
})
await createBadgeAwardedNotification(user, newProvenCorrectBadge)
}
}
async function handleUpdatedCloseTime( async function handleUpdatedCloseTime(
previousContract: Contract, previousContract: Contract,
contract: Contract, contract: Contract,
@ -57,62 +118,42 @@ async function handleUpdatedCloseTime(
) )
} }
async function handleUnusedCommentBountyRefunds(contract: Contract) { async function handleContractGroupUpdated(
const outstandingCommentBounties = await getValues<Txn>( previousContract: Contract,
firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY') contract: Contract
) ) {
const prevLength = previousContract.groupSlugs?.length ?? 0
const newLength = contract.groupSlugs?.length ?? 0
if (prevLength < newLength) {
// Contract was added to a new group
const groupId = contract.groupLinks?.find(
(link) =>
!previousContract.groupLinks
?.map((l) => l.groupId)
.includes(link.groupId)
)?.groupId
if (!groupId) throw new Error('Could not find new group id')
const commentBountiesOnThisContract = sortBy( await firestore
outstandingCommentBounties.filter( .collection(`groups/${groupId}/groupContracts`)
(bounty) => bounty.data?.contractId === contract.id .doc(contract.id)
), .set({
(bounty) => bounty.createdTime contractId: contract.id,
) createdTime: Date.now(),
} as GroupContractDoc)
}
if (prevLength > newLength) {
// Contract was removed from a group
const groupId = previousContract.groupLinks?.find(
(link) =>
!contract.groupLinks?.map((l) => l.groupId).includes(link.groupId)
)?.groupId
if (!groupId) throw new Error('Could not find old group id')
const [toBank, fromBank] = partition( await firestore
commentBountiesOnThisContract, .collection(`groups/${groupId}/groupContracts`)
(bounty) => bounty.toType === 'BANK' .doc(contract.id)
) .delete()
if (toBank.length <= fromBank.length) return }
await firestore
.collection('contracts')
.doc(contract.id)
.update({ openCommentBounties: 0 })
const refunds = toBank.slice(fromBank.length)
await Promise.all(
refunds.map(async (extraBountyTxn) => {
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: extraBountyTxn.toId,
fromType: 'BANK',
toId: extraBountyTxn.fromId,
toType: 'USER',
amount: extraBountyTxn.amount,
token: 'M$',
category: 'REFUND_COMMENT_BOUNTY',
data: {
contractId: contract.id,
},
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(
`Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`,
result.status
)
log('message:', result.message)
} else {
log(
`Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`,
result.txn?.id
)
}
})
)
} }
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification' import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn' import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy' import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) { if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId) await handleUserUpdatedReferral(user, eventId)
} }
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
}) })
async function handleUserUpdatedReferral(user: User, eventId: string) { async function handleUserUpdatedReferral(user: User, eventId: string) {
@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
) )
}) })
} }
async function cancelLimitOrders(userId: string) {
const snapshot = (await firestore
.collectionGroup('bets')
.where('userId', '==', userId)
.where('isFilled', '==', false)
.get()) as QuerySnapshot<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { FLAT_TRADE_FEE } from '../../common/fees'
import { import {
BetInfo, BetInfo,
getBinaryCpmmBetInfo, getBinaryCpmmBetInfo,
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { filterDefined } from '../../common/util/array'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalLiquidity, newTotalLiquidity,
newP, newP,
makers, makers,
ordersToCancel,
} = await (async (): Promise< } = await (async (): Promise<
BetInfo & { BetInfo & {
makers?: maker[] makers?: maker[]
ordersToCancel?: LimitBet[]
} }
> => { > => {
if ( if (
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb = Math.round(limitProb * 100) / 100 limitProb = Math.round(limitProb * 100) / 100
} }
const unfilledBetsSnap = await trans.get( const { unfilledBets, balanceByUserId } =
getUnfilledBetsQuery(contractDoc) await getUnfilledBetsAndUserBalances(trans, contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
return getBinaryCpmmBetInfo( return getBinaryCpmmBetInfo(
outcome, outcome,
amount, amount,
contract, contract,
limitProb, limitProb,
unfilledBets unfilledBets,
balanceByUserId
) )
} else if ( } else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (makers) { if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans) updateMakers(makers, betDoc.id, contractDoc, trans)
} }
if (ordersToCancel) {
for (const bet of ordersToCancel) {
trans.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
}
const balanceChange =
newBet.amount !== 0
? // quick bet
newBet.amount + FLAT_TRADE_FEE
: // limit order
FLAT_TRADE_FEE
trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) })
log('Updated user balance.')
if (newBet.amount !== 0) { if (newBet.amount !== 0) {
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
log('Updated user balance.')
trans.update( trans.update(
contractDoc, contractDoc,
removeUndefinedProps({ removeUndefinedProps({
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc return contractDoc
.collection('bets') .collection('bets')
.where('isFilled', '==', false) .where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet> .where('isCancelled', '==', false) as Query<LimitBet>
} }
export const getUnfilledBetsAndUserBalances = async (
trans: Transaction,
contractDoc: DocumentReference
) => {
const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc))
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
// Get balance of all users with open limit orders.
const userIds = uniq(unfilledBets.map((bet) => bet.userId))
const userDocs =
userIds.length === 0
? []
: await trans.getAll(
...userIds.map((userId) => firestore.doc(`users/${userId}`))
)
const users = filterDefined(userDocs.map((doc) => doc.data() as User))
const balanceByUserId = Object.fromEntries(
users.map((user) => [user.id, user.balance])
)
return { unfilledBets, balanceByUserId }
}
type maker = { type maker = {
bet: LimitBet bet: LimitBet
amount: number amount: number

View File

@ -9,7 +9,15 @@ import {
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils' import {
getContractPath,
getUser,
getValues,
isProd,
log,
payUser,
revalidateStaticProps,
} from './utils'
import { import {
getLoanPayouts, getLoanPayouts,
getPayouts, getPayouts,
@ -145,6 +153,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
resolutions, resolutions,
collectedFees, collectedFees,
}), }),
subsidyPool: 0,
} }
await contractDoc.update(updatedContract) await contractDoc.update(updatedContract)

View File

@ -0,0 +1,30 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllPrivateUsers } from 'functions/src/utils'
initAdmin()
const firestore = admin.firestore()
async function main() {
const privateUsers = await getAllPrivateUsers()
await Promise.all(
privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve()
if (privateUser.notificationPreferences.badges_awarded === undefined) {
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
badges_awarded: ['browser'],
},
})
}
return
})
)
}
if (require.main === module) main().then(() => process.exit())

View File

@ -0,0 +1,129 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getAllUsers, getValues } from '../utils'
import { Contract } from 'common/contract'
import {
MarketCreatorBadge,
marketCreatorBadgeRarityThresholds,
StreakerBadge,
streakerBadgeRarityThresholds,
} from 'common/badge'
import { User } from 'common/user'
initAdmin()
const firestore = admin.firestore()
async function main() {
const users = await getAllUsers()
// const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian
// const users = filterDefined([await getUser('uglwf3YKOZNGjjEXKc5HampOFRE2')]) // prod David
// const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian
await Promise.all(
users.map(async (user) => {
if (!user.id) return
// Only backfill users without achievements
if (user.achievements === undefined) {
await firestore.collection('users').doc(user.id).update({
achievements: {},
})
user.achievements = {}
user.achievements = await awardMarketCreatorBadges(user)
user.achievements = await awardBettingStreakBadges(user)
console.log('Added achievements to user', user.id)
// going to ignore backfilling the proven correct badges for now
}
})
)
}
if (require.main === module) main().then(() => process.exit())
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeErrorBadges(user: User) {
if (
user.achievements.streaker?.badges.some(
(b) => b.data.totalBettingStreak > 1
)
) {
console.log(
`User ${
user.id
} has a streaker badge with streaks ${user.achievements.streaker?.badges.map(
(b) => b.data.totalBettingStreak
)}`
)
// delete non 1,50 streaks
user.achievements.streaker.badges =
user.achievements.streaker.badges.filter((b) =>
streakerBadgeRarityThresholds.includes(b.data.totalBettingStreak)
)
// update user
await firestore.collection('users').doc(user.id).update({
achievements: user.achievements,
})
}
}
async function awardMarketCreatorBadges(user: User) {
// Award market maker badges
const contracts = await getValues<Contract>(
firestore
.collection(`contracts`)
.where('creatorId', '==', user.id)
.where('resolution', '!=', 'CANCEL')
)
const achievements = {
...user.achievements,
marketCreator: {
badges: [...(user.achievements.marketCreator?.badges ?? [])],
},
}
for (const threshold of marketCreatorBadgeRarityThresholds) {
if (contracts.length >= threshold) {
const badge = {
type: 'MARKET_CREATOR',
name: 'Market Creator',
data: {
totalContractsCreated: threshold,
},
createdTime: Date.now(),
} as MarketCreatorBadge
achievements.marketCreator.badges.push(badge)
}
}
// update user
await firestore.collection('users').doc(user.id).update({
achievements,
})
return achievements
}
async function awardBettingStreakBadges(user: User) {
const streak = user.currentBettingStreak ?? 0
const achievements = {
...user.achievements,
streaker: {
badges: [...(user.achievements?.streaker?.badges ?? [])],
},
}
for (const threshold of streakerBadgeRarityThresholds) {
if (streak >= threshold) {
const badge = {
type: 'STREAKER',
name: 'Streaker',
data: {
totalBettingStreak: threshold,
},
createdTime: Date.now(),
} as StreakerBadge
achievements.streaker.badges.push(badge)
}
}
// update user
await firestore.collection('users').doc(user.id).update({
achievements,
})
return achievements
}

View File

@ -50,3 +50,5 @@ async function main() {
} }
} }
main() main()
export {}

View File

@ -0,0 +1,65 @@
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
const DOMAIN = 'dev.manifold.markets'
// Dev API key for Cause Exploration Prizes (@CEP)
const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
const GROUP_SLUG = 'cart-contest'
// Can just curl /v0/group/{slug} to get a group
async function getGroupBySlug(slug: string) {
const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`)
return await resp.json()
}
async function getMarketsByGroupId(id: string) {
// API structure: /v0/group/by-id/[id]/markets
const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`)
return await resp.json()
}
/* Example curl 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"}'
*/
async function resolveMarketById(
id: string,
outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL'
) {
const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
outcome,
}),
})
return await resp.json()
}
async function main() {
const group = await getGroupBySlug(GROUP_SLUG)
const markets = await getMarketsByGroupId(group.id)
// Count up some metrics
console.log('Number of markets', markets.length)
console.log(
'Number of resolved markets',
markets.filter((m: any) => m.isResolved).length
)
// Resolve each market to NO
for (const market of markets) {
if (!market.isResolved) {
console.log(`Resolving market ${market.url} to NO`)
await resolveMarketById(market.id, 'NO')
}
}
}
main()
export {}

View File

@ -3,7 +3,6 @@
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
import { isEqual, zip } from 'lodash' import { isEqual, zip } from 'lodash'
import { UpdateSpec } from '../utils'
export type DocumentValue = { export type DocumentValue = {
doc: DocumentSnapshot doc: DocumentSnapshot
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
return { return {
doc: diff.dest.doc.ref, doc: diff.dest.doc.ref,
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
} as UpdateSpec }
} }
export function applyDiff(transaction: Transaction, diff: DocumentDiff) { export function applyDiff(transaction: Transaction, diff: DocumentDiff) {

View File

@ -0,0 +1,8 @@
import { initAdmin } from './script-init'
initAdmin()
import { drizzleLiquidity } from '../drizzle-liquidity'
if (require.main === module) {
drizzleLiquidity().then(() => process.exit())
}

View File

@ -1,46 +0,0 @@
import * as admin from 'firebase-admin'
import { uniq } from 'lodash'
import { initAdmin } from './script-init'
initAdmin()
import { Contract } from '../../../common/contract'
import { parseTags } from '../../../common/util/parse'
import { getValues } from '../utils'
async function updateContractTags() {
const firestore = admin.firestore()
console.log('Updating contracts tags')
const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loaded', contracts.length, 'contracts')
for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`)
const tags = uniq([
...parseTags(contract.question + contract.description),
...(contract.tags ?? []),
])
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
console.log(
'Updating tags',
contract.slug,
'from',
contract.tags,
'to',
tags
)
await contractRef.update({
tags,
lowercaseTags,
} as Partial<Contract>)
}
}
if (require.main === module) {
updateContractTags().then(() => process.exit())
}

View File

@ -89,17 +89,20 @@ const getGroups = async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() { async function updateTotalContractsAndMembers() {
const groups = await getGroups() const groups = await getGroups()
for (const group of groups) { await Promise.all(
log('updating group total contracts and members', group.slug) groups.map(async (group) => {
const groupRef = admin.firestore().collection('groups').doc(group.id) log('updating group total contracts and members', group.slug)
const totalMembers = (await groupRef.collection('groupMembers').get()).size const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalContracts = (await groupRef.collection('groupContracts').get()) const totalMembers = (await groupRef.collection('groupMembers').get())
.size .size
await groupRef.update({ const totalContracts = (await groupRef.collection('groupContracts').get())
totalMembers, .size
totalContracts, await groupRef.update({
totalMembers,
totalContracts,
})
}) })
} )
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() { async function removeUnusedMemberAndContractFields() {
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
if (require.main === module) { if (require.main === module) {
initAdmin() initAdmin()
// convertGroupFieldsToGroupDocuments() // convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers() updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields() // removeUnusedMemberAndContractFields()
} }

View File

@ -1,6 +1,7 @@
import { mapValues, groupBy, sumBy, uniq } from 'lodash' import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { FieldValue } from 'firebase-admin/firestore'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { log } from './utils' import { log } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market' import { removeUserFromContractFollowers } from './follow-market'
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, 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], userBetsSnap, unfilledBetsSnap] = const [
await Promise.all([ [contractSnap, userSnap],
transaction.getAll(contractDoc, userDoc), userBetsSnap,
transaction.get(betsQ), { unfilledBets, balanceByUserId },
transaction.get(getUnfilledBetsQuery(contractDoc)), ] = await Promise.all([
]) transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
getUnfilledBetsAndUserBalances(transaction, contractDoc),
])
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.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const user = userSnap.data() as User const user = userSnap.data() as User
@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
let loanPaid = saleFrac * loanAmount let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0 if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( const { newBet, newPool, newP, fees, makers, ordersToCancel } =
soldShares, getCpmmSellBetInfo(
chosenOutcome, soldShares,
contract, chosenOutcome,
unfilledBets, contract,
loanPaid unfilledBets,
) balanceByUserId,
loanPaid
)
if ( if (
!newP || !newP ||
@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
}) })
) )
for (const bet of ordersToCancel) {
transaction.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
return { newBet, makers, maxShares, soldShares } return { newBet, makers, maxShares, soldShares }
}) })

View File

@ -19,8 +19,6 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
@ -61,10 +59,8 @@ addJsonEndpointRoute('/sellbet', sellbet)
addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty) addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)

View File

@ -1,6 +1,6 @@
import { APIError, newEndpoint } from './api' import { APIError, newEndpoint } from './api'
import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails'
import { isProd } from './utils' import { isProd } from './utils'
import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
// Function for testing scheduled functions locally // Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint( export const testscheduledfunction = newEndpoint(
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
throw new APIError(400, 'This function is only available in dev mode') throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here // Replace your function here
await sendPortfolioUpdateEmailsToAllUsers() await sendMarketCloseEmails()
return { success: true } return { success: true }
} }

View File

@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences' import { notification_preference } from '../../common/user-notification-preferences'
import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = { export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 }, opts: { method: 'GET', minInstances: 1 },
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.') res.status(400).send('Invalid subscription type parameter.')
return return
} }
const optOutAllType: notification_preference = 'opt_out_all'
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id) const user = await getPrivateUser(id)
@ -31,28 +34,36 @@ export const unsubscribe: EndpointDefinition = {
const previousDestinations = const previousDestinations =
user.notificationPreferences[notificationSubscriptionType] user.notificationPreferences[notificationSubscriptionType]
let newDestinations = previousDestinations
if (wantsToOptOutAll) newDestinations.push('email')
else
newDestinations = previousDestinations.filter(
(destination) => destination !== 'email'
)
console.log(previousDestinations) console.log(previousDestinations)
const { email } = user const { email } = user
const update: Partial<PrivateUser> = { const update: Partial<PrivateUser> = {
notificationPreferences: { notificationPreferences: {
...user.notificationPreferences, ...user.notificationPreferences,
[notificationSubscriptionType]: previousDestinations.filter( [notificationSubscriptionType]: newDestinations,
(destination) => destination !== 'email'
),
}, },
} }
await firestore.collection('private-users').doc(id).update(update) await firestore.collection('private-users').doc(id).update(update)
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
res.send( const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
` if (wantsToOptOutAll) {
<!DOCTYPE html> res.send(
`
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"> xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<title>Manifold Markets 7th Day Anniversary Gift!</title> <title>Unsubscribe from Manifold Markets emails</title>
<!--[if !mso]><!--> <!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]--> <!--<![endif]-->
@ -163,19 +174,6 @@ export const unsubscribe: EndpointDefinition = {
</a> </a>
</td> </td>
</tr> </tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hello!</span></p>
</div>
</td>
</tr>
<tr> <tr>
<td align="left" <td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
@ -186,20 +184,9 @@ export const unsubscribe: EndpointDefinition = {
data-testid="4XoHRGw1Y"> data-testid="4XoHRGw1Y">
<span <span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has been unsubscribed from email notifications related to: ${email} has opted out of receiving unnecessary email notifications
</span> </span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div> </div>
</td> </td>
@ -219,9 +206,193 @@ export const unsubscribe: EndpointDefinition = {
</div> </div>
</div> </div>
</body> </body>
</html>`
)
} else {
res.send(
`
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Unsubscribe from Manifold Markets emails</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Hello!</span></p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y">
<span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has been unsubscribed from email notifications related to:
</span>
<br/>
<br/>
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
</p>
<br/>
<br/>
<br/>
<span>Click
<a href=${optOutAllUrl}>here</a>
to unsubscribe from all unnecessary emails.
</span>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings.
</span>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html> </html>
` `
) )
}
}, },
} }

View File

@ -1,10 +1,11 @@
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 { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { groupBy, keyBy, sortBy } from 'lodash'
import fetch from 'node-fetch'
import { getValues, log, logMemory, writeAsync } from './utils' import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract' import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans' import { getLoanUpdates } from '../../common/loans'
@ -14,18 +15,44 @@ import {
calculateNewPortfolioMetrics, calculateNewPortfolioMetrics,
calculateNewProfit, calculateNewProfit,
calculateProbChanges, calculateProbChanges,
calculateMetricsByContract,
computeElasticity,
computeVolume, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise' import { batchedWaitAll } from '../../common/util/promise'
import { newEndpointNoAuth } from './api'
import { getFunctionUrl } from '../../common/api'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore() const firestore = admin.firestore()
export const scheduleUpdateMetrics = functions.pubsub
.schedule('every 15 minutes')
.onRun(async () => {
const url = getFunctionUrl('updatemetrics')
console.log('Scheduling update metrics', url)
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({}),
})
export const updateMetrics = functions const json = await response.json()
.runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes') if (response.ok) console.log(json)
.onRun(updateMetricsCore) else console.error(json)
})
export const updatemetrics = newEndpointNoAuth(
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
async (_req) => {
await updateMetricsCore()
return { success: true }
}
)
export async function updateMetricsCore() { export async function updateMetricsCore() {
console.log('Loading users') console.log('Loading users')
@ -35,11 +62,7 @@ export async function updateMetricsCore() {
const contracts = await getValues<Contract>(firestore.collection('contracts')) const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loading portfolio history') console.log('Loading portfolio history')
const allPortfolioHistories = await getValues<PortfolioMetrics>( const userPortfolioHistory = await loadPortfolioHistory(users)
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
)
console.log('Loading groups') console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups')) const groups = await getValues<Group>(firestore.collection('groups'))
@ -103,6 +126,7 @@ export async function updateMetricsCore() {
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
elasticity: computeElasticity(contractBets, contract),
...cpmmFields, ...cpmmFields,
}, },
} }
@ -115,11 +139,10 @@ export async function updateMetricsCore() {
) )
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId) const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userMetrics = users.map((user) => { const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? [] const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? [] const portfolioHistory = userPortfolioHistory[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? []
const newCreatorVolume = calculateCreatorVolume(userContracts) const newCreatorVolume = calculateCreatorVolume(userContracts)
const newPortfolio = calculateNewPortfolioMetrics( const newPortfolio = calculateNewPortfolioMetrics(
@ -127,21 +150,51 @@ export async function updateMetricsCore() {
contractsById, contractsById,
currentBets currentBets
) )
const lastPortfolio = last(portfolioHistory) const currPortfolio = portfolioHistory.current
const didPortfolioChange = const didPortfolioChange =
lastPortfolio === undefined || currPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance || currPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue currPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
const metricsByContract = calculateMetricsByContract(
currentBets,
contractsById
)
const contractRatios = userContracts
.map((contract) => {
if (
!contract.flaggedByUsernames ||
contract.flaggedByUsernames?.length === 0
) {
return 0
}
const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
return contractRatio
})
.filter((ratio) => ratio > 0)
const badResolutions = contractRatios.filter(
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
)
let newFractionResolvedCorrectly = 1
if (userContracts.length > 0) {
newFractionResolvedCorrectly =
(userContracts.length - badResolutions.length) / userContracts.length
}
return { return {
user, user,
newCreatorVolume, newCreatorVolume,
newPortfolio, newPortfolio,
newProfit, newProfit,
didPortfolioChange, didPortfolioChange,
newFractionResolvedCorrectly,
metricsByContract,
} }
}) })
@ -157,61 +210,61 @@ export async function updateMetricsCore() {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map( const userUpdates = userMetrics.map(
({ ({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
fieldUpdates: { doc: firestore.collection('users').doc(user.id),
doc: firestore.collection('users').doc(user.id), fields: {
fields: { creatorVolumeCached: newCreatorVolume,
creatorVolumeCached: newCreatorVolume, profitCached: newProfit,
profitCached: newProfit, nextLoanCached,
nextLoanCached, fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
},
subcollectionUpdates: {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: didPortfolioChange ? newPortfolio : {},
}, },
} }
} }
) )
await writeAsync( await writeAsync(firestore, userUpdates)
firestore,
userUpdates.map((u) => u.fieldUpdates) const portfolioHistoryUpdates = filterDefined(
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
return didPortfolioChange
? {
doc: firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: newPortfolio,
}
: null
})
) )
await writeAsync( await writeAsync(firestore, portfolioHistoryUpdates, 'set')
firestore,
userUpdates const contractMetricsUpdates = userMetrics.flatMap(
.filter((u) => !isEmpty(u.subcollectionUpdates.fields)) ({ user, metricsByContract }) => {
.map((u) => u.subcollectionUpdates), const collection = firestore
'set' .collection('users')
.doc(user.id)
.collection('contract-metrics')
return metricsByContract.map((metrics) => ({
doc: collection.doc(metrics.contractId),
fields: metrics,
}))
}
) )
await writeAsync(firestore, contractMetricsUpdates, 'set')
log(`Updated metrics for ${users.length} users.`) log(`Updated metrics for ${users.length} users.`)
try { try {
const groupUpdates = groups.map((group, index) => { const groupUpdates = groups.map((group, index) => {
const groupContractIds = contractsByGroup[index] as GroupContractDoc[] const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
const groupContracts = groupContractIds const groupContracts = filterDefined(
.map((e) => contractsById[e.contractId]) groupContractIds.map((e) => contractsById[e.contractId])
.filter((e) => e !== undefined) as Contract[] )
const bets = groupContracts.map((e) => { const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
if (e != null && e.id in betsByContract) {
return betsByContract[e.id] ?? []
} else {
return []
}
})
const creatorScores = scoreCreators(groupContracts) const creatorScores = scoreCreators(groupContracts)
const traderScores = scoreTraders(groupContracts, bets) const traderScores = scoreTraders(groupContracts, bets)
@ -243,3 +296,46 @@ const topUserScores = (scores: { [userId: string]: number }) => {
} }
type GroupContractDoc = { contractId: string; createdTime: number } type GroupContractDoc = { contractId: string; createdTime: number }
const BAD_RESOLUTION_THRESHOLD = 0.1
const loadPortfolioHistory = async (users: User[]) => {
const now = Date.now()
const userPortfolioHistory = await batchedWaitAll(
users.map((user) => async () => {
const query = firestore
.collection('users')
.doc(user.id)
.collection('portfolioHistory')
.orderBy('timestamp', 'desc')
.limit(1)
const portfolioMetrics = await Promise.all([
getValues<PortfolioMetrics>(query),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - DAY_MS)
),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - 7 * DAY_MS)
),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - 30 * DAY_MS)
),
])
const [current, day, week, month] = portfolioMetrics.map(
(p) => p[0] as PortfolioMetrics | undefined
)
return {
userId: user.id,
current,
day,
week,
month,
}
}),
100
)
return keyBy(userPortfolioHistory, (p) => p.userId)
}

View File

@ -47,7 +47,7 @@ export const writeAsync = async (
const batch = db.batch() const batch = db.batch()
for (const { doc, fields } of chunks[i]) { for (const { doc, fields } of chunks[i]) {
if (operationType === 'update') { if (operationType === 'update') {
batch.update(doc, fields) batch.update(doc, fields as any)
} else { } else {
batch.set(doc, fields) batch.set(doc, fields)
} }
@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => {
return users.docs.map((doc) => doc.data() as PrivateUser) return users.docs.map((doc) => doc.data() as PrivateUser)
} }
export const getAllUsers = async () => {
const firestore = admin.firestore()
const users = await firestore.collection('users').get()
return users.docs.map((doc) => doc.data() as User)
}
export const getUserByUsername = async (username: string) => { export const getUserByUsername = async (username: string) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const snap = await firestore const snap = await firestore

View File

@ -4,21 +4,24 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { import {
getAllPrivateUsers, getAllPrivateUsers,
getGroup,
getPrivateUser, getPrivateUser,
getUser, getUser,
getValues, getValues,
isProd, isProd,
log, log,
} from './utils' } from './utils'
import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random' import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS } from '../../common/util/time' import { DAY_MS, HOUR_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow'
import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for an hour at 12pm PT (UTC -07:00) // every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1') .pubsub.schedule('* 19-20 * * 1')
.timeZone('Etc/UTC') .timeZone('Etc/UTC')
.onRun(async () => { .onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers() await sendTrendingMarketsEmailsToAllUsers()
@ -40,20 +43,30 @@ export async function getTrendingContracts() {
) )
} }
async function sendTrendingMarketsEmailsToAllUsers() { export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6 const numContractsToSend = 6
const privateUsers = isProd() const privateUsers = isProd()
? await getAllPrivateUsers() ? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) : filterDefined([
// get all users that haven't unsubscribed from weekly emails await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
])
const privateUsersToSendEmailsTo = privateUsers const privateUsersToSendEmailsTo = privateUsers
.filter((user) => { // Get all users that haven't unsubscribed from weekly emails
return ( .filter(
(user) =>
user.notificationPreferences.trending_markets.includes('email') && user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent !user.weeklyTrendingEmailSent
) )
}) .slice(0, 90) // Send the emails out in batches
.slice(150) // Send the emails out in batches
// For testing different users on prod: (only send ian an email though)
// const privateUsersToSendEmailsTo = filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
// // isProd()
// await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik
// // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
log( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length, privateUsersToSendEmailsTo.length,
@ -70,42 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') && !contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e') !contract.groupSlugs?.includes('manifold-6748e065087e')
) )
.slice(0, 20) .slice(0, 50)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
)
// TODO: convert to Promise.all const uniqueTrendingContracts = removeSimilarQuestions(
for (const privateUser of privateUsersToSendEmailsTo) { trendingContracts,
if (!privateUser.email) { trendingContracts,
log(`No email for ${privateUser.username}`) true
continue ).slice(0, 20)
}
const contractsAvailableToSend = trendingContracts.filter((contract) => { await Promise.all(
return !contract.uniqueBettorIds?.includes(privateUser.id) privateUsersToSendEmailsTo.map(async (privateUser) => {
}) if (!privateUser.email) {
if (contractsAvailableToSend.length < numContractsToSend) { log(`No email for ${privateUser.username}`)
log('not enough new, unbet-on contracts to send to user', privateUser.id) return
await firestore.collection('private-users').doc(privateUser.id).update({ }
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
privateUser.id
)
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
privateUser.id,
unbetOnFollowedMarkets
)
const similarBettorsMarkets = await getSimilarBettorsMarkets(
privateUser.id,
unBetOnGroupMarkets
)
const marketsAvailableToSend = uniqBy(
[
...chooseRandomSubset(unbetOnFollowedMarkets, 2),
// // Most people will belong to groups but may not follow other users,
// so choose more from the other subsets if the followed markets is sparse
...chooseRandomSubset(
unBetOnGroupMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
...chooseRandomSubset(
similarBettorsMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
],
(contract) => contract.id
)
// // at least send them trending contracts if nothing else
if (marketsAvailableToSend.length < numContractsToSend) {
const trendingMarketsToSend =
numContractsToSend - marketsAvailableToSend.length
log(
`not enough personalized markets, sending ${trendingMarketsToSend} trending`
)
marketsAvailableToSend.push(
...removeSimilarQuestions(
uniqueTrendingContracts,
marketsAvailableToSend,
false
)
.filter(
(contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
)
.slice(0, trendingMarketsToSend)
)
}
if (marketsAvailableToSend.length < numContractsToSend) {
log(
'not enough new, unbet-on contracts to send to user',
privateUser.id
)
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyTrendingEmailSent: true,
})
return
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset(
marketsAvailableToSend,
numContractsToSend
)
const user = await getUser(privateUser.id)
if (!user) return
log(
'sending contracts:',
contractsToSend.map((c) => c.question + ' ' + c.popularityScore)
)
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true, weeklyTrendingEmailSent: true,
}) })
continue
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset(
contractsAvailableToSend,
numContractsToSend
)
const user = await getUser(privateUser.id)
if (!user) continue
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
}) })
} )
}
const MINIMUM_POPULARITY_SCORE = 10
const getUserUnBetOnFollowsMarkets = async (userId: string) => {
const follows = await getValues<Follow>(
firestore.collection('users').doc(userId).collection('follows')
)
const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => {
const unresolvedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('visibility', '==', 'public')
.where('creatorId', '==', follow.userId)
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
.orderBy('popularityScore', 'desc')
.limit(50)
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts.filter(
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
)
return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(userId)
)
})
)
const sortedMarkets = uniqBy(
unBetOnContractsFromFollows.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
// log(
// 'top 10 sorted markets by followed users',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
const getUserUnBetOnGroupsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
const snap = await firestore
.collectionGroup('groupMembers')
.where('userId', '==', userId)
.get()
const groupIds = filterDefined(
snap.docs.map((doc) => doc.ref.parent.parent?.id)
)
const groups = filterDefined(
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
)
if (groups.length === 0) return []
const unBetOnContractsFromGroups = await Promise.all(
groups.map(async (group) => {
const unresolvedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.where('visibility', '==', 'public')
.where('groupSlugs', 'array-contains', group.slug)
// can't use multiple inequality (/orderBy) operators on different fields,
// so have to filter for closed contracts separately
.orderBy('popularityScore', 'desc')
.limit(50)
)
// filter out contracts that have close times less than 6 hours from now
const openContracts = unresolvedContracts.filter(
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
)
return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(userId)
)
})
)
const sortedMarkets = uniqBy(
unBetOnContractsFromGroups.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
const topSortedMarkets = removeSimilarQuestions(
uniqueSortedMarkets,
differentThanTheseContracts,
false
).slice(0, 10)
// log(
// 'top 10 sorted group markets',
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
// )
return topSortedMarkets
}
// Gets markets followed by similar bettors and bet on by similar bettors
const getSimilarBettorsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
// get contracts with unique bettor ids with this user
const contractsUserHasBetOn = await getValues<Contract>(
firestore
.collection('contracts')
.where('uniqueBettorIds', 'array-contains', userId)
)
if (contractsUserHasBetOn.length === 0) return []
// count the number of times each unique bettor id appears on those contracts
const bettorIdsToCounts = countBy(
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
(bettorId) => bettorId
)
// sort by number of times they appear with at least 2 appearances
const sortedBettorIds = Object.entries(bettorIdsToCounts)
.sort((a, b) => b[1] - a[1])
.filter((bettorId) => bettorId[1] > 2)
.map((entry) => entry[0])
.filter((bettorId) => bettorId !== userId)
// get the top 10 most similar bettors (excluding this user)
const similarBettorIds = sortedBettorIds.slice(0, 10)
if (similarBettorIds.length === 0) return []
// get contracts with unique bettor ids with this user
const contractsSimilarBettorsHaveBetOn = uniqBy(
(
await getValues<Contract>(
firestore
.collection('contracts')
.where(
'uniqueBettorIds',
'array-contains-any',
similarBettorIds.slice(0, 10)
)
.orderBy('popularityScore', 'desc')
.limit(200)
)
).filter(
(contract) =>
!contract.uniqueBettorIds?.includes(userId) &&
(contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE
),
(contract) => contract.id
)
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array
const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
.map((contract) => {
const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
similarBettorIds.includes(bettorId)
).length
return [contract, appearances] as [Contract, number]
})
.sort((a, b) => b[1] - a[1])
.map((entry) => entry[0])
const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
sortedContractsInSimilarBettorsBets,
sortedContractsInSimilarBettorsBets,
true
)
const topMostSimilarContracts = removeSimilarQuestions(
uniqueSortedContractsInSimilarBettorsBets,
differentThanTheseContracts,
false
).slice(0, 10)
// log(
// 'top 10 sorted contracts other similar bettors have bet on',
// topMostSimilarContracts.map((c) => c.question)
// )
return topMostSimilarContracts
}
// search contract array by question and remove contracts with 3 matching words in the question
const removeSimilarQuestions = (
contractsToFilter: Contract[],
byContracts: Contract[],
allowExactSameContracts: boolean
) => {
// log(
// 'contracts to filter by',
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
// )
let contractsToRemove: Contract[] = []
byContracts.length > 0 &&
byContracts.forEach((contract) => {
const contractQuestion = stripNonAlphaChars(
contract.question.toLowerCase()
)
const contractQuestionWords = uniq(contractQuestion.split(' ')).filter(
(w) => !IGNORE_WORDS.includes(w)
)
contractsToRemove = contractsToRemove.concat(
contractsToFilter.filter(
// Remove contracts with more than 2 matching (uncommon) words and a lower popularity score
(c2) => {
const significantOverlap =
// TODO: we should probably use a library for comparing strings/sentiments
uniq(
stripNonAlphaChars(c2.question.toLowerCase()).split(' ')
).filter((word) => contractQuestionWords.includes(word)).length >
2
const lessPopular =
(c2.popularityScore ?? 0) < (contract.popularityScore ?? 0)
return (
(significantOverlap && lessPopular) ||
(allowExactSameContracts ? false : c2.id === contract.id)
)
}
)
)
})
// log(
// 'contracts to filter out',
// contractsToRemove.map((c) => c.question)
// )
const returnContracts = contractsToFilter.filter(
(cf) => !contractsToRemove.map((c) => c.id).includes(cf.id)
)
return returnContracts
} }
const fiveMinutes = 5 * 60 * 1000 const fiveMinutes = 5 * 60 * 1000
@ -116,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng) shuffle(contracts, rng)
return contracts.slice(0, count) return contracts.slice(0, count)
} }
function stripNonAlphaChars(str: string) {
return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ')
}
const IGNORE_WORDS = [
'the',
'a',
'an',
'and',
'or',
'of',
'to',
'in',
'on',
'will',
'be',
'is',
'are',
'for',
'by',
'at',
'from',
'what',
'when',
'which',
'that',
'it',
'as',
'if',
'then',
'than',
'but',
'have',
'has',
'had',
]

View File

@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
) )
) )
) )
log('Found', contractsUsersBetOn.length, 'contracts')
let count = 0
await Promise.all( await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => { privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
// Don't send to a user unless they're over 5 days old // Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
return await setEmailFlagAsSent(privateUser.id)
const userBets = usersBets[privateUser.id] as Bet[] const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) => const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id) userBets.some((bet) => bet.contractId === contract.id)
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
(differences) => Math.abs(differences.profit) (differences) => Math.abs(differences.profit)
).reverse() ).reverse()
log(
'Found',
investmentValueDifferences.length,
'investment differences for user',
privateUser.id
)
const [winningInvestments, losingInvestments] = partition( const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter( investmentValueDifferences.filter(
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
usersToContractsCreated[privateUser.id].length === 0 usersToContractsCreated[privateUser.id].length === 0
) { ) {
log( log(
'No bets in last week, no market movers, no markets created. Not sending an email.' `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
) )
await firestore.collection('private-users').doc(privateUser.id).update({ return await setEmailFlagAsSent(privateUser.id)
weeklyPortfolioUpdateEmailSent: true,
})
return
} }
// Set the flag beforehand just to be safe
await setEmailFlagAsSent(privateUser.id)
await sendWeeklyPortfolioUpdateEmail( await sendWeeklyPortfolioUpdateEmail(
user, user,
privateUser, privateUser,
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
performanceData performanceData
) )
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
log('Sent weekly portfolio update email to', privateUser.email)
count++
log('sent out emails to users:', count)
}) })
) )
} }
async function setEmailFlagAsSent(privateUserId: string) {
await firestore.collection('private-users').doc(privateUserId).update({
weeklyPortfolioUpdateEmailSent: true,
})
}
export type PerContractInvestmentsData = { export type PerContractInvestmentsData = {
questionTitle: string questionTitle: string
questionUrl: string questionUrl: string

View File

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

View File

@ -14,11 +14,6 @@ export function getHtml(parsedReq: ParsedRequest) {
numericValue, numericValue,
resolution, resolution,
} = parsedReq } = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden' const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary' let resolutionColor = 'text-primary'
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<meta charset="utf-8"> <meta charset="utf-8">
<title>Generated Image</title> <title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com?plugins=line-clamp"></script>
</head> </head>
<style> <style>
${getTemplateCss(theme, fontSize)} ${getTemplateCss(theme, fontSize)}
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
</div> </div>
<div class="flex flex-row justify-between gap-12 pt-36"> <div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-tight"> <div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
${truncatedQuestion} ${question}
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
${ ${
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<!-- Metadata --> <!-- Metadata -->
<div class="absolute bottom-16"> <div class="absolute bottom-16">
<div class="text-gray-500 text-3xl max-w-[80vw]"> <div class="text-gray-500 text-3xl max-w-[80vw] line-clamp-2">
${metadata} ${metadata}
</div> </div>
</div> </div>

View File

@ -24,8 +24,5 @@
"prettier": "2.7.1", "prettier": "2.7.1",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "4.8.2" "typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"
} }
} }

10
web/components/NoSEO.tsx Normal file
View File

@ -0,0 +1,10 @@
import Head from 'next/head'
/** Exclude page from search results */
export function NoSEO() {
return (
<Head>
<meta name="robots" content="noindex,follow" />
</Head>
)
}

View File

@ -15,7 +15,7 @@ export function SEO(props: {
return ( return (
<Head> <Head>
<title>{title} | Manifold Markets</title> <title>{`${title} | Manifold Markets`}</title>
<meta <meta
property="og:title" property="og:title"

View File

@ -1,84 +0,0 @@
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe'
import { FundsSelector } from './yes-no-selector'
export function AddFundsButton(props: { className?: string }) {
const { className } = props
const user = useUser()
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
2500
)
const location = useLocation()
return (
<>
<label
htmlFor="add-funds"
className={clsx(
'btn btn-xs btn-outline modal-button font-normal normal-case',
className
)}
>
Get M$
</label>
<input type="checkbox" id="add-funds" className="modal-toggle" />
<div className="modal">
<div className="modal-box">
<div className="mb-6 text-xl">Get Mana</div>
<div className="mb-6 text-gray-500">
Buy mana (M$) to trade in your favorite markets. <br /> (Not
redeemable for cash.)
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>
<FundsSelector
selected={amountSelected}
onSelect={setAmountSelected}
/>
<div className="mt-6">
<div className="mb-1 text-sm text-gray-500">Price USD</div>
<div className="text-xl">
${Math.round(amountSelected / 100)}.00
</div>
</div>
<div className="modal-action">
<label htmlFor="add-funds" className={clsx('btn btn-ghost')}>
Back
</label>
<form
action={checkoutURL(user?.id || '', amountSelected, location)}
method="POST"
>
<button
type="submit"
className="btn btn-primary bg-gradient-to-r from-indigo-500 to-blue-500 px-10 font-medium hover:from-indigo-600 hover:to-blue-600"
>
Checkout
</button>
</form>
</div>
</div>
</div>
</>
)
}
// needed in next js
// window not loaded at runtime
const useLocation = () => {
const [href, setHref] = useState('')
useEffect(() => {
setHref(window.location.href)
}, [])
return href
}

View File

@ -0,0 +1,58 @@
import { manaToUSD } from 'common/util/format'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe'
import { Button } from './button'
import { Modal } from './layout/modal'
import { FundsSelector } from './yes-no-selector'
export function AddFundsModal(props: {
open: boolean
setOpen(open: boolean): void
}) {
const { open, setOpen } = props
const user = useUser()
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
2500
)
return (
<Modal open={open} setOpen={setOpen} className="rounded-md bg-white p-8">
<div className="mb-6 text-xl text-indigo-700">Get Mana</div>
<div className="mb-6 text-gray-700">
Buy mana (M$) to trade in your favorite markets. <br /> (Not redeemable
for cash.)
</div>
<div className="mb-2 text-sm text-gray-500">Amount</div>
<FundsSelector selected={amountSelected} onSelect={setAmountSelected} />
<div className="mt-6">
<div className="mb-1 text-sm text-gray-500">Price USD</div>
<div className="text-xl">{manaToUSD(amountSelected)}</div>
</div>
<div className="modal-action">
<Button color="gray-white" onClick={() => setOpen(false)}>
Back
</Button>
<form
action={checkoutURL(
user?.id || '',
amountSelected,
window.location.href
)}
method="POST"
>
<Button type="submit" color="gradient">
Checkout
</Button>
</form>
</div>
</Modal>
)
}

View File

@ -1,11 +1,12 @@
import clsx from 'clsx' import clsx from 'clsx'
import React from 'react' import React, { useState } from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { Row } from './layout/row' import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal'
import { Input } from './input'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -35,6 +36,8 @@ export function AmountInput(props: {
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const [addFundsModalOpen, setAddFundsModalOpen] = useState(false)
return ( return (
<> <>
<Col className={className}> <Col className={className}>
@ -42,9 +45,9 @@ export function AmountInput(props: {
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2"> <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label} {label}
</span> </span>
<input <Input
className={clsx( className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', 'pl-9',
error && 'input-error', error && 'input-error',
'w-24 md:w-auto', 'w-24 md:w-auto',
inputClassName inputClassName
@ -66,9 +69,16 @@ export function AmountInput(props: {
{error === 'Insufficient balance' ? ( {error === 'Insufficient balance' ? (
<> <>
Not enough funds. Not enough funds.
<span className="ml-1 text-indigo-500"> <button
<SiteLink href="/add-funds">Buy more?</SiteLink> className="ml-1 text-indigo-500 hover:underline hover:decoration-indigo-400"
</span> onClick={() => setAddFundsModalOpen(true)}
>
Buy more?
</button>
<AddFundsModal
open={addFundsModalOpen}
setOpen={setAddFundsModalOpen}
/>
</> </>
) : ( ) : (
error error

View File

@ -192,6 +192,7 @@ export function AnswerBetPanel(props: {
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
disabled={!!betDisabled} disabled={!!betDisabled}
color={'indigo'} color={'indigo'}
actionLabel="Buy"
/> />
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
import { Input } from '../input'
export function AnswerItem(props: { export function AnswerItem(props: {
answer: Answer answer: Answer
@ -74,8 +75,8 @@ export function AnswerItem(props: {
<Row className="items-center justify-end gap-4 self-end sm:self-start"> <Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo && {!wasResolvedTo &&
(showChoice === 'checkbox' ? ( (showChoice === 'checkbox' ? (
<input <Input
className="input input-bordered w-24 justify-self-end text-2xl" className="w-24 justify-self-end !text-2xl"
type="number" type="number"
placeholder={`${roundedProb}`} placeholder={`${roundedProb}`}
maxLength={9} maxLength={9}

View File

@ -20,11 +20,11 @@ import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector'
import { UserLink } from 'web/components/user-link'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]' import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CATEGORY_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice'
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
@ -38,6 +38,7 @@ export function AnswersPanel(props: {
const answers = (useAnswers(contract.id) ?? contract.answers).filter( const answers = (useAnswers(contract.id) ?? contract.answers).filter(
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
) )
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
const [winningAnswers, losingAnswers] = partition( const [winningAnswers, losingAnswers] = partition(
@ -104,6 +105,10 @@ export function AnswersPanel(props: {
? 'checkbox' ? 'checkbox'
: undefined : undefined
const colorSortedAnswer = useChartAnswers(contract).map(
(value, _index) => value.text
)
return ( return (
<Col className="gap-3"> <Col className="gap-3">
{(resolveOption || resolution) && {(resolveOption || resolution) &&
@ -128,7 +133,12 @@ export function AnswersPanel(props: {
)} )}
> >
{answerItems.map((item) => ( {answerItems.map((item) => (
<OpenAnswer key={item.id} answer={item} contract={contract} /> <OpenAnswer
key={item.id}
answer={item}
contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)}
/>
))} ))}
{hasZeroBetAnswers && !showAllAnswers && ( {hasZeroBetAnswers && !showAllAnswers && (
<Button <Button
@ -147,11 +157,9 @@ export function AnswersPanel(props: {
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
{outcomeType === 'FREE_RESPONSE' && {outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
tradingAllowed(contract) && <CreateAnswerPanel contract={contract} />
(!resolveOption || resolveOption === 'CANCEL') && ( )}
<CreateAnswerPanel contract={contract} />
)}
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) && {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
!resolution && ( !resolution && (
@ -174,15 +182,18 @@ export function AnswersPanel(props: {
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
colorIndex: number | undefined
}) { }) {
const { answer, contract } = props const { answer, contract, colorIndex } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob) const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const color =
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
return ( return (
<Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2"> <Col className="my-1 px-2">
<Modal open={open} setOpen={setOpen} position="center"> <Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel <AnswerBetPanel
answer={answer} answer={answer}
@ -193,40 +204,44 @@ function OpenAnswer(props: {
/> />
</Modal> </Modal>
<div <Col
className="pointer-events-none absolute -mx-2 h-full rounded-tl-md bg-green-600 bg-opacity-10" className={clsx(
style={{ width: `${100 * Math.max(prob, 0.01)}%` }} 'bg-greyscale-1 relative w-full rounded-lg transition-all',
/> tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)}
<Row className="my-4 gap-3"> >
<Avatar className="mx-1" username={username} avatarUrl={avatarUrl} /> <Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Col className="min-w-0 flex-1 lg:gap-1"> <Row>
<div className="text-sm text-gray-500"> <Avatar
<UserLink username={username} name={name} /> answered className="mt-0.5 mr-2 inline h-5 w-5 border border-transparent transition-transform hover:border-none"
</div> username={username}
avatarUrl={avatarUrl}
<Col className="align-items justify-between gap-4 sm:flex-row"> />
<Linkify className="whitespace-pre-line text-lg" text={text} /> <Linkify
<Row className="align-items items-center justify-end gap-4"> className="text-md cursor-pointer whitespace-pre-line"
<span text={text}
className={clsx( />
'text-2xl', </Row>
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500' <Row className="gap-2">
)} <div className="my-auto text-xl">{probPercent}</div>
> {tradingAllowed(contract) && (
{probPercent} <Button
</span> size="2xs"
<BuyButton color="gray-outline"
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
/> className="my-auto"
</Row> >
</Col> BUY
</Col> </Button>
</Row> )}
</Row>
</Row>
<hr
color={color}
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
</Col>
</Col> </Col>
) )
} }

View File

@ -1,6 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useState } from 'react' import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity' import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract } from 'common/contract'
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash' import { lowerCase } from 'lodash'
import { Button } from '../button' import { Button } from '../button'
import { ExpandingInput } from '../expanding-input'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props const { contract } = props
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Col className="flex-1 gap-2 px-4 xl:px-0"> <Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div> <div className="mb-1">Add your answer</div>
<Textarea <ExpandingInput
value={text} value={text}
onChange={(e) => changeAnswer(e.target.value)} onChange={(e) => changeAnswer(e.target.value)}
className="textarea textarea-bordered w-full resize-none" className="w-full"
placeholder="Type your answer..." placeholder="Type your answer..."
rows={1} rows={1}
maxLength={MAX_ANSWER_LENGTH} maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,8 +1,8 @@
import { MAX_ANSWER_LENGTH } from 'common/answer' import { MAX_ANSWER_LENGTH } from 'common/answer'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { ExpandingInput } from '../expanding-input'
export function MultipleChoiceAnswers(props: { export function MultipleChoiceAnswers(props: {
answers: string[] answers: string[]
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
{answers.map((answer, i) => ( {answers.map((answer, i) => (
<Row className="mb-2 items-center gap-2 align-middle"> <Row className="mb-2 items-center gap-2 align-middle">
{i + 1}.{' '} {i + 1}.{' '}
<Textarea <ExpandingInput
value={answer} value={answer}
onChange={(e) => setAnswer(i, e.target.value)} onChange={(e) => setAnswer(i, e.target.value)}
className="textarea textarea-bordered ml-2 w-full resize-none" className="ml-2 w-full"
placeholder="Type your answer..." placeholder="Type your answer..."
rows={1} rows={1}
maxLength={MAX_ANSWER_LENGTH} maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,13 +1,13 @@
import clsx from 'clsx' import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { MenuIcon } from '@heroicons/react/solid' import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { XCircleIcon } from '@heroicons/react/outline'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle' import { Subtitle } from 'web/components/subtitle'
import { keyBy } from 'lodash' import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button' import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users' import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups' import { leaveGroup } from 'web/lib/firebase/groups'

View File

@ -68,11 +68,11 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser]) }, [setAuthUser, serverUser])
useEffect(() => { useEffect(() => {
if (authUser != null) { if (authUser) {
// Persist to local storage, to reduce login blink next time. // Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb // Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser)) localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else { } else if (authUser === null) {
localStorage.removeItem(CACHED_USER_KEY) localStorage.removeItem(CACHED_USER_KEY)
} }
}, [authUser]) }, [authUser])

View File

@ -0,0 +1,62 @@
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { getBadgesByRarity } from 'common/badge'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { BadgesModal } from 'web/components/profile/badges-modal'
import { ParsedUrlQuery } from 'querystring'
export const goldClassName = 'text-amber-400'
export const silverClassName = 'text-gray-500'
export const bronzeClassName = 'text-amber-900'
export function BadgeDisplay(props: {
user: User | undefined | null
query: ParsedUrlQuery
}) {
const { user, query } = props
const [showBadgesModal, setShowBadgesModal] = useState(false)
useEffect(() => {
const showBadgesModal = query['show'] == 'badges'
setShowBadgesModal(showBadgesModal)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// get number of badges of each rarity type
const badgesByRarity = getBadgesByRarity(user)
const badgesByRarityItems = Object.entries(badgesByRarity).map(
([rarity, numBadges]) => {
return (
<Row
key={rarity}
className={clsx(
'items-center gap-2',
rarity === 'bronze'
? bronzeClassName
: rarity === 'silver'
? silverClassName
: goldClassName
)}
>
<span className={clsx('-m-0.5 text-lg')}></span>
<span className="text-xs">{numBadges}</span>
</Row>
)
}
)
return (
<Row
className={'cursor-pointer gap-2'}
onClick={() => setShowBadgesModal(true)}
>
{badgesByRarityItems}
{user && (
<BadgesModal
isOpen={showBadgesModal}
setOpen={setShowBadgesModal}
user={user}
/>
)}
</Row>
)
}

View File

@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
user: User user: User
}) { }) {
const { contract, user } = props const { contract, user } = props
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<> <>
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
contract={contract as CPMMBinaryContract} contract={contract as CPMMBinaryContract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
mobileView={true} mobileView={true}
/> />
</Col> </Col>

View File

@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
import { Button } from './button' import { Button } from './button'
import { Row } from './layout/row' import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm' import { getCpmmProbability } from 'common/calculate-cpmm'
@ -34,14 +34,17 @@ export function BetInline(props: {
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { newPool, newP } = getBinaryCpmmBetInfo( const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES', outcome ?? 'YES',
amount ?? 0, amount ?? 0,
contract, contract,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
const resultProb = getCpmmProbability(newPool, newP) const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb]) useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])

View File

@ -25,7 +25,7 @@ import {
NoLabel, NoLabel,
YesLabel, YesLabel,
} from './outcome-label' } from './outcome-label'
import { getProbability } from 'common/calculate' import { getContractBetMetrics, getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus' import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { ProbabilityOrNumericInput } from './probability-input' import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets' import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
@ -55,7 +55,9 @@ export function BetPanel(props: {
const { contract, className } = props const { contract, className } = props
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -86,12 +88,14 @@ export function BetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
<LimitOrderPanel <LimitOrderPanel
hidden={!isLimitOrder} hidden={!isLimitOrder}
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
</> </>
) : ( ) : (
@ -117,7 +121,9 @@ export function SimpleBetPanel(props: {
const user = useUser() const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<Col className={className}> <Col className={className}>
@ -142,6 +148,7 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
<LimitOrderPanel <LimitOrderPanel
@ -149,6 +156,7 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
@ -167,13 +175,21 @@ export function SimpleBetPanel(props: {
export function BuyPanel(props: { export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean hidden: boolean
onBuySuccess?: () => void onBuySuccess?: () => void
mobileView?: boolean mobileView?: boolean
}) { }) {
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } = const {
props contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
mobileView,
} = props
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -261,7 +277,8 @@ export function BuyPanel(props: {
betAmount ?? 0, betAmount ?? 0,
contract, contract,
undefined, undefined,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const [seeLimit, setSeeLimit] = useState(false) const [seeLimit, setSeeLimit] = useState(false)
@ -395,6 +412,7 @@ export function BuyPanel(props: {
disabled={!!betDisabled || outcome === undefined} disabled={!!betDisabled || outcome === undefined}
size="xl" size="xl"
color={outcome === 'NO' ? 'red' : 'green'} color={outcome === 'NO' ? 'red' : 'green'}
actionLabel="Wager"
/> />
)} )}
<button <button
@ -415,6 +433,7 @@ export function BuyPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
<LimitBets <LimitBets
contract={contract} contract={contract}
@ -430,11 +449,19 @@ export function BuyPanel(props: {
function LimitOrderPanel(props: { function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean hidden: boolean
onBuySuccess?: () => void onBuySuccess?: () => void
}) { }) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
} = props
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -580,7 +607,8 @@ function LimitOrderPanel(props: {
yesAmount, yesAmount,
contract, contract,
yesLimitProb ?? initialProb, yesLimitProb ?? initialProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const yesReturnPercent = formatPercent(yesReturn) const yesReturnPercent = formatPercent(yesReturn)
@ -594,7 +622,8 @@ function LimitOrderPanel(props: {
noAmount, noAmount,
contract, contract,
noLimitProb ?? initialProb, noLimitProb ?? initialProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const noReturnPercent = formatPercent(noReturn) const noReturnPercent = formatPercent(noReturn)
@ -829,15 +858,25 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const betDisabled = isSubmitting || !amount || error const betDisabled = isSubmitting || !amount || error !== undefined
// Sell all shares if remaining shares would be < 1 // Sell all shares if remaining shares would be < 1
const isSellingAllShares = amount === Math.floor(shares) const isSellingAllShares = amount === Math.floor(shares)
const sellQuantity = isSellingAllShares ? shares : amount const sellQuantity = isSellingAllShares ? shares : amount
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const soldShares = Math.min(sellQuantity ?? 0, shares)
const saleFrac = soldShares / shares
const loanPaid = saleFrac * loanAmount
const { invested } = getContractBetMetrics(contract, userBets)
const costBasis = invested * saleFrac
async function submitSell() { async function submitSell() {
if (!user || !amount) return if (!user || !amount) return
@ -880,10 +919,26 @@ export function SellPanel(props: {
contract, contract,
sellQuantity ?? 0, sellQuantity ?? 0,
sharesOutcome, sharesOutcome,
unfilledBets unfilledBets,
balanceByUserId
) )
const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p) const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
const getValue = getMappedValue(contract)
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
const displayedDifference =
contract.outcomeType === 'PSEUDO_NUMERIC'
? formatLargeNumber(rawDifference)
: formatPercent(rawDifference)
const probChange = Math.abs(resultProb - initialProb)
const warning =
probChange >= 0.3
? `Are you sure you want to move the market by ${displayedDifference}?`
: undefined
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = partition( const [yesBets, noBets] = partition(
openUserBets, openUserBets,
@ -923,14 +978,18 @@ export function SellPanel(props: {
label="Qty" label="Qty"
error={error} error={error}
disabled={isSubmitting} disabled={isSubmitting}
inputClassName="w-full" inputClassName="w-full ml-1"
/> />
<Col className="mt-3 w-full gap-3 text-sm"> <Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500"> <Row className="items-center justify-between gap-2 text-gray-500">
Sale proceeds Sale amount
<span className="text-neutral">{formatMoney(saleValue)}</span> <span className="text-neutral">{formatMoney(saleValue)}</span>
</Row> </Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Profit
<span className="text-neutral">{formatMoney(profit)}</span>
</Row>
<Row className="items-center justify-between"> <Row className="items-center justify-between">
<div className="text-gray-500"> <div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'} {isPseudoNumeric ? 'Estimated value' : 'Probability'}
@ -941,24 +1000,33 @@ export function SellPanel(props: {
{format(resultProb)} {format(resultProb)}
</div> </div>
</Row> </Row>
{loanPaid !== 0 && (
<>
<Row className="mt-6 items-center justify-between gap-2 text-gray-500">
Loan payment
<span className="text-neutral">{formatMoney(-loanPaid)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds
<span className="text-neutral">{formatMoney(netProceeds)}</span>
</Row>
</>
)}
</Col> </Col>
<Spacer h={8} /> <Spacer h={8} />
<button <WarningConfirmationButton
className={clsx( marketType="binary"
'btn flex-1', amount={undefined}
betDisabled warning={warning}
? 'btn-disabled' isSubmitting={isSubmitting}
: sharesOutcome === 'YES' onSubmit={betDisabled ? undefined : submitSell}
? 'btn-primary' disabled={!!betDisabled}
: 'border-none bg-red-400 hover:bg-red-500', size="xl"
isSubmitting ? 'loading' : '' color="blue"
)} actionLabel={`Sell ${Math.floor(soldShares)} shares`}
onClick={betDisabled ? undefined : submitSell} />
>
{isSubmitting ? 'Submitting...' : 'Submit sell'}
</button>
{wasSubmitted && <div className="mt-4">Sell submitted!</div>} {wasSubmitted && <div className="mt-4">Sell submitted!</div>}
</> </>

View File

@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet, MAX_USER_BETS_LOADED } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { import {
formatMoney, formatMoney,
@ -17,6 +17,7 @@ import {
Contract, Contract,
contractPath, contractPath,
getBinaryProbPercent, getBinaryProbPercent,
MAX_USER_BET_CONTRACTS_LOADED,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Row } from './layout/row' import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api' import { sellBet } from 'web/lib/firebase/api'
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { Pagination } from './pagination' import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets' import { LimitOrderTable } from './limit-bets'
@ -50,6 +51,7 @@ import {
usePersistentState, usePersistentState,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import { ExclamationIcon } from '@heroicons/react/outline'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -80,6 +82,10 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList]) }, [contractList])
const loadedPartialData =
userBets?.length === MAX_USER_BETS_LOADED ||
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
const [sort, setSort] = usePersistentState<BetSort>('newest', { const [sort, setSort] = usePersistentState<BetSort>('newest', {
key: 'bets-list-sort', key: 'bets-list-sort',
store: storageStore(safeLocalStorage()), store: storageStore(safeLocalStorage()),
@ -160,26 +166,38 @@ export function BetsList(props: { user: User }) {
unsettled, unsettled,
(c) => contractsMetrics[c.id].payout (c) => contractsMetrics[c.id].payout
) )
const currentNetInvestment = sumBy( const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
unsettled,
(c) => contractsMetrics[c.id].netPayout
)
const investedProfitPercent = const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return ( return (
<Col> <Col>
<Row className="justify-between gap-4 sm:flex-row"> {loadedPartialData && (
<Col> <Row className="my-4 items-center gap-2 self-start rounded bg-yellow-50 p-4">
<div className="text-greyscale-6 text-xs sm:text-sm"> <ExclamationIcon className="h-5 w-5" />
Investment value <div>Partial trade data only</div>
</div> </Row>
<div className="text-lg"> )}
{formatMoney(currentNetInvestment)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} /> <Col className="justify-between gap-4 sm:flex-row">
</div> <Row className="gap-4">
</Col> <Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Investment value
</div>
<div className="text-lg">
{formatMoney(currentBetsValue)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Total loans
</div>
<div className="text-lg">{formatMoney(currentLoan)}</div>
</Col>
</Row>
<Row className="gap-2"> <Row className="gap-2">
<select <select
@ -206,7 +224,7 @@ export function BetsList(props: { user: User }) {
<option value="closeTime">Close date</option> <option value="closeTime">Close date</option>
</select> </select>
</Row> </Row>
</Row> </Col>
<Col className="mt-6 divide-y"> <Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? ( {displayedContracts.length === 0 ? (
@ -407,7 +425,9 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -456,6 +476,7 @@ export function ContractBetsTable(props: {
contract={contract} contract={contract}
isYourBet={isYourBets} isYourBet={isYourBets}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
))} ))}
</tbody> </tbody>
@ -470,8 +491,10 @@ function BetRow(props: {
saleBet?: Bet saleBet?: Bet
isYourBet: boolean isYourBet: boolean
unfilledBets: LimitBet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) { }) {
const { bet, saleBet, contract, isYourBet, unfilledBets } = props const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
props
const { const {
amount, amount,
outcome, outcome,
@ -499,9 +522,9 @@ function BetRow(props: {
} else if (contract.isResolved) { } else if (contract.isResolved) {
return resolvedPayout(contract, bet) return resolvedPayout(contract, bet)
} else { } else {
return calculateSaleAmount(contract, bet, unfilledBets) return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
} }
}, [contract, bet, saleBet, unfilledBets]) }, [contract, bet, saleBet, unfilledBets, balanceByUserId])
const saleDisplay = isAnte ? ( const saleDisplay = isAnte ? (
'ANTE' 'ANTE'
@ -540,6 +563,7 @@ function BetRow(props: {
contract={contract} contract={contract}
bet={bet} bet={bet}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
)} )}
</td> </td>
@ -585,8 +609,9 @@ function SellButton(props: {
contract: Contract contract: Contract
bet: Bet bet: Bet
unfilledBets: LimitBet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) { }) {
const { contract, bet, unfilledBets } = props const { contract, bet, unfilledBets, balanceByUserId } = props
const { outcome, shares, loanAmount } = bet const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -600,10 +625,16 @@ function SellButton(props: {
contract, contract,
outcome, outcome,
shares, shares,
unfilledBets unfilledBets,
balanceByUserId
) )
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets) const saleAmount = calculateSaleAmount(
contract,
bet,
unfilledBets,
balanceByUserId
)
const profit = saleAmount - bet.amount const profit = saleAmount - bet.amount
return ( return (
@ -612,7 +643,7 @@ function SellButton(props: {
label: 'Sell', label: 'Sell',
disabled: isSubmitting, disabled: isSubmitting,
}} }}
submitBtn={{ className: 'btn-primary', label: 'Sell' }} submitBtn={{ label: 'Sell', color: 'green' }}
onSubmit={async () => { onSubmit={async () => {
setIsSubmitting(true) setIsSubmitting(true)
await sellBet({ contractId: contract.id, betId: bet.id }) await sellBet({ contractId: contract.id, betId: bet.id })

View File

@ -1,5 +1,6 @@
import { MouseEventHandler, ReactNode } from 'react' import { MouseEventHandler, ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { LoadingIndicator } from 'web/components/loading-indicator'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type ColorType = export type ColorType =
@ -9,18 +10,57 @@ export type ColorType =
| 'indigo' | 'indigo'
| 'yellow' | 'yellow'
| 'gray' | 'gray'
| 'gray-outline'
| 'gradient' | 'gradient'
| 'gray-white' | 'gray-white'
| 'highlight-blue' | 'highlight-blue'
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}
export function buttonClass(size: SizeType, color: ColorType | 'override') {
return clsx(
'font-md inline-flex items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' &&
'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none'
)
}
export function Button(props: { export function Button(props: {
className?: string className?: string
onClick?: MouseEventHandler<any> | undefined onClick?: MouseEventHandler<any> | undefined
children?: ReactNode children?: ReactNode
size?: SizeType size?: SizeType
color?: ColorType color?: ColorType | 'override'
type?: 'button' | 'reset' | 'submit' type?: 'button' | 'reset' | 'submit'
disabled?: boolean disabled?: boolean
loading?: boolean
}) { }) {
const { const {
children, children,
@ -30,47 +70,17 @@ export function Button(props: {
color = 'indigo', color = 'indigo',
type = 'button', type = 'button',
disabled = false, disabled = false,
loading,
} = props } = props
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}[size]
return ( return (
<button <button
type={type} type={type}
className={clsx( className={clsx(buttonClass(size, color), className)}
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed', disabled={disabled || loading}
sizeClasses,
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className
)}
disabled={disabled}
onClick={onClick} onClick={onClick}
> >
{loading && <LoadingIndicator className={'mr-2 border-gray-500'} />}
{children} {children}
</button> </button>
) )

16
web/components/card.tsx Normal file
View File

@ -0,0 +1,16 @@
import clsx from 'clsx'
export function Card(props: JSX.IntrinsicElements['div']) {
const { children, className, ...rest } = props
return (
<div
className={clsx(
'cursor-pointer rounded-lg border bg-white transition-shadow hover:shadow-md focus:shadow-md',
className
)}
{...rest}
>
{children}
</div>
)
}

View File

@ -1,8 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline' import { SwitchVerticalIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
@ -16,16 +15,16 @@ import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label' import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input' import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy' import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor' import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button'
import { ExpandingInput } from '../expanding-input'
type challengeInfo = { type challengeInfo = {
amount: number amount: number
@ -154,9 +153,9 @@ function CreateChallengeForm(props: {
{contract ? ( {contract ? (
<span className="underline">{contract.question}</span> <span className="underline">{contract.question}</span>
) : ( ) : (
<Textarea <ExpandingInput
placeholder="e.g. Will a Democrat be the next president?" placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none" className="mt-1 w-full"
autoFocus={true} autoFocus={true}
maxLength={MAX_QUESTION_LENGTH} maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question} value={challengeInfo.question}
@ -302,16 +301,7 @@ function CreateChallengeForm(props: {
<Title className="!my-0" text="Challenge Created!" /> <Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div> <div>Share the challenge using the link.</div>
<button <CopyLinkButton url={challengeSlug} />
onClick={() => {
copyToClipboard(challengeSlug)
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Copy link
</button>
<QRCode url={challengeSlug} className="self-center" /> <QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}> <Row className={'gap-1 text-gray-500'}>

View File

@ -6,6 +6,8 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns' import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format' import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Col } from '../layout/col'
import { Card } from '../card'
export function CharityCard(props: { charity: Charity; match?: number }) { export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity } = props const { charity } = props
@ -15,43 +17,44 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
const raised = sumBy(txns, (txn) => txn.amount) const raised = sumBy(txns, (txn) => txn.amount)
return ( return (
<Link href={`/charity/${slug}`} passHref> <Link href={`/charity/${slug}`}>
<div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md"> <a className="flex-1">
<Row className="mt-6 mb-2"> <Card className="!rounded-2xl">
{tags?.includes('Featured') && <FeaturedBadge />} <Row className="mt-6 mb-2">
</Row> {tags?.includes('Featured') && <FeaturedBadge />}
<div className="px-8"> </Row>
<figure className="relative h-32"> <div className="px-8">
{photo ? ( <figure className="relative h-32">
<Image src={photo} alt="" layout="fill" objectFit="contain" /> {photo ? (
) : ( <Image src={photo} alt="" layout="fill" objectFit="contain" />
<div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" /> ) : (
)} <div className="h-full w-full bg-gradient-to-r from-slate-300 to-indigo-200" />
</figure> )}
</div> </figure>
<div className="card-body"> </div>
{/* <h3 className="card-title line-clamp-3">{name}</h3> */} <Col className="p-8">
<div className="line-clamp-4 text-sm">{preview}</div> <div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && ( {raised > 0 && (
<> <>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900"> <Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Row className="items-baseline gap-1"> <Row className="items-baseline gap-1">
<span className="text-3xl font-semibold"> <span className="text-3xl font-semibold">
{formatUsd(raised)} {formatUsd(raised)}
</span> </span>
raised raised
</Row> </Row>
{/* {match && ( {/* {match && (
<Col className="text-gray-500"> <Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span> <span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span> <span className="">match</span>
</Col> </Col>
)} */} )} */}
</Row> </Row>
</> </>
)} )}
</div> </Col>
</div> </Card>
</a>
</Link> </Link>
) )
} }

View File

@ -9,8 +9,6 @@ import { BinaryContract } from 'common/contract'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { import {
TooltipProps, TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange, getDateRange,
getRightmostVisibleDate, getRightmostVisibleDate,
formatDateInRange, formatDateInRange,
@ -20,6 +18,10 @@ import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getBetPoints = (bets: Bet[]) => { const getBetPoints = (bets: Bet[]) => {
return sortBy(bets, (b) => b.createdTime).map((b) => ({ return sortBy(bets, (b) => b.createdTime).map((b) => ({
x: new Date(b.createdTime), x: new Date(b.createdTime),
@ -29,9 +31,9 @@ const getBetPoints = (bets: Bet[]) => {
} }
const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => { const BinaryChartTooltip = (props: TooltipProps<Date, HistoryPoint<Bet>>) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX) const d = xScale.invert(x)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
@ -73,14 +75,15 @@ export const BinaryContractChart = (props: {
<SingleValueHistoryChart <SingleValueHistoryChart
w={width} w={width}
h={height} h={height}
margin={MARGIN}
xScale={xScale} xScale={xScale}
yScale={yScale} yScale={yScale}
yKind="percent"
data={data} data={data}
color="#11b981" color="#11b981"
curve={curveStepAfter} curve={curveStepAfter}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
Tooltip={BinaryChartTooltip} Tooltip={BinaryChartTooltip}
pct
/> />
) )
} }

View File

@ -10,8 +10,6 @@ import { getOutcomeProbability } from 'common/calculate'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { import {
TooltipProps, TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange, getDateRange,
getRightmostVisibleDate, getRightmostVisibleDate,
formatPct, formatPct,
@ -21,64 +19,65 @@ import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors export const CATEGORY_COLORS = [
const CATEGORY_COLORS = [ '#7eb0d5',
'#00b8dd', '#fd7f6f',
'#eecafe', '#b2e061',
'#874c62', '#bd7ebe',
'#6457ca', '#ffb55a',
'#f773ba', '#ffee65',
'#9c6bbc', '#beb9db',
'#a87744', '#fdcce5',
'#af8a04', '#8bd3c7',
'#bff9aa', '#bddfb7',
'#f3d89d', '#e2e3f3',
'#c9a0f5', '#fafafa',
'#ff00e5', '#9fcdeb',
'#9dc6f7', '#d3d3d3',
'#824475', '#b1a296',
'#d973cc', '#e1bdb6',
'#bc6808', '#f2dbc0',
'#056e70', '#fae5d3',
'#677932', '#c5e0ec',
'#00b287', '#e0f0ff',
'#c8ab6c', '#ffddcd',
'#a2fb7a', '#fbd5e2',
'#f8db68', '#f2e7e5',
'#14675a', '#ffe7ba',
'#8288f4', '#eed9c4',
'#fe1ca0', '#ea9999',
'#ad6aff', '#f9cb9c',
'#786306', '#ffe599',
'#9bfbaf', '#b6d7a8',
'#b00cf7', '#a2c4c9',
'#2f7ec5', '#9fc5e8',
'#4b998b', '#b4a7d6',
'#42fa0e', '#d5a6bd',
'#5b80a1', '#e06666',
'#962d9d', '#f6b26b',
'#3385ff', '#ffd966',
'#48c5ab', '#93c47d',
'#b2c873', '#76a5af',
'#4cf9a4', '#6fa8dc',
'#00ffff', '#8e7cc3',
'#3cca73', '#c27ba0',
'#99ae17', '#cc0000',
'#7af5cf', '#e69138',
'#52af45', '#f1c232',
'#fbb80f', '#6aa84f',
'#29971b', '#45818e',
'#187c9a', '#3d85c6',
'#00d539', '#674ea7',
'#bbfa1a', '#a64d79',
'#61f55c', '#990000',
'#cabc03', '#b45f06',
'#ff9000', '#bf9000',
'#779100',
'#bcfd6f',
'#70a560',
] ]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getTrackedAnswers = ( const getTrackedAnswers = (
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
topN: number topN: number
@ -142,6 +141,15 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
) )
} }
export function useChartAnswers(
contract: FreeResponseContract | MultipleChoiceContract
) {
return useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
}
export const ChoiceContractChart = (props: { export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[] bets: Bet[]
@ -151,10 +159,7 @@ export const ChoiceContractChart = (props: {
}) => { }) => {
const { contract, bets, width, height, onMouseOver } = props const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract) const [start, end] = getDateRange(contract)
const answers = useMemo( const answers = useChartAnswers(contract)
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
const data = useMemo( const data = useMemo(
() => [ () => [
@ -178,9 +183,9 @@ export const ChoiceContractChart = (props: {
const ChoiceTooltip = useMemo( const ChoiceTooltip = useMemo(
() => (props: TooltipProps<Date, MultiPoint<Bet>>) => { () => (props: TooltipProps<Date, MultiPoint<Bet>>) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX) const d = xScale.invert(x)
const legendItems = sortBy( const legendItems = sortBy(
data.y.map((p, i) => ({ data.y.map((p, i) => ({
color: CATEGORY_COLORS[i], color: CATEGORY_COLORS[i],
@ -211,14 +216,15 @@ export const ChoiceContractChart = (props: {
<MultiValueHistoryChart <MultiValueHistoryChart
w={width} w={width}
h={height} h={height}
margin={MARGIN}
xScale={xScale} xScale={xScale}
yScale={yScale} yScale={yScale}
yKind="percent"
data={data} data={data}
colors={CATEGORY_COLORS} colors={CATEGORY_COLORS}
curve={curveStepAfter} curve={curveStepAfter}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip} Tooltip={ChoiceTooltip}
pct
/> />
) )
} }

View File

@ -6,9 +6,13 @@ import { formatLargeNumber } from 'common/util/format'
import { getDpmOutcomeProbabilities } from 'common/calculate-dpm' import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
import { NumericContract } from 'common/contract' import { NumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { TooltipProps, MARGIN_X, MARGIN_Y, formatPct } from '../helpers' import { TooltipProps, formatPct } from '../helpers'
import { DistributionPoint, DistributionChart } from '../generic-charts' import { DistributionPoint, DistributionChart } from '../generic-charts'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getNumericChartData = (contract: NumericContract) => { const getNumericChartData = (contract: NumericContract) => {
const { totalShares, bucketCount, min, max } = contract const { totalShares, bucketCount, min, max } = contract
const step = (max - min) / bucketCount const step = (max - min) / bucketCount
@ -22,11 +26,11 @@ const getNumericChartData = (contract: NumericContract) => {
const NumericChartTooltip = ( const NumericChartTooltip = (
props: TooltipProps<number, DistributionPoint> props: TooltipProps<number, DistributionPoint>
) => { ) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const x = xScale.invert(mouseX) const amount = xScale.invert(x)
return ( return (
<> <>
<span className="text-semibold">{formatLargeNumber(x)}</span> <span className="text-semibold">{formatLargeNumber(amount)}</span>
<span className="text-greyscale-6">{formatPct(data.y, 2)}</span> <span className="text-greyscale-6">{formatPct(data.y, 2)}</span>
</> </>
) )
@ -48,6 +52,7 @@ export const NumericContractChart = (props: {
<DistributionChart <DistributionChart
w={width} w={width}
h={height} h={height}
margin={MARGIN}
xScale={xScale} xScale={xScale}
yScale={yScale} yScale={yScale}
data={data} data={data}

View File

@ -11,8 +11,6 @@ import { PseudoNumericContract } from 'common/contract'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants' import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { import {
TooltipProps, TooltipProps,
MARGIN_X,
MARGIN_Y,
getDateRange, getDateRange,
getRightmostVisibleDate, getRightmostVisibleDate,
formatDateInRange, formatDateInRange,
@ -21,6 +19,10 @@ import { HistoryPoint, SingleValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
// mqp: note that we have an idiosyncratic version of 'log scale' // mqp: note that we have an idiosyncratic version of 'log scale'
// contracts. the values are stored "linearly" and can include zero. // contracts. the values are stored "linearly" and can include zero.
// as a result, we have to do some weird-looking stuff in this code // as a result, we have to do some weird-looking stuff in this code
@ -43,9 +45,9 @@ const getBetPoints = (bets: Bet[], scaleP: (p: number) => number) => {
const PseudoNumericChartTooltip = ( const PseudoNumericChartTooltip = (
props: TooltipProps<Date, HistoryPoint<Bet>> props: TooltipProps<Date, HistoryPoint<Bet>>
) => { ) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const [start, end] = xScale.domain() const [start, end] = xScale.domain()
const d = xScale.invert(mouseX) const d = xScale.invert(x)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
{data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />} {data.obj && <Avatar size="xs" avatarUrl={data.obj.userAvatarUrl} />}
@ -95,6 +97,7 @@ export const PseudoNumericContractChart = (props: {
<SingleValueHistoryChart <SingleValueHistoryChart
w={width} w={width}
h={height} h={height}
margin={MARGIN}
xScale={xScale} xScale={xScale}
yScale={yScale} yScale={yScale}
data={data} data={data}

View File

@ -7,6 +7,8 @@ import {
CurveFactory, CurveFactory,
SeriesPoint, SeriesPoint,
curveLinear, curveLinear,
curveStepBefore,
curveStepAfter,
stack, stack,
stackOrderReverse, stackOrderReverse,
} from 'd3-shape' } from 'd3-shape'
@ -14,34 +16,62 @@ import { range } from 'lodash'
import { import {
ContinuousScale, ContinuousScale,
Margin,
SVGChart, SVGChart,
AreaPath, AreaPath,
AreaWithTopStroke, AreaWithTopStroke,
Point, Point,
SliceMarker,
TooltipParams,
TooltipComponent, TooltipComponent,
computeColorStops,
formatPct, formatPct,
} from './helpers' } from './helpers'
import { useEvent } from 'web/hooks/use-event' import { useEvent } from 'web/hooks/use-event'
import { formatMoney } from 'common/util/format'
import { nanoid } from 'nanoid'
export type MultiPoint<T = unknown> = Point<Date, number[], T> export type MultiPoint<T = unknown> = Point<Date, number[], T>
export type HistoryPoint<T = unknown> = Point<Date, number, T> export type HistoryPoint<T = unknown> = Point<Date, number, T>
export type DistributionPoint<T = unknown> = Point<number, number, T> export type DistributionPoint<T = unknown> = Point<number, number, T>
export type ValueKind = 'm$' | 'percent' | 'amount'
type SliceExtent = { y0: number; y1: number }
const interpolateY = (
curve: CurveFactory,
x: number,
x0: number,
x1: number,
y0: number,
y1: number
) => {
if (curve === curveLinear) {
const p = (x - x0) / (x1 - x0)
return y0 * (1 - p) + y1 * p
} else if (curve === curveStepAfter) {
return y0
} else if (curve === curveStepBefore) {
return y1
}
}
const getTickValues = (min: number, max: number, n: number) => { const getTickValues = (min: number, max: number, n: number) => {
const step = (max - min) / (n - 1) const step = (max - min) / (n - 1)
return [min, ...range(1, n - 1).map((i) => min + step * i), max] return [min, ...range(1, n - 1).map((i) => min + step * i), max]
} }
const betAtPointSelector = <X, Y, P extends Point<X, Y>>( const dataAtPointSelector = <X, Y, P extends Point<X, Y>>(
data: P[], data: P[],
xScale: ContinuousScale<X> xScale: ContinuousScale<X>
) => { ) => {
const bisect = bisector((p: P) => p.x) const bisect = bisector((p: P) => p.x)
return (posX: number) => { return (posX: number) => {
const x = xScale.invert(posX) const x = xScale.invert(posX)
const item = data[bisect.left(data, x) - 1] const i = bisect.left(data, x)
const result = item ? { ...item, x: posX } : undefined const prev = data[i - 1] as P | undefined
return result const next = data[i] as P | undefined
return { prev, next, x: posX }
} }
} }
@ -50,14 +80,16 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
w: number w: number
h: number h: number
color: string color: string
margin: Margin
xScale: ScaleContinuousNumeric<number, number> xScale: ScaleContinuousNumeric<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
curve?: CurveFactory curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<number, P> Tooltip?: TooltipComponent<number, P>
}) => { }) => {
const { color, data, yScale, w, h, curve, Tooltip } = props const { data, w, h, color, margin, yScale, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] = const [viewXScale, setViewXScale] =
useState<ScaleContinuousNumeric<number, number>>() useState<ScaleContinuousNumeric<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
@ -72,7 +104,18 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, xScale, yScale]) }, [w, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale)) const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
@ -89,10 +132,13 @@ export const DistributionChart = <P extends DistributionPoint>(props: {
<SVGChart <SVGChart
w={w} w={w}
h={h} h={h}
margin={margin}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect} onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip} Tooltip={Tooltip}
> >
<AreaWithTopStroke <AreaWithTopStroke
@ -112,15 +158,17 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
w: number w: number
h: number h: number
colors: readonly string[] colors: readonly string[]
margin: Margin
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P> Tooltip?: TooltipComponent<Date, P>
pct?: boolean
}) => { }) => {
const { colors, data, yScale, w, h, curve, Tooltip, pct } = props const { data, w, h, colors, margin, yScale, yKind, curve, Tooltip } = props
const [ttParams, setTTParams] = useState<TooltipParams<P>>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
@ -131,29 +179,47 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100) const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct const yAxis =
? axisLeft<number>(yScale) yKind === 'percent'
.tickValues(pctTickValues) ? axisLeft<number>(yScale)
.tickFormat((n) => formatPct(n)) .tickValues(pctTickValues)
: axisLeft<number>(yScale) .tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale]) }, [w, h, yKind, xScale, yScale])
const series = useMemo(() => { const series = useMemo(() => {
const d3Stack = stack<P, number>() const d3Stack = stack<P, number>()
.keys(range(0, Math.max(...data.map(({ y }) => y.length)))) .keys(range(0, Math.max(...data.map(({ y }) => y.length))))
.value(({ y }, o) => y[o]) .value(({ y }, k) => y[k])
.order(stackOrderReverse) .order(stackOrderReverse)
return d3Stack(data) return d3Stack(data)
}, [data]) }, [data])
const onMouseOver = useEvent(betAtPointSelector(data, xScale)) const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number, mouseY: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
if (p.prev) {
setTTParams({ x: mouseX, y: mouseY, data: p.prev })
} else {
setTTParams(undefined)
}
})
const onMouseLeave = useEvent(() => setTTParams(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() => setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
) )
@ -166,10 +232,13 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
<SVGChart <SVGChart
w={w} w={w}
h={h} h={h}
margin={margin}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
ttParams={ttParams}
onSelect={onSelect} onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip} Tooltip={Tooltip}
> >
{series.map((s, i) => ( {series.map((s, i) => (
@ -191,66 +260,137 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
data: P[] data: P[]
w: number w: number
h: number h: number
color: string color: string | ((p: P) => string)
margin: Margin
xScale: ScaleTime<number, number> xScale: ScaleTime<number, number>
yScale: ScaleContinuousNumeric<number, number> yScale: ScaleContinuousNumeric<number, number>
yKind?: ValueKind
curve?: CurveFactory curve?: CurveFactory
onMouseOver?: (p: P | undefined) => void onMouseOver?: (p: P | undefined) => void
Tooltip?: TooltipComponent<Date, P> Tooltip?: TooltipComponent<Date, P>
pct?: boolean pct?: boolean
}) => { }) => {
const { color, data, yScale, w, h, curve, Tooltip, pct } = props const { data, w, h, color, margin, yScale, yKind, Tooltip } = props
const curve = props.curve ?? curveLinear
const [mouse, setMouse] = useState<TooltipParams<P> & SliceExtent>()
const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>() const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
const xScale = viewXScale ?? props.xScale const xScale = viewXScale ?? props.xScale
const px = useCallback((p: P) => xScale(p.x), [xScale]) const px = useCallback((p: P) => xScale(p.x), [xScale])
const py0 = yScale(yScale.domain()[0]) const py0 = yScale(0)
const py1 = useCallback((p: P) => yScale(p.y), [yScale]) const py1 = useCallback((p: P) => yScale(p.y), [yScale])
const { xAxis, yAxis } = useMemo(() => { const { xAxis, yAxis } = useMemo(() => {
const [min, max] = yScale.domain() const [min, max] = yScale.domain()
const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5) const nTicks = h < 200 ? 3 : 5
const pctTickValues = getTickValues(min, max, nTicks)
const xAxis = axisBottom<Date>(xScale).ticks(w / 100) const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
const yAxis = pct const yAxis =
? axisLeft<number>(yScale) yKind === 'percent'
.tickValues(pctTickValues) ? axisLeft<number>(yScale)
.tickFormat((n) => formatPct(n)) .tickValues(pctTickValues)
: axisLeft<number>(yScale) .tickFormat((n) => formatPct(n))
: yKind === 'm$'
? axisLeft<number>(yScale)
.ticks(nTicks)
.tickFormat((n) => formatMoney(n))
: axisLeft<number>(yScale).ticks(nTicks)
return { xAxis, yAxis } return { xAxis, yAxis }
}, [w, h, pct, xScale, yScale]) }, [w, h, yKind, xScale, yScale])
const onMouseOver = useEvent(betAtPointSelector(data, xScale)) const selector = dataAtPointSelector(data, xScale)
const onMouseOver = useEvent((mouseX: number) => {
const p = selector(mouseX)
props.onMouseOver?.(p.prev)
const x0 = p.prev ? xScale(p.prev.x) : xScale.range()[0]
const x1 = p.next ? xScale(p.next.x) : xScale.range()[1]
const y0 = p.prev ? yScale(p.prev.y) : yScale.range()[0]
const y1 = p.next ? yScale(p.next.y) : yScale.range()[1]
const markerY = interpolateY(curve, mouseX, x0, x1, y0, y1)
if (p.prev && markerY) {
setMouse({
x: mouseX,
y: markerY,
y0: py0,
y1: markerY,
data: p.prev,
})
} else {
setMouse(undefined)
}
})
const onMouseLeave = useEvent(() => setMouse(undefined))
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() => const newViewXScale = xScale
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) .copy()
) .domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
setViewXScale(() => newViewXScale)
const dataInView = data.filter((p) => {
const x = newViewXScale(p.x)
return x >= 0 && x <= w
})
const yMin = Math.min(...dataInView.map((p) => p.y))
const yMax = Math.max(...dataInView.map((p) => p.y))
// Prevents very small selections from being too zoomed in
if (yMax - yMin > 0.05) {
// adds a little padding to the top and bottom of the selection
yScale.domain([yMin - (yMax - yMin) * 0.1, yMax + (yMax - yMin) * 0.1])
}
} else { } else {
setViewXScale(undefined) setViewXScale(undefined)
yScale.domain([0, 1])
} }
}) })
const gradientId = useMemo(() => nanoid(), [])
const stops = useMemo(
() =>
typeof color !== 'string' ? computeColorStops(data, color, px) : null,
[color, data, px]
)
return ( return (
<SVGChart <SVGChart
w={w} w={w}
h={h} h={h}
margin={margin}
xAxis={xAxis} xAxis={xAxis}
yAxis={yAxis} yAxis={yAxis}
ttParams={
mouse ? { x: mouse.x, y: mouse.y, data: mouse.data } : undefined
}
onSelect={onSelect} onSelect={onSelect}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
Tooltip={Tooltip} Tooltip={Tooltip}
> >
{stops && (
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id={gradientId}>
{stops.map((s, i) => (
<stop key={i} offset={`${s.x / w}`} stopColor={s.color} />
))}
</linearGradient>
</defs>
)}
<AreaWithTopStroke <AreaWithTopStroke
color={color} color={typeof color === 'string' ? color : `url(#${gradientId})`}
data={data} data={data}
px={px} px={px}
py0={py0} py0={py0}
py1={py1} py1={py1}
curve={curve ?? curveLinear} curve={curve ?? curveLinear}
/> />
{mouse && (
<SliceMarker color="#5BCEFF" x={mouse.x} y0={mouse.y0} y1={mouse.y1} />
)}
</SVGChart> </SVGChart>
) )
} }

View File

@ -1,12 +1,4 @@
import { import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
ReactNode,
SVGProps,
memo,
useRef,
useEffect,
useMemo,
useState,
} from 'react'
import { pointer, select } from 'd3-selection' import { pointer, select } from 'd3-selection'
import { Axis, AxisScale } from 'd3-axis' import { Axis, AxisScale } from 'd3-axis'
import { brushX, D3BrushEvent } from 'd3-brush' import { brushX, D3BrushEvent } from 'd3-brush'
@ -17,6 +9,7 @@ import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size' import { useMeasureSize } from 'web/hooks/use-measure-size'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T } export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
@ -27,11 +20,12 @@ export interface ContinuousScale<T> extends AxisScale<T> {
export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never export type XScale<P> = P extends Point<infer X, infer _> ? AxisScale<X> : never
export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never export type YScale<P> = P extends Point<infer _, infer Y> ? AxisScale<Y> : never
export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } export type Margin = {
export const MARGIN_X = MARGIN.right + MARGIN.left top: number
export const MARGIN_Y = MARGIN.top + MARGIN.bottom right: number
const MARGIN_STYLE = `${MARGIN.top}px ${MARGIN.right}px ${MARGIN.bottom}px ${MARGIN.left}px` bottom: number
const MARGIN_XFORM = `translate(${MARGIN.left}, ${MARGIN.top})` left: number
}
export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => { export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
const { h, axis } = props const { h, axis } = props
@ -55,8 +49,6 @@ export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
useEffect(() => { useEffect(() => {
if (axisRef.current != null) { if (axisRef.current != null) {
select(axisRef.current) select(axisRef.current)
.transition()
.duration(250)
.call(axis) .call(axis)
.call((g) => .call((g) =>
g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1) g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
@ -100,14 +92,14 @@ const AreaPathInternal = <P,>(
export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
export const AreaWithTopStroke = <P,>(props: { export const AreaWithTopStroke = <P,>(props: {
color: string
data: P[] data: P[]
color: string
px: number | ((p: P) => number) px: number | ((p: P) => number)
py0: number | ((p: P) => number) py0: number | ((p: P) => number)
py1: number | ((p: P) => number) py1: number | ((p: P) => number)
curve: CurveFactory curve: CurveFactory
}) => { }) => {
const { color, data, px, py0, py1, curve } = props const { data, color, px, py0, py1, curve } = props
return ( return (
<g> <g>
<AreaPath <AreaPath
@ -124,23 +116,60 @@ export const AreaWithTopStroke = <P,>(props: {
) )
} }
export const SliceMarker = (props: {
color: string
x: number
y0: number
y1: number
}) => {
const { color, x, y0, y1 } = props
return (
<g>
<line stroke="white" strokeWidth={1} x1={x} x2={x} y1={y0} y2={y1} />
<circle
stroke="white"
strokeWidth={1}
fill={color}
cx={x}
cy={y1}
r={5}
/>
</g>
)
}
export const SVGChart = <X, TT>(props: { export const SVGChart = <X, TT>(props: {
children: ReactNode children: ReactNode
w: number w: number
h: number h: number
margin: Margin
xAxis: Axis<X> xAxis: Axis<X>
yAxis: Axis<number> yAxis: Axis<number>
ttParams: TooltipParams<TT> | undefined
onSelect?: (ev: D3BrushEvent<any>) => void onSelect?: (ev: D3BrushEvent<any>) => void
onMouseOver?: (mouseX: number, mouseY: number) => TT | undefined onMouseOver?: (mouseX: number, mouseY: number) => void
onMouseLeave?: () => void
Tooltip?: TooltipComponent<X, TT> Tooltip?: TooltipComponent<X, TT>
}) => { }) => {
const { children, w, h, xAxis, yAxis, onMouseOver, onSelect, Tooltip } = props const {
const [mouse, setMouse] = useState<{ x: number; y: number; data: TT }>() children,
w,
h,
margin,
xAxis,
yAxis,
ttParams,
onSelect,
onMouseOver,
onMouseLeave,
Tooltip,
} = props
const tooltipMeasure = useMeasureSize() const tooltipMeasure = useMeasureSize()
const overlayRef = useRef<SVGGElement>(null) const overlayRef = useRef<SVGGElement>(null)
const innerW = w - MARGIN_X const innerW = w - (margin.left + margin.right)
const innerH = h - MARGIN_Y const innerH = h - (margin.top + margin.bottom)
const clipPathId = useMemo(() => nanoid(), []) const clipPathId = useMemo(() => nanoid(), [])
const isMobile = useIsMobile()
const justSelected = useRef(false) const justSelected = useRef(false)
useEffect(() => { useEffect(() => {
@ -155,7 +184,7 @@ export const SVGChart = <X, TT>(props: {
if (!justSelected.current) { if (!justSelected.current) {
justSelected.current = true justSelected.current = true
onSelect(ev) onSelect(ev)
setMouse(undefined) onMouseLeave?.()
if (overlayRef.current) { if (overlayRef.current) {
select(overlayRef.current).call(brush.clear) select(overlayRef.current).call(brush.clear)
} }
@ -171,43 +200,49 @@ export const SVGChart = <X, TT>(props: {
.select('.selection') .select('.selection')
.attr('shape-rendering', 'null') .attr('shape-rendering', 'null')
} }
}, [innerW, innerH, onSelect]) }, [innerW, innerH, onSelect, onMouseLeave])
const onPointerMove = (ev: React.PointerEvent) => { const onPointerMove = (ev: React.PointerEvent) => {
if (ev.pointerType === 'mouse' && onMouseOver) { if (ev.pointerType === 'mouse' && onMouseOver) {
const [x, y] = pointer(ev) const [x, y] = pointer(ev)
const data = onMouseOver(x, y) onMouseOver(x, y)
if (data !== undefined) { }
setMouse({ x, y, data }) }
} else {
setMouse(undefined) const onTouchMove = (ev: React.TouchEvent) => {
} if (onMouseOver) {
const touch = ev.touches[0]
const x = touch.pageX - ev.currentTarget.getBoundingClientRect().left
const y = touch.pageY - ev.currentTarget.getBoundingClientRect().top
onMouseOver(x, y)
} }
} }
const onPointerLeave = () => { const onPointerLeave = () => {
setMouse(undefined) onMouseLeave?.()
} }
return ( return (
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
{mouse && Tooltip && ( {ttParams && Tooltip && (
<TooltipContainer <TooltipContainer
setElem={tooltipMeasure.setElem} setElem={tooltipMeasure.setElem}
margin={margin}
pos={getTooltipPosition( pos={getTooltipPosition(
mouse.x, ttParams.x,
mouse.y, ttParams.y,
innerW, innerW,
innerH, innerH,
tooltipMeasure.width, tooltipMeasure.width ?? 140,
tooltipMeasure.height tooltipMeasure.height ?? 35,
isMobile ?? false
)} )}
> >
<Tooltip <Tooltip
xScale={xAxis.scale()} xScale={xAxis.scale()}
mouseX={mouse.x} x={ttParams.x}
mouseY={mouse.y} y={ttParams.y}
data={mouse.data} data={ttParams.data}
/> />
</TooltipContainer> </TooltipContainer>
)} )}
@ -215,22 +250,34 @@ export const SVGChart = <X, TT>(props: {
<clipPath id={clipPathId}> <clipPath id={clipPathId}>
<rect x={0} y={0} width={innerW} height={innerH} /> <rect x={0} y={0} width={innerW} height={innerH} />
</clipPath> </clipPath>
<g transform={MARGIN_XFORM}> <g transform={`translate(${margin.left}, ${margin.top})`}>
<XAxis axis={xAxis} w={innerW} h={innerH} /> <XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} /> <YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g> <g clipPath={`url(#${clipPathId})`}>{children}</g>
<g {!isMobile ? (
ref={overlayRef} <g
x="0" ref={overlayRef}
y="0" x="0"
width={innerW} y="0"
height={innerH} width={innerW}
fill="none" height={innerH}
pointerEvents="all" fill="none"
onPointerEnter={onPointerMove} pointerEvents="all"
onPointerMove={onPointerMove} onPointerEnter={onPointerMove}
onPointerLeave={onPointerLeave} onPointerMove={onPointerMove}
/> onPointerLeave={onPointerLeave}
/>
) : (
<rect
x="0"
y="0"
width={innerW}
height={innerH}
fill="transparent"
onTouchMove={onTouchMove}
onTouchEnd={onPointerLeave}
/>
)}
</g> </g>
</svg> </svg>
</div> </div>
@ -244,41 +291,45 @@ export const getTooltipPosition = (
mouseY: number, mouseY: number,
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
tooltipWidth?: number, tooltipWidth: number,
tooltipHeight?: number tooltipHeight: number,
isMobile: boolean
) => { ) => {
let left = mouseX + 12 let left = mouseX + 12
let bottom = containerHeight - mouseY + 12 let bottom = !isMobile
? containerHeight - mouseY + 12
: containerHeight - tooltipHeight + 12
if (tooltipWidth != null) { if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) { if (overflow > 0) {
left -= overflow left -= overflow
} }
} }
if (tooltipHeight != null) { if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY const overflow = tooltipHeight - mouseY
if (overflow > 0) { if (overflow > 0) {
bottom -= overflow bottom -= overflow
} }
} }
return { left, bottom } return { left, bottom }
} }
export type TooltipProps<X, T> = { export type TooltipParams<T> = { x: number; y: number; data: T }
mouseX: number export type TooltipProps<X, T> = TooltipParams<T> & {
mouseY: number
xScale: ContinuousScale<X> xScale: ContinuousScale<X>
data: T
} }
export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>> export type TooltipComponent<X, T> = React.ComponentType<TooltipProps<X, T>>
export const TooltipContainer = (props: { export const TooltipContainer = (props: {
setElem: (e: HTMLElement | null) => void setElem: (e: HTMLElement | null) => void
pos: TooltipPosition pos: TooltipPosition
margin: Margin
className?: string className?: string
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const { setElem, pos, className, children } = props const { setElem, pos, margin, className, children } = props
return ( return (
<div <div
ref={setElem} ref={setElem}
@ -286,13 +337,43 @@ export const TooltipContainer = (props: {
className, className,
'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm' 'pointer-events-none absolute z-10 whitespace-pre rounded border border-gray-200 bg-white/80 p-2 px-4 py-2 text-xs sm:text-sm'
)} )}
style={{ margin: MARGIN_STYLE, ...pos }} style={{
margin: `${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`,
...pos,
}}
> >
{children} {children}
</div> </div>
) )
} }
export const computeColorStops = <P,>(
data: P[],
pc: (p: P) => string,
px: (p: P) => number
) => {
const segments: { x: number; color: string }[] = []
let currOffset = 0
let currColor = pc(data[0])
for (const p of data) {
const c = pc(p)
if (c !== currColor) {
segments.push({ x: currOffset, color: currColor })
currOffset = px(p)
currColor = c
}
}
segments.push({ x: currOffset, color: currColor })
const stops: { x: number; color: string }[] = []
stops.push({ x: segments[0].x, color: segments[0].color })
for (const s of segments.slice(1)) {
stops.push({ x: s.x, color: stops[stops.length - 1].color })
stops.push({ x: s.x, color: s.color })
}
return stops
}
export const getDateRange = (contract: Contract) => { export const getDateRange = (contract: Contract) => {
const { createdTime, closeTime, resolutionTime } = contract const { createdTime, closeTime, resolutionTime } = contract
const isClosed = !!closeTime && Date.now() > closeTime const isClosed = !!closeTime && Date.now() > closeTime

View File

@ -6,9 +6,13 @@ import dayjs from 'dayjs'
import { formatPercent } from 'common/util/format' import { formatPercent } from 'common/util/format'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { HistoryPoint, SingleValueHistoryChart } from './generic-charts' import { HistoryPoint, SingleValueHistoryChart } from './generic-charts'
import { TooltipProps, MARGIN_X, MARGIN_Y } from './helpers' import { TooltipProps } from './helpers'
import { SizedContainer } from 'web/components/sized-container' import { SizedContainer } from 'web/components/sized-container'
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getPoints = (startDate: number, dailyValues: number[]) => { const getPoints = (startDate: number, dailyValues: number[]) => {
const startDateDayJs = dayjs(startDate) const startDateDayJs = dayjs(startDate)
return dailyValues.map((y, i) => ({ return dailyValues.map((y, i) => ({
@ -18,8 +22,8 @@ const getPoints = (startDate: number, dailyValues: number[]) => {
} }
const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => { const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const d = xScale.invert(mouseX) const d = xScale.invert(x)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span> <span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
@ -29,8 +33,8 @@ const DailyCountTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
} }
const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => { const DailyPercentTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
const { data, mouseX, xScale } = props const { data, x, xScale } = props
const d = xScale.invert(mouseX) const d = xScale.invert(x)
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<span className="font-semibold">{dayjs(d).format('MMM DD')}</span> <span className="font-semibold">{dayjs(d).format('MMM DD')}</span>
@ -63,12 +67,13 @@ export function DailyChart(props: {
<SingleValueHistoryChart <SingleValueHistoryChart
w={width} w={width}
h={height} h={height}
margin={MARGIN}
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])} xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])} yScale={scaleLinear([0, maxValue], [height - MARGIN_Y, 0])}
yKind={pct ? 'percent' : 'amount'}
data={data} data={data}
Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip} Tooltip={pct ? DailyPercentTooltip : DailyCountTooltip}
color="#11b981" color="#11b981"
pct={pct}
/> />
)} )}
</SizedContainer> </SizedContainer>

View File

@ -138,16 +138,6 @@ export function CommentInputTextArea(props: {
<LoadingIndicator spinnerClassName="border-gray-500" /> <LoadingIndicator spinnerClassName="border-gray-500" />
)} )}
</TextEditor> </TextEditor>
<Row>
{!user && (
<button
className="btn btn-outline btn-sm mt-2 normal-case"
onClick={submitComment}
>
Add my comment
</button>
)}
</Row>
</> </>
) )
} }

View File

@ -16,16 +16,18 @@ export function ConfirmationButton(props: {
} }
cancelBtn?: { cancelBtn?: {
label?: string label?: string
className?: string color?: ColorType
} }
submitBtn?: { submitBtn?: {
label?: string label?: string
className?: string color?: ColorType
isSubmitting?: boolean
} }
children: ReactNode children: ReactNode
onSubmit?: () => void onSubmit?: () => void
onOpenChanged?: (isOpen: boolean) => void onOpenChanged?: (isOpen: boolean) => void
onSubmitWithSuccess?: () => Promise<boolean> onSubmitWithSuccess?: () => Promise<boolean>
disabled?: boolean
}) { }) {
const { const {
openModalBtn, openModalBtn,
@ -35,6 +37,7 @@ export function ConfirmationButton(props: {
children, children,
onOpenChanged, onOpenChanged,
onSubmitWithSuccess, onSubmitWithSuccess,
disabled,
} = props } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -50,14 +53,14 @@ export function ConfirmationButton(props: {
<Col className="gap-4 rounded-md bg-white px-8 py-6"> <Col className="gap-4 rounded-md bg-white px-8 py-6">
{children} {children}
<Row className="gap-4"> <Row className="gap-4">
<div <Button
className={clsx('btn', cancelBtn?.className)} color={cancelBtn?.color ?? 'gray-white'}
onClick={() => updateOpen(false)} onClick={() => updateOpen(false)}
> >
{cancelBtn?.label ?? 'Cancel'} {cancelBtn?.label ?? 'Cancel'}
</div> </Button>
<div <Button
className={clsx('btn', submitBtn?.className)} color={submitBtn?.color ?? 'blue'}
onClick={ onClick={
onSubmitWithSuccess onSubmitWithSuccess
? () => ? () =>
@ -66,15 +69,22 @@ export function ConfirmationButton(props: {
) )
: onSubmit : onSubmit
} }
loading={submitBtn?.isSubmitting}
> >
{submitBtn?.label ?? 'Submit'} {submitBtn?.label ?? 'Submit'}
</div> </Button>
</Row> </Row>
</Col> </Col>
</Modal> </Modal>
<Button <Button
className={clsx(openModalBtn.className)} className={openModalBtn.className}
onClick={() => updateOpen(true)} onClick={() => {
if (disabled) {
return
}
updateOpen(true)
}}
disabled={openModalBtn.disabled} disabled={openModalBtn.disabled}
color={openModalBtn.color} color={openModalBtn.color}
size={openModalBtn.size} size={openModalBtn.size}
@ -90,18 +100,11 @@ export function ResolveConfirmationButton(props: {
onResolve: () => void onResolve: () => void
isSubmitting: boolean isSubmitting: boolean
openModalButtonClass?: string openModalButtonClass?: string
submitButtonClass?: string
color?: ColorType color?: ColorType
disabled?: boolean disabled?: boolean
}) { }) {
const { const { onResolve, isSubmitting, openModalButtonClass, color, disabled } =
onResolve, props
isSubmitting,
openModalButtonClass,
submitButtonClass,
color,
disabled,
} = props
return ( return (
<ConfirmationButton <ConfirmationButton
openModalBtn={{ openModalBtn={{
@ -116,7 +119,8 @@ export function ResolveConfirmationButton(props: {
}} }}
submitBtn={{ submitBtn={{
label: 'Resolve', label: 'Resolve',
className: clsx('border-none', submitButtonClass), color: color,
isSubmitting,
}} }}
onSubmit={onResolve} onSubmit={onResolve}
> >

View File

@ -41,12 +41,15 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
import { Button } from './button' import { Button } from './button'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import { Input } from './input'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: 'Daily trending', value: 'daily-score' }, { label: 'Daily trending', value: 'daily-score' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: 'Most popular', value: 'most-popular' },
{ label: 'Liquidity', value: 'liquidity' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Closing soon', value: 'close-date' }, { label: 'Closing soon', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' }, { label: 'Resolve date', value: 'resolve-date' },
@ -436,16 +439,16 @@ function ContractSearchControls(props: {
return ( return (
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}> <Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2"> <Row className="gap-1 sm:gap-2">
<input <Input
type="text" type="text"
value={query} value={query}
onChange={(e) => updateQuery(e.target.value)} onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query: query })} onBlur={trackCallback('search', { query: query })}
placeholder={'Search'} placeholder="Search"
className="input input-bordered w-full" className="w-full"
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
{!isMobile && ( {!isMobile && !query && (
<SearchFilters <SearchFilters
filter={filter} filter={filter}
selectFilter={selectFilter} selectFilter={selectFilter}
@ -456,7 +459,7 @@ function ContractSearchControls(props: {
includeProbSorts={includeProbSorts} includeProbSorts={includeProbSorts}
/> />
)} )}
{isMobile && ( {isMobile && !query && (
<> <>
<MobileSearchBar <MobileSearchBar
children={ children={

View File

@ -1,12 +1,16 @@
import clsx from 'clsx'
import { useState } from 'react'
import { CurrencyDollarIcon } from '@heroicons/react/outline' import { CurrencyDollarIcon } from '@heroicons/react/outline'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Tooltip } from 'web/components/tooltip'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
import { Tooltip } from 'web/components/tooltip'
import { CommentBountyDialog } from './comment-bounty-dialog'
export function BountiedContractBadge() { export function BountiedContractBadge() {
return ( return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800"> <span className="inline-flex items-center gap-1 rounded-full bg-indigo-300 px-3 py-0.5 text-sm font-medium text-white">
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty <CurrencyDollarIcon className={'h4 w-4'} /> Bounty
</span> </span>
) )
@ -18,30 +22,59 @@ export function BountiedContractSmallBadge(props: {
}) { }) {
const { contract, showAmount } = props const { contract, showAmount } = props
const { openCommentBounties } = contract const { openCommentBounties } = contract
if (!openCommentBounties) return <div />
return ( const [open, setOpen] = useState(false)
<Tooltip
text={CommentBountiesTooltipText( if (!openCommentBounties && !showAmount) return <></>
contract.creatorName,
openCommentBounties const modal = (
)} <CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
placement="bottom"
>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<CurrencyDollarIcon className={'h3 w-3'} />
{showAmount && formatMoney(openCommentBounties)} Bounty
</span>
</Tooltip>
) )
}
export const CommentBountiesTooltipText = ( const bountiesClosed =
creator: string, contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
openCommentBounties: number
) => if (!openCommentBounties) {
`${creator} may award ${formatMoney( if (bountiesClosed) return <></>
return (
<>
{modal}
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
</>
)
}
const tooltip = `${contract.creatorName} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney( )} for good comments. ${formatMoney(
openCommentBounties openCommentBounties
)} currently available.` )} currently available.`
return (
<Tooltip text={tooltip} placement="bottom">
{modal}
<SmallBadge
text={`${formatMoney(openCommentBounties)} bounty`}
onClick={bountiesClosed ? undefined : () => setOpen(true)}
/>
</Tooltip>
)
}
function SmallBadge(props: { text: string; onClick?: () => void }) {
const { text, onClick } = props
return (
<button
onClick={onClick}
className={clsx(
'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white',
!onClick && 'cursor-default'
)}
>
<CurrencyDollarIcon className={'h4 w-4'} />
{text}
</button>
)
}

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