Merge branch 'main' into austin/dc-hackathon

This commit is contained in:
Austin Chen 2022-10-12 09:27:34 -07:00
commit cd73baad8c
132 changed files with 2936 additions and 1356 deletions

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 }
@ -267,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,11 +1,17 @@
import { last, sortBy, sum, sumBy, uniq } from 'lodash' import { Dictionary, groupBy, last, partition, sum, sumBy, uniq } from 'lodash'
import { calculatePayout } from './calculate' import { calculatePayout, getContractBetMetrics } from './calculate'
import { Bet, LimitBet } from './bet' import { Bet, LimitBet } from './bet'
import { Contract, CPMMContract, DPMContract } 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 { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm' import { getCpmmProbability } from './calculate-cpmm'
import { removeUndefinedProps } from './util/object'
const computeInvestmentValue = ( const computeInvestmentValue = (
bets: Bet[], bets: Bet[],
@ -35,8 +41,7 @@ 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
}) })
@ -97,7 +102,11 @@ export const computeBinaryCpmmElasticity = (
) )
const resultNo = getCpmmProbability(poolN, pN) const resultNo = getCpmmProbability(poolN, pN)
return resultYes - resultNo // handle AMM overflow
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
return safeYes - safeNo
} }
export const computeDpmElasticity = ( export const computeDpmElasticity = (
@ -190,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
} }
@ -212,33 +216,100 @@ 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, recentBets] = partition(
bets,
(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: recentProfit, invested: recentInvested } =
getContractBetMetrics(contract, recentBets)
const profit = currentBetsValue - previousBetsValue + recentProfit
const invested = previousBetsValue + recentInvested
const profitPercent = invested === 0 ? 0 : 100 * (profit / invested)
return {
profit,
profitPercent,
invested,
prevValue: previousBetsValue,
value: currentBetsValue,
}
}

View File

@ -178,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'
@ -215,7 +217,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
} }
const profit = payout + saleValue + redeemed - totalInvested const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100 const profitPercent = 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(

View File

@ -91,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

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

3
common/globalConfig.ts Normal file
View File

@ -0,0 +1,3 @@
export type GlobalConfig = {
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}

View File

@ -63,6 +63,7 @@ export function getNewContract(
tags: [], tags: [],
lowercaseTags: [], lowercaseTags: [],
visibility, visibility,
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
isResolved: false, isResolved: false,
createdTime: Date.now(), createdTime: Date.now(),
closeTime, closeTime,
@ -111,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'
@ -237,6 +238,10 @@ 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: { opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)', simple: 'Opt out of all notifications (excludes when your markets close)',
detailed: detailed:

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

@ -8,6 +8,10 @@ export type Post = {
creatorId: string // User id creatorId: string // User id
createdTime: number createdTime: number
slug: string slug: string
// denormalized user fields
creatorName: string
creatorUsername: string
} }
export type DateDoc = Post & { export type DateDoc = Post & {

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

@ -53,7 +53,7 @@ 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[] opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users // When adding a new notification preference, use add-new-notification-preference.ts to existing users
} }
@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = (
onboarding_flow: constructPref(false, false), onboarding_flow: constructPref(false, false),
opt_out_all: [], opt_out_all: [],
badges_awarded: constructPref(true, false),
} }
return defaults return defaults
} }
@ -178,6 +179,8 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference reason: notification_reason_types | notification_preference
) => { ) => {
const notificationSettings = privateUser.notificationPreferences const notificationSettings = privateUser.notificationPreferences
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
try {
let destinations let destinations
let subscriptionType: notification_preference | undefined let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) { if (Object.keys(notificationSettings).includes(reason)) {
@ -198,11 +201,22 @@ export const getNotificationDestinationsForUser = (
const optedOutOfBrowser = const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') && optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed' subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return { return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail, sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${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
@ -51,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 = {
@ -81,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.

View File

@ -60,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

@ -53,7 +53,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,
@ -73,7 +73,8 @@ export const exhibitExts = [
Image, Image,
Link, Link,
Mention, Mention, // user @mention
Mention.extend({ name: 'contract-mention' }), // market %mention
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler, TiptapSpoiler,
@ -97,7 +98,7 @@ export function richTextToString(text?: JSONContent) {
current.type = 'text' current.type = 'text'
} }
}) })
return generateText(newText, exhibitExts) return generateText(newText, stringParseExts)
} }
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => { const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {

View File

@ -23,11 +23,17 @@ service cloud.firestore {
allow read; allow read;
} }
match /globalConfig/globalConfig {
allow read;
allow update: if isAdmin()
allow create: if isAdmin()
}
match /users/{userId} { match /users/{userId} {
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 +50,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;
} }

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

@ -20,7 +20,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
3. `$ firebase login` to authenticate the CLI tools to Firebase 3. `$ firebase login` to authenticate the CLI tools to Firebase
4. `$ firebase use dev` to choose the dev project 4. `$ firebase use dev` to choose the dev project
### For local development #### (Installing) For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
@ -35,10 +35,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Developing locally ## Developing locally
0. `$ firebase use dev` if you haven't already 0. `$ ./dev.sh localdb` to start the local emulator and front end
1. `$ yarn serve` to spin up the emulators 0. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001. 1. If you change db trigger code, you have to start (doesn't have to complete) the deploy of it to dev to cause a hard emulator code refresh `$ firebase deploy --only functions:dbTriggerNameHere`
Note: You have to kill and restart emulators when you change code; no hot reload =( - There's surely a better way to cause/react to a db trigger update but just adding this here for now as it works
2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 0. Note: emulated database is cleared after every shutdown 2. If you want to test a scheduled function replace your function in `test-scheduled-function.ts` and send a GET to `http://localhost:8088/testscheduledfunction` (Best user experience is via [Postman](https://www.postman.com/downloads/)!)
## Firestore Commands ## Firestore Commands

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',
message: 'Liquidity injection rejected due to overflow error.',
}
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity, totalLiquidity: newTotalLiquidity,
}) } as Partial<CPMMContract>)
)
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

@ -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

@ -100,6 +100,8 @@ export const createpost = newEndpoint({}, async (req, auth) => {
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: content,
contractSlug, contractSlug,
creatorName: creator.name,
creatorUsername: creator.username,
}) })
await postRef.create(post) await postRef.create(post)

View File

@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true, shouldShowWelcome: true,
fractionResolvedCorrectly: 1, 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

@ -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 { updateMetrics } 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'
@ -68,7 +68,6 @@ import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { addliquidity } from './add-liquidity'
import { createcomment } from './create-comment' import { createcomment } from './create-comment'
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'
@ -79,6 +78,7 @@ 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 { 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)
@ -94,11 +94,10 @@ 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 createCommentFunction = toCloudFunction(createcomment) const createCommentFunction = toCloudFunction(createcomment)
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)
@ -123,8 +122,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,

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)
return snap.docs const now = Date.now()
.map((doc) => { const closeContracts = contracts.filter(
const contract = doc.data() as Contract (contract) =>
contract.closeTime &&
if ( contract.closeTime < now &&
contract.resolution || shouldSendFirstOrFollowUpCloseNotification(contract)
(contract.closeEmailsSent ?? 0) >= 1 ||
contract.closeTime === undefined ||
(contract.closeTime ?? 0) > Date.now()
) )
return undefined
transaction.update(doc.ref, { await Promise.all(
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1, closeContracts.map(async (contract) => {
await transaction.update(
firestore.collection('contracts').doc(contract.id),
{
closeEmailsSent: admin.firestore.FieldValue.increment(1),
}
)
}) })
)
return contract return closeContracts
})
.filter((x) => !!x) as Contract[]
}) })
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,9 +1,19 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } 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 { GroupContractDoc } from '../../common/group' import { Bet } from '../../common/bet'
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}')
@ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore
if (!previousContract.isResolved && contract.isResolved) { if (!previousContract.isResolved && contract.isResolved) {
// 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) { } else if (previousContract.groupSlugs !== contract.groupSlugs) {
await handleContractGroupUpdated(previousContract, contract) await handleContractGroupUpdated(previousContract, contract)
} else if ( } else if (
@ -26,6 +36,64 @@ export const onUpdateContract = functions.firestore
} }
}) })
async function handleResolvedContract(contract: Contract) {
if (
(contract.uniqueBettorCount ?? 0) <
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE ||
contract.resolution === 'CANCEL'
)
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,

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

@ -11,15 +11,18 @@ async function main() {
await Promise.all( await Promise.all(
privateUsers.map((privateUser) => { privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve() if (!privateUser.id) return Promise.resolve()
if (privateUser.notificationPreferences.badges_awarded === undefined) {
return firestore return firestore
.collection('private-users') .collection('private-users')
.doc(privateUser.id) .doc(privateUser.id)
.update({ .update({
notificationPreferences: { notificationPreferences: {
...privateUser.notificationPreferences, ...privateUser.notificationPreferences,
opt_out_all: [], badges_awarded: ['browser'],
}, },
}) })
}
return
}) })
) )
} }

View File

@ -0,0 +1,136 @@
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
} else {
// Make corrections to existing achievements
await awardMarketCreatorBadges(user)
}
})
)
}
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)
)
).filter((c) => !c.resolution || c.resolution != 'CANCEL')
const achievements = {
...user.achievements,
marketCreator: {
badges: [...(user.achievements.marketCreator?.badges ?? [])],
},
}
for (const threshold of marketCreatorBadgeRarityThresholds) {
const alreadyHasBadge = user.achievements.marketCreator?.badges.some(
(b) => b.data.totalContractsCreated === threshold
)
if (alreadyHasBadge) continue
if (contracts.length >= threshold) {
console.log(`User ${user.id} has at least ${threshold} contracts`)
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

@ -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

@ -63,10 +63,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 { isProd } from './utils' import { isProd } from './utils'
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails' 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 sendTrendingMarketsEmailsToAllUsers() await sendMarketCloseEmails()
return { success: true } return { success: true }
} }

View File

@ -1,6 +1,6 @@
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 fetch from 'node-fetch'
import { getValues, log, logMemory, writeAsync } from './utils' import { getValues, log, logMemory, writeAsync } from './utils'
@ -15,6 +15,7 @@ import {
calculateNewPortfolioMetrics, calculateNewPortfolioMetrics,
calculateNewProfit, calculateNewProfit,
calculateProbChanges, calculateProbChanges,
calculateMetricsByContract,
computeElasticity, computeElasticity,
computeVolume, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
@ -23,13 +24,15 @@ import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise' import { batchedWaitAll } from '../../common/util/promise'
import { newEndpointNoAuth } from './api' import { newEndpointNoAuth } from './api'
import { getFunctionUrl } from '../../common/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
export const updateMetrics = functions.pubsub
.schedule('every 15 minutes') .schedule('every 15 minutes')
.onRun(async () => { .onRun(async () => {
const response = await fetch(getFunctionUrl('updatemetrics'), { const url = getFunctionUrl('updatemetrics')
console.log('Scheduling update metrics', url)
const response = await fetch(url, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@ -59,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'))
@ -140,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(
@ -152,14 +150,20 @@ 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 const contractRatios = userContracts
.map((contract) => { .map((contract) => {
if ( if (
@ -169,7 +173,7 @@ export async function updateMetricsCore() {
return 0 return 0
} }
const contractRatio = const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
return contractRatio return contractRatio
}) })
@ -177,7 +181,7 @@ export async function updateMetricsCore() {
const badResolutions = contractRatios.filter( const badResolutions = contractRatios.filter(
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD (ratio) => ratio > BAD_RESOLUTION_THRESHOLD
) )
let newFractionResolvedCorrectly = 0 let newFractionResolvedCorrectly = 1
if (userContracts.length > 0) { if (userContracts.length > 0) {
newFractionResolvedCorrectly = newFractionResolvedCorrectly =
(userContracts.length - badResolutions.length) / userContracts.length (userContracts.length - badResolutions.length) / userContracts.length
@ -190,6 +194,7 @@ export async function updateMetricsCore() {
newProfit, newProfit,
didPortfolioChange, didPortfolioChange,
newFractionResolvedCorrectly, newFractionResolvedCorrectly,
metricsByContract,
} }
}) })
@ -205,17 +210,9 @@ 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,
newFractionResolvedCorrectly,
}) => {
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,
@ -223,45 +220,51 @@ export async function updateMetricsCore() {
nextLoanCached, nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly, fractionResolvedCorrectly: newFractionResolvedCorrectly,
}, },
}, }
}
)
await writeAsync(firestore, userUpdates)
subcollectionUpdates: { const portfolioHistoryUpdates = filterDefined(
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
return didPortfolioChange
? {
doc: firestore doc: firestore
.collection('users') .collection('users')
.doc(user.id) .doc(user.id)
.collection('portfolioHistory') .collection('portfolioHistory')
.doc(), .doc(),
fields: didPortfolioChange ? newPortfolio : {}, fields: newPortfolio,
},
} }
: null
})
)
await writeAsync(firestore, portfolioHistoryUpdates, 'set')
const contractMetricsUpdates = userMetrics.flatMap(
({ user, metricsByContract }) => {
const collection = firestore
.collection('users')
.doc(user.id)
.collection('contract-metrics')
return metricsByContract.map((metrics) => ({
doc: collection.doc(metrics.contractId),
fields: metrics,
}))
} }
) )
await writeAsync(
firestore, await writeAsync(firestore, contractMetricsUpdates, 'set')
userUpdates.map((u) => u.fieldUpdates)
)
await writeAsync(
firestore,
userUpdates
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
.map((u) => u.subcollectionUpdates),
'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)
@ -295,3 +298,44 @@ 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 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

@ -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

@ -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

@ -6,6 +6,7 @@ import { Col } from './layout/col'
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 { AddFundsModal } from './add-funds-modal'
import { Input } from './input'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -44,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

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}
@ -92,7 +93,7 @@ export function AnswerItem(props: {
<div <div
className={clsx( className={clsx(
'text-2xl', 'text-2xl',
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500' tradingAllowed(contract) ? 'text-teal-500' : 'text-gray-500'
)} )}
> >
{probPercent} {probPercent}
@ -143,7 +144,7 @@ export function AnswerItem(props: {
<div <div
className={clsx( className={clsx(
'text-xl', 'text-xl',
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700' resolution === 'MKT' ? 'text-blue-700' : 'text-teal-600'
)} )}
> >
Chosen{' '} Chosen{' '}

View File

@ -23,7 +23,7 @@ import { Linkify } from 'web/components/linkify'
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 { CHOICE_ANSWER_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice' import { useChartAnswers } from '../charts/contract/choice'
export function AnswersPanel(props: { export function AnswersPanel(props: {
@ -190,7 +190,10 @@ function OpenAnswer(props: {
const probPercent = formatPercent(prob) const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const color = const color =
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7' colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
? CHOICE_ANSWER_COLORS[colorIndex] + '55' // semi-transparent
: '#B1B1C755'
const colorWidth = 100 * Math.max(prob, 0.01)
return ( return (
<Col className="my-1 px-2"> <Col className="my-1 px-2">
@ -206,9 +209,12 @@ function OpenAnswer(props: {
<Col <Col
className={clsx( className={clsx(
'bg-greyscale-1 relative w-full rounded-lg transition-all', 'relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5' tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)} )}
style={{
background: `linear-gradient(to right, ${color} ${colorWidth}%, #FBFBFF ${colorWidth}%)`,
}}
> >
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3"> <Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row> <Row>
@ -236,11 +242,6 @@ function OpenAnswer(props: {
)} )}
</Row> </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> </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

@ -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

@ -47,6 +47,7 @@ import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid' import { CheckIcon } from '@heroicons/react/solid'
import { Button } from './button'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -469,7 +470,6 @@ function LimitOrderPanel(props: {
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>() const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
const [highLimitProb, setHighLimitProb] = useState<number | undefined>() const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
const betChoice = 'YES'
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -783,22 +783,18 @@ function LimitOrderPanel(props: {
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />} {(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
{user && ( {user && (
<button <Button
className={clsx( size="xl"
'btn flex-1', disabled={betDisabled ? true : false}
betDisabled color={'indigo'}
? 'btn-disabled' loading={isSubmitting}
: betChoice === 'YES' className="flex-1"
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting {isSubmitting
? 'Submitting...' ? 'Submitting...'
: `Submit order${hasTwoBets ? 's' : ''}`} : `Submit order${hasTwoBets ? 's' : ''}`}
</button> </Button>
)} )}
</Col> </Col>
) )

View File

@ -20,11 +20,10 @@ 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 { 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 { CopyLinkButton } from '../copy-link-button'
import { ExpandingInput } from '../expanding-input'
type challengeInfo = { type challengeInfo = {
amount: number amount: number
@ -43,7 +42,6 @@ export function CreateChallengeModal(props: {
const { user, contract, isOpen, setOpen } = props const { user, contract, isOpen, setOpen } = props
const [challengeSlug, setChallengeSlug] = useState('') const [challengeSlug, setChallengeSlug] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { editor } = useTextEditor({ placeholder: '' })
return ( return (
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
@ -64,7 +62,6 @@ export function CreateChallengeModal(props: {
question: newChallenge.question, question: newChallenge.question,
outcomeType: 'BINARY', outcomeType: 'BINARY',
initialProb: 50, initialProb: 50,
description: editor?.getJSON(),
ante: FIXED_ANTE, ante: FIXED_ANTE,
closeTime: dayjs().add(30, 'day').valueOf(), closeTime: dayjs().add(30, 'day').valueOf(),
}) })
@ -153,9 +150,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}

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash' import { last, range, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale' import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape' import { curveStepAfter } from 'd3-shape'
@ -19,83 +19,36 @@ 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'
export const CATEGORY_COLORS = [ type ChoiceContract = FreeResponseContract | MultipleChoiceContract
'#7eb0d5',
'#fd7f6f', export const CHOICE_ANSWER_COLORS = [
'#b2e061', '#97C1EB',
'#bd7ebe', '#F39F83',
'#ffb55a', '#F9EBA5',
'#ffee65', '#FFC7D2',
'#beb9db', '#C7ECFF',
'#fdcce5', '#8CDEC7',
'#8bd3c7', '#DBE96F',
'#bddfb7',
'#e2e3f3',
'#fafafa',
'#9fcdeb',
'#d3d3d3',
'#b1a296',
'#e1bdb6',
'#f2dbc0',
'#fae5d3',
'#c5e0ec',
'#e0f0ff',
'#ffddcd',
'#fbd5e2',
'#f2e7e5',
'#ffe7ba',
'#eed9c4',
'#ea9999',
'#f9cb9c',
'#ffe599',
'#b6d7a8',
'#a2c4c9',
'#9fc5e8',
'#b4a7d6',
'#d5a6bd',
'#e06666',
'#f6b26b',
'#ffd966',
'#93c47d',
'#76a5af',
'#6fa8dc',
'#8e7cc3',
'#c27ba0',
'#cc0000',
'#e69138',
'#f1c232',
'#6aa84f',
'#45818e',
'#3d85c6',
'#674ea7',
'#a64d79',
'#990000',
'#b45f06',
'#bf9000',
] ]
export const CHOICE_OTHER_COLOR = '#CCC'
export const CHOICE_ALL_COLORS = [...CHOICE_ANSWER_COLORS, CHOICE_OTHER_COLOR]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getTrackedAnswers = ( const getAnswers = (contract: ChoiceContract) => {
contract: FreeResponseContract | MultipleChoiceContract, const { answers, outcomeType } = contract
topN: number const validAnswers = answers.filter(
) => { (answer) => answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE'
const { answers, outcomeType, totalBets } = contract
const validAnswers = answers.filter((answer) => {
return (
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
) )
})
return sortBy( return sortBy(
validAnswers, validAnswers,
(answer) => -1 * getOutcomeProbability(contract, answer.id) (answer) => -1 * getOutcomeProbability(contract, answer.id)
).slice(0, topN) )
} }
const getBetPoints = (answers: Answer[], bets: Bet[]) => { const getBetPoints = (answers: Answer[], bets: Bet[], topN?: number) => {
const sortedBets = sortBy(bets, (b) => b.createdTime) const sortedBets = sortBy(bets, (b) => b.createdTime)
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
const sharesByOutcome = Object.fromEntries( const sharesByOutcome = Object.fromEntries(
@ -109,11 +62,14 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
const sharesSquared = sum( const sharesSquared = sum(
Object.values(sharesByOutcome).map((shares) => shares ** 2) Object.values(sharesByOutcome).map((shares) => shares ** 2)
) )
points.push({ const probs = answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared)
x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), if (topN != null && answers.length > topN) {
obj: bet, const y = [...probs.slice(0, topN), sum(probs.slice(topN))]
}) points.push({ x: new Date(bet.createdTime), y, obj: bet })
} else {
points.push({ x: new Date(bet.createdTime), y: probs, obj: bet })
}
} }
return points return points
} }
@ -141,17 +97,12 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
) )
} }
export function useChartAnswers( export function useChartAnswers(contract: ChoiceContract) {
contract: FreeResponseContract | MultipleChoiceContract return useMemo(() => getAnswers(contract), [contract])
) {
return useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
} }
export const ChoiceContractChart = (props: { export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract contract: ChoiceContract
bets: Bet[] bets: Bet[]
width: number width: number
height: number height: number
@ -160,18 +111,33 @@ 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 = useChartAnswers(contract) const answers = useChartAnswers(contract)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const topN = Math.min(CHOICE_ANSWER_COLORS.length, answers.length)
const data = useMemo( const betPoints = useMemo(
() => [ () => getBetPoints(answers, bets, topN),
{ x: new Date(start), y: answers.map((_) => 0) }, [answers, bets, topN]
)
const endProbs = useMemo(
() => answers.map((a) => getOutcomeProbability(contract, a.id)),
[answers, contract]
)
const data = useMemo(() => {
const yCount = answers.length > topN ? topN + 1 : topN
const startY = range(0, yCount).map((_) => 0)
const endY =
answers.length > topN
? [...endProbs.slice(0, topN), sum(endProbs.slice(topN))]
: endProbs
return [
{ x: new Date(start), y: startY },
...betPoints, ...betPoints,
{ {
x: new Date(end ?? Date.now() + DAY_MS), x: new Date(end ?? Date.now() + DAY_MS),
y: answers.map((a) => getOutcomeProbability(contract, a.id)), y: endY,
}, },
], ]
[answers, contract, betPoints, start, end] }, [answers.length, topN, betPoints, endProbs, start, end])
)
const rightmostDate = getRightmostVisibleDate( const rightmostDate = getRightmostVisibleDate(
end, end,
last(betPoints)?.x?.getTime(), last(betPoints)?.x?.getTime(),
@ -188,8 +154,8 @@ export const ChoiceContractChart = (props: {
const d = xScale.invert(x) 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: CHOICE_ALL_COLORS[i],
label: answers[i].text, label: i === CHOICE_ANSWER_COLORS.length ? 'Other' : answers[i].text,
value: formatPct(p), value: formatPct(p),
p, p,
})), })),
@ -221,7 +187,7 @@ export const ChoiceContractChart = (props: {
yScale={yScale} yScale={yScale}
yKind="percent" yKind="percent"
data={data} data={data}
colors={CATEGORY_COLORS} colors={CHOICE_ALL_COLORS}
curve={curveStepAfter} curve={curveStepAfter}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip} Tooltip={ChoiceTooltip}

View File

@ -17,13 +17,21 @@ export function CommentInput(props: {
// Reply to another comment // Reply to another comment
parentCommentId?: string parentCommentId?: string
onSubmitComment?: (editor: Editor) => void onSubmitComment?: (editor: Editor) => void
// unique id for autosave
pageId: string
className?: string className?: string
}) { }) {
const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } = const {
props parentAnswerOutcome,
parentCommentId,
replyTo,
onSubmitComment,
pageId,
} = props
const user = useUser() const user = useUser()
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
key: `comment ${pageId} ${parentCommentId ?? parentAnswerOutcome ?? ''}`,
simple: true, simple: true,
max: MAX_COMMENT_LENGTH, max: MAX_COMMENT_LENGTH,
placeholder: placeholder:
@ -80,7 +88,7 @@ export function CommentInputTextArea(props: {
const submit = () => { const submit = () => {
submitComment() submitComment()
editor?.commands?.clearContent() editor?.commands?.clearContent(true)
} }
useEffect(() => { useEffect(() => {
@ -107,7 +115,7 @@ export function CommentInputTextArea(props: {
}, },
}) })
// insert at mention and focus // insert at mention and focus
if (replyTo) { if (replyTo && editor.isEmpty) {
editor editor
.chain() .chain()
.setContent({ .setContent({

View File

@ -41,6 +41,7 @@ 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' },
@ -438,13 +439,13 @@ 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 && !query && ( {!isMobile && !query && (

View File

@ -0,0 +1,36 @@
import { useState } from 'react'
import clsx from 'clsx'
import { buttonClass } from 'web/components/button'
import { CPMMContract } from 'common/contract'
import { LiquidityModal } from './liquidity-modal'
export function AddLiquidityButton(props: {
contract: CPMMContract
className?: string
}) {
const { contract, className } = props
const [open, setOpen] = useState(false)
const disabled =
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
if (disabled) return <></>
return (
<a
className={clsx(
buttonClass('2xs', 'override'),
'cursor-pointer',
'gap-1 border-2 border-blue-400 text-blue-400 hover:bg-blue-400 hover:text-white',
className
)}
onClick={() => setOpen(true)}
target="_blank"
>
<div>💧 Add liquidity</div>
<LiquidityModal contract={contract} isOpen={open} setOpen={setOpen} />
</a>
)
}

View File

@ -3,9 +3,9 @@ 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' import { CommentBountyDialog } from './comment-bounty-dialog'
export function BountiedContractBadge() { export function BountiedContractBadge() {
@ -30,13 +30,20 @@ export function BountiedContractSmallBadge(props: {
const modal = ( const modal = (
<CommentBountyDialog open={open} setOpen={setOpen} contract={contract} /> <CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
) )
if (!openCommentBounties)
const bountiesClosed =
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
if (!openCommentBounties) {
if (bountiesClosed) return <></>
return ( return (
<> <>
{modal} {modal}
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} /> <SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
</> </>
) )
}
const tooltip = `${contract.creatorName} may award ${formatMoney( const tooltip = `${contract.creatorName} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT COMMENT_BOUNTY_AMOUNT
@ -49,7 +56,7 @@ export function BountiedContractSmallBadge(props: {
{modal} {modal}
<SmallBadge <SmallBadge
text={`${formatMoney(openCommentBounties)} bounty`} text={`${formatMoney(openCommentBounties)} bounty`}
onClick={() => setOpen(true)} onClick={bountiesClosed ? undefined : () => setOpen(true)}
/> />
</Tooltip> </Tooltip>
) )
@ -57,11 +64,13 @@ export function BountiedContractSmallBadge(props: {
function SmallBadge(props: { text: string; onClick?: () => void }) { function SmallBadge(props: { text: string; onClick?: () => void }) {
const { text, onClick } = props const { text, onClick } = props
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={clsx( 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' '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'} /> <CurrencyDollarIcon className={'h4 w-4'} />

View File

@ -1,7 +1,11 @@
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatLargeNumber, formatPercent } from 'common/util/format' import {
formatLargeNumber,
formatMoney,
formatPercent,
} from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { import {
@ -29,7 +33,7 @@ import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { getColor, ProbBar, QuickBet } from './quick-bet' import { getColor, ProbBar, QuickBet } from './quick-bet'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user' import { useUser, useUserContractMetrics } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric' import { getMappedValue } from 'common/pseudo-numeric'
@ -37,6 +41,7 @@ import { Tooltip } from '../tooltip'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { ProbChange } from './prob-change-table' import { ProbChange } from './prob-change-table'
import { Card } from '../card' import { Card } from '../card'
import { ProfitBadgeMana } from '../profit-badge'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -390,11 +395,18 @@ export function PseudoNumericResolutionOrExpectation(props: {
export function ContractCardProbChange(props: { export function ContractCardProbChange(props: {
contract: CPMMContract contract: CPMMContract
noLinkAvatar?: boolean noLinkAvatar?: boolean
showPosition?: boolean
className?: string className?: string
}) { }) {
const { noLinkAvatar, className } = props const { noLinkAvatar, showPosition, className } = props
const contract = useContractWithPreload(props.contract) as CPMMBinaryContract const contract = useContractWithPreload(props.contract) as CPMMBinaryContract
const user = useUser()
const metrics = useUserContractMetrics(user?.id, contract.id)
const dayMetrics = metrics && metrics.from && metrics.from.day
const outcome =
metrics && metrics.hasShares && metrics.totalShares.YES ? 'YES' : 'NO'
return ( return (
<Card className={clsx(className, 'mb-4')}> <Card className={clsx(className, 'mb-4')}>
<AvatarDetails <AvatarDetails
@ -411,6 +423,27 @@ export function ContractCardProbChange(props: {
</SiteLink> </SiteLink>
<ProbChange className="py-2 pr-4" contract={contract} /> <ProbChange className="py-2 pr-4" contract={contract} />
</Row> </Row>
{showPosition && metrics && (
<Row
className={clsx(
'items-center justify-between gap-4 pl-6 pr-4 pb-2 text-sm'
)}
>
<Row className="gap-1 text-gray-700">
<div className="text-gray-500">Position</div>
{formatMoney(metrics.payout)} {outcome}
</Row>
{dayMetrics && (
<>
<Row className="items-center">
<div className="mr-1 text-gray-500">Daily profit</div>
<ProfitBadgeMana amount={dayMetrics.profit} gray />
</Row>
</>
)}
</Row>
)}
</Card> </Card>
) )
} }

View File

@ -1,20 +1,22 @@
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useState } from 'react' import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { exhibitExts } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { updateContract } from 'web/lib/firebase/contracts' import { updateContract } from 'web/lib/firebase/contracts'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Content } from '../editor' import { Content } from '../editor'
import { TextEditor, useTextEditor } from 'web/components/editor' import {
TextEditor,
editorExtensions,
useTextEditor,
} from 'web/components/editor'
import { Button } from '../button' import { Button } from '../button'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Editor, Content as ContentType } from '@tiptap/react' import { Editor, Content as ContentType } from '@tiptap/react'
import { insertContent } from '../editor/utils' import { insertContent } from '../editor/utils'
import { ExpandingInput } from '../expanding-input'
export function ContractDescription(props: { export function ContractDescription(props: {
contract: Contract contract: Contract
@ -46,6 +48,7 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
// key: `description ${contract.id}`,
max: MAX_DESCRIPTION_LENGTH, max: MAX_DESCRIPTION_LENGTH,
defaultValue: contract.description, defaultValue: contract.description,
disabled: isSubmitting, disabled: isSubmitting,
@ -119,7 +122,10 @@ function EditQuestion(props: {
} }
function joinContent(oldContent: ContentType, newContent: string) { function joinContent(oldContent: ContentType, newContent: string) {
const editor = new Editor({ content: oldContent, extensions: exhibitExts }) const editor = new Editor({
content: oldContent,
extensions: editorExtensions(),
})
editor.commands.focus('end') editor.commands.focus('end')
insertContent(editor, newContent) insertContent(editor, newContent)
return editor.getJSON() return editor.getJSON()
@ -138,8 +144,8 @@ function EditQuestion(props: {
return editing ? ( return editing ? (
<div className="mt-4"> <div className="mt-4">
<Textarea <ExpandingInput
className="textarea textarea-bordered mb-1 h-24 w-full resize-none" className="mb-1 h-24 w-full"
rows={2} rows={2}
value={text} value={text}
onChange={(e) => setText(e.target.value || '')} onChange={(e) => setText(e.target.value || '')}

View File

@ -8,7 +8,6 @@ import clsx from 'clsx'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import Link from 'next/link' import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Contract, updateContract } from 'web/lib/firebase/contracts' import { Contract, updateContract } from 'web/lib/firebase/contracts'
@ -20,7 +19,6 @@ import NewContractBadge from '../new-contract-badge'
import { MiniUserFollowButton } from '../follow-button' import { MiniUserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { useUser, useUserById } from 'web/hooks/use-user' import { useUser, useUserById } from 'web/hooks/use-user'
import { exhibitExts } from 'common/util/parse'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
@ -40,6 +38,8 @@ import {
BountiedContractBadge, BountiedContractBadge,
BountiedContractSmallBadge, BountiedContractSmallBadge,
} from 'web/components/contract/bountied-contract-badge' } from 'web/components/contract/bountied-contract-badge'
import { Input } from '../input'
import { editorExtensions } from '../editor'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -153,8 +153,8 @@ export function MarketSubheader(props: {
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
const { resolvedDate } = contractMetrics(contract) const { resolvedDate } = contractMetrics(contract)
const user = useUser() const user = useUser()
const correctResolutionPercentage = const creator = useUserById(creatorId)
useUserById(creatorId)?.fractionResolvedCorrectly const correctResolutionPercentage = creator?.fractionResolvedCorrectly
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isMobile = useIsMobile() const isMobile = useIsMobile()
return ( return (
@ -177,12 +177,14 @@ export function MarketSubheader(props: {
{disabled ? ( {disabled ? (
creatorName creatorName
) : ( ) : (
<Row className={'gap-2'}>
<UserLink <UserLink
className="my-auto whitespace-nowrap" className="my-auto whitespace-nowrap"
name={creatorName} name={creatorName}
username={creatorUsername} username={creatorUsername}
short={isMobile}
/> />
{/*<BadgeDisplay user={creator} />*/}
</Row>
)} )}
{correctResolutionPercentage != null && {correctResolutionPercentage != null &&
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && ( correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
@ -418,7 +420,7 @@ function EditableCloseDate(props: {
const content = contract.description const content = contract.description
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
const editor = new Editor({ content, extensions: exhibitExts }) const editor = new Editor({ content, extensions: editorExtensions() })
editor.commands.focus('end') editor.commands.focus('end')
insertContent( insertContent(
editor, editor,
@ -445,17 +447,17 @@ function EditableCloseDate(props: {
<Col className="rounded bg-white px-8 pb-8"> <Col className="rounded bg-white px-8 pb-8">
<Subtitle text="Edit market close time" /> <Subtitle text="Edit market close time" />
<Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2"> <Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
<input <Input
type="date" type="date"
className="input input-bordered w-full shrink-0 sm:w-fit" className="w-full shrink-0 sm:w-fit"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)} onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()} min={Date.now()}
value={closeDate} value={closeDate}
/> />
<input <Input
type="time" type="time"
className="input input-bordered w-full shrink-0 sm:w-max" className="w-full shrink-0 sm:w-max"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)} onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00" min="00:00"

View File

@ -7,7 +7,6 @@ import { capitalize } from 'lodash'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney, formatPercent } from 'common/util/format' import { formatMoney, formatPercent } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts' import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Title } from '../title' import { Title } from '../title'
@ -21,6 +20,7 @@ import { DuplicateContractButton } from '../duplicate-contract-button'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { BETTORS, User } from 'common/user' import { BETTORS, User } from 'common/user'
import { Button } from '../button' import { Button } from '../button'
import { AddLiquidityButton } from './add-liquidity-button'
export const contractDetailsButtonClassName = export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
@ -55,6 +55,7 @@ export function ContractInfoDialog(props: {
outcomeType, outcomeType,
id, id,
elasticity, elasticity,
pool,
} = contract } = contract
const typeDisplay = const typeDisplay =
@ -172,10 +173,25 @@ export function ContractInfoDialog(props: {
</tr> </tr>
<tr> <tr>
<td>Liquidity subsidies</td>
<td> <td>
{mechanism === 'cpmm-1' ? 'Liquidity pool' : 'Betting pool'} {mechanism === 'cpmm-1'
? formatMoney(contract.totalLiquidity)
: formatMoney(100)}
</td>
</tr>
<tr>
<td>Pool</td>
<td>
{mechanism === 'cpmm-1' && outcomeType === 'BINARY'
? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO`
: mechanism === 'cpmm-1' && outcomeType === 'PSEUDO_NUMERIC'
? `${Math.round(pool.YES)} HIGHER, ${Math.round(
pool.NO
)} LOWER`
: contractPool(contract)}
</td> </td>
<td>{contractPool(contract)}</td>
</tr> </tr>
{/* Show a path to Firebase if user is an admin, or we're on localhost */} {/* Show a path to Firebase if user is an admin, or we're on localhost */}
@ -226,9 +242,11 @@ export function ContractInfoDialog(props: {
</table> </table>
<Row className="flex-wrap"> <Row className="flex-wrap">
{mechanism === 'cpmm-1' && (
<AddLiquidityButton contract={contract} className="mr-2" />
)}
<DuplicateContractButton contract={contract} /> <DuplicateContractButton contract={contract} />
</Row> </Row>
{!contract.resolution && <LiquidityBountyPanel contract={contract} />}
</Col> </Col>
</Modal> </Modal>
</> </>

View File

@ -2,15 +2,17 @@ import { Bet } from 'common/bet'
import { resolvedPayout } from 'common/calculate' import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { memo } from 'react' import { groupBy, mapValues, sumBy } from 'lodash'
import { useComments } from 'web/hooks/use-comments'
import { FeedBet } from '../feed/feed-bets' import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments' import { FeedComment } from '../feed/feed-comments'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Leaderboard } from '../leaderboard' import { Leaderboard } from '../leaderboard'
import { Title } from '../title' import { Title } from '../title'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
import { scoreCommentorsAndBettors } from 'common/scoring'
import { ContractComment } from 'common/comment'
import { memo } from 'react'
export const ContractLeaderboard = memo(function ContractLeaderboard(props: { export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
contract: Contract contract: Contract
@ -50,47 +52,38 @@ export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
) : null ) : null
}) })
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) { export function ContractTopTrades(props: {
const { contract, bets } = props contract: Contract
// todo: this stuff should be calced in DB at resolve time bets: Bet[]
const comments = useComments(contract.id) comments: ContractComment[]
const betsById = keyBy(bets, 'id') }) {
const { contract, bets, comments } = props
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit const {
// Otherwise, we record the profit at resolution time topBetId,
const profitById: Record<string, number> = {} topBettor,
for (const bet of bets) { profitById,
if (bet.sale) { betsById,
const originalBet = betsById[bet.sale.betId] topCommentId,
const profit = bet.sale.amount - originalBet.amount commentsById,
profitById[bet.id] = profit topCommentBetId,
profitById[originalBet.id] = profit } = scoreCommentorsAndBettors(contract, bets, comments)
} 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 comment with the highest profit
const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
return ( return (
<div className="mt-12 max-w-sm"> <div className="mt-12 max-w-sm">
{topComment && profitById[topComment.id] > 0 && ( {topCommentBetId && profitById[topCommentBetId] > 0 && (
<> <>
<Title text="💬 Proven correct" className="!mt-0" /> <Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment contract={contract} comment={topComment} /> <FeedComment
contract={contract}
comment={commentsById[topCommentId]}
/>
</div> </div>
<Spacer h={16} /> <Spacer h={16} />
</> </>
)} )}
{/* If they're the same, only show the comment; otherwise show both */} {/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && ( {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<> <>
<Title text="💸 Best bet" className="!mt-0" /> <Title text="💸 Best bet" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">

View File

@ -81,6 +81,7 @@ export function ContractsGrid(props: {
<ContractCardProbChange <ContractCardProbChange
key={contract.id} key={contract.id}
contract={contract as CPMMBinaryContract} contract={contract as CPMMBinaryContract}
showPosition
/> />
) : ( ) : (
<ContractCard <ContractCard

View File

@ -18,7 +18,9 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
return ( return (
<Row> <Row>
<FollowMarketButton contract={contract} user={user} /> <FollowMarketButton contract={contract} user={user} />
<LikeMarketButton contract={contract} user={user} /> <LikeMarketButton contract={contract} user={user} />
<Tooltip text="Share" placement="bottom" noTap noFade> <Tooltip text="Share" placement="bottom" noTap noFade>
<Button <Button
size="sm" size="sm"
@ -35,6 +37,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
/> />
</Button> </Button>
</Tooltip> </Tooltip>
<ContractInfoDialog contract={contract} user={user} /> <ContractInfoDialog contract={contract} user={user} />
</Row> </Row>
) )

View File

@ -1,240 +0,0 @@
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Contract, CPMMContract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user'
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
import { AmountInput } from 'web/components/amount-input'
import { Row } from 'web/components/layout/row'
import { useUserLiquidity } from 'web/hooks/use-liquidity'
import { Tabs } from 'web/components/layout/tabs'
import { NoLabel, YesLabel } from 'web/components/outcome-label'
import { Col } from 'web/components/layout/col'
import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from 'web/components/info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin'
export function LiquidityBountyPanel(props: { contract: Contract }) {
const { contract } = props
const isCPMM = contract.mechanism === 'cpmm-1'
const user = useUser()
// eslint-disable-next-line react-hooks/rules-of-hooks
const lpShares = isCPMM && useUserLiquidity(contract, user?.id ?? '')
const [showWithdrawal, setShowWithdrawal] = useState(false)
useEffect(() => {
if (!showWithdrawal && lpShares && lpShares.YES && lpShares.NO)
setShowWithdrawal(true)
}, [showWithdrawal, lpShares])
const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin()
if (!isCreator && !isAdmin && !showWithdrawal) return <></>
return (
<Tabs
tabs={buildArray(
(isCreator || isAdmin) &&
isCPMM && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />,
},
showWithdrawal &&
isCPMM && {
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
contract={contract}
lpShares={lpShares as { YES: number; NO: number }}
/>
),
},
(isCreator || isAdmin) &&
isCPMM && {
title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />,
}
)}
/>
)
}
function AddLiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
const { id: contractId, slug } = contract
const user = useUser()
const [amount, setAmount] = useState<number | undefined>(undefined)
const [error, setError] = useState<string | undefined>(undefined)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const onAmountChange = (amount: number | undefined) => {
setIsSuccess(false)
setAmount(amount)
// Check for errors.
if (amount !== undefined) {
if (user && user.balance < amount) {
setError('Insufficient balance')
} else if (amount < 1) {
setError('Minimum amount: ' + formatMoney(1))
} else {
setError(undefined)
}
}
}
const submit = () => {
if (!amount) return
setIsLoading(true)
setIsSuccess(false)
addLiquidity({ amount, contractId })
.then((_) => {
setIsSuccess(true)
setError(undefined)
setIsLoading(false)
})
.catch((_) => setError('Server error'))
track('add liquidity', { amount, contractId, slug })
}
return (
<>
<div className="mb-4 text-gray-500">
Contribute your M$ to make this market more accurate.{' '}
<InfoTooltip
text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`}
/>
</div>
<Row>
<AmountInput
amount={amount}
onChange={onAmountChange}
label="M$"
error={error}
disabled={isLoading}
inputClassName="w-28"
/>
<button
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
>
Add
</button>
</Row>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
)}
{isLoading && <div>Processing...</div>}
</>
)
}
function ViewLiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
const { pool } = contract
const { YES: yesShares, NO: noShares } = pool
return (
<Col className="mb-4">
<div className="mb-4 text-gray-500">
The liquidity pool for this market currently contains:
</div>
<span>
{yesShares.toFixed(2)} <YesLabel /> shares
</span>
<span>
{noShares.toFixed(2)} <NoLabel /> shares
</span>
</Col>
)
}
function WithdrawLiquidityPanel(props: {
contract: CPMMContract
lpShares: { YES: number; NO: number }
}) {
const { contract, lpShares } = props
const { YES: yesShares, NO: noShares } = lpShares
const [_error, setError] = useState<string | undefined>(undefined)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const submit = () => {
setIsLoading(true)
setIsSuccess(false)
withdrawLiquidity({ contractId: contract.id })
.then((_) => {
setIsSuccess(true)
setError(undefined)
setIsLoading(false)
})
.catch((_) => setError('Server error'))
track('withdraw liquidity')
}
if (isSuccess)
return (
<div className="text-gray-500">
Success! Your liquidity was withdrawn.
</div>
)
if (!yesShares && !noShares)
return (
<div className="text-gray-500">
You do not have any liquidity positions to withdraw.
</div>
)
return (
<Col>
<div className="mb-4 text-gray-500">
Your liquidity position is currently:
</div>
<span>
{yesShares.toFixed(2)} <YesLabel /> shares
</span>
<span>
{noShares.toFixed(2)} <NoLabel /> shares
</span>
<Row className="mt-4 mb-2">
<button
className={clsx(
'btn btn-outline btn-sm ml-2',
isLoading && 'btn-disabled'
)}
onClick={submit}
disabled={isLoading}
>
Withdraw
</button>
</Row>
{isLoading && <div>Processing...</div>}
</Col>
)
}

View File

@ -0,0 +1,92 @@
import { useState } from 'react'
import clsx from 'clsx'
import { Button } from 'web/components/button'
import { formatMoney, shortFormatNumber } from 'common/util/format'
import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip'
import { CPMMContract } from 'common/contract'
import { User } from 'common/user'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { LiquidityModal } from './liquidity-modal'
export function LiquidityButton(props: {
contract: CPMMContract
user: User | undefined | null
}) {
const { contract, user } = props
const { totalLiquidity: total } = contract
const lp = useLiquidity(contract.id)
const userActive = lp?.find((l) => l.userId === user?.id) !== undefined
const [open, setOpen] = useState(false)
const disabled =
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
return (
<Tooltip
text={`${formatMoney(total)} in liquidity subsidies`}
placement="bottom"
noTap
noFade
>
<LiquidityIconButton
total={total}
userActive={userActive}
onClick={() => setOpen(true)}
disabled={disabled}
/>
<LiquidityModal contract={contract} isOpen={open} setOpen={setOpen} />
</Tooltip>
)
}
function LiquidityIconButton(props: {
total: number
onClick: () => void
userActive: boolean
isCompact?: boolean
disabled?: boolean
}) {
const { total, userActive, isCompact, onClick, disabled } = props
return (
<Button
size={'sm'}
className={clsx(
'max-w-xs self-center pt-1',
isCompact && 'px-0 py-0',
disabled && 'hover:bg-inherit'
)}
color={'gray-white'}
onClick={onClick}
disabled={disabled}
>
<Col className={'relative items-center sm:flex-row'}>
<span
className={clsx(
'text-xl sm:text-2xl',
total > 0 ? 'mr-2' : '',
userActive ? '' : 'grayscale'
)}
>
💧
</span>
{total > 0 && (
<div
className={clsx(
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
total > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{shortFormatNumber(total)}
</div>
)}
</Col>
</Button>
)
}

View File

@ -0,0 +1,108 @@
import { CPMMContract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { addSubsidy } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics'
import { AmountInput } from '../amount-input'
import { Button } from '../button'
import { InfoTooltip } from '../info-tooltip'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { Title } from '../title'
export function LiquidityModal(props: {
contract: CPMMContract
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { contract, isOpen, setOpen } = props
const { totalLiquidity } = contract
return (
<Modal open={isOpen} setOpen={setOpen} size="sm">
<Col className="gap-2.5 rounded bg-white p-4 pb-8 sm:gap-4">
<Title className="!mt-0 !mb-2" text="💧 Add liquidity" />
<div>Total liquidity subsidies: {formatMoney(totalLiquidity)}</div>
<AddLiquidityPanel contract={contract as CPMMContract} />
</Col>
</Modal>
)
}
function AddLiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
const { id: contractId, slug } = contract
const user = useUser()
const [amount, setAmount] = useState<number | undefined>(undefined)
const [error, setError] = useState<string | undefined>(undefined)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const onAmountChange = (amount: number | undefined) => {
setIsSuccess(false)
setAmount(amount)
// Check for errors.
if (amount !== undefined) {
if (user && user.balance < amount) {
setError('Insufficient balance')
} else if (amount < 1) {
setError('Minimum amount: ' + formatMoney(1))
} else {
setError(undefined)
}
}
}
const submit = () => {
if (!amount) return
setIsLoading(true)
setIsSuccess(false)
addSubsidy({ amount, contractId })
.then((_) => {
setIsSuccess(true)
setError(undefined)
setIsLoading(false)
})
.catch((_) => setError('Server error'))
track('add liquidity', { amount, contractId, slug })
}
return (
<>
<div className="mb-4 text-gray-500">
Contribute your M$ to make this market more accurate by subsidizing
trading.{' '}
<InfoTooltip text="Liquidity is how much money traders can make if they're right. The more traders can earn, the greater the incentive to find the correct probability." />
</div>
<Row>
<AmountInput
amount={amount}
onChange={onAmountChange}
label="M$"
error={error}
disabled={isLoading}
inputClassName="w-28 mr-4"
/>
<Button size="md" color="blue" onClick={submit} disabled={isLoading}>
Add
</Button>
</Row>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
)}
{isLoading && <div>Processing...</div>}
</>
)
}

View File

@ -1,11 +1,70 @@
import clsx from 'clsx' import clsx from 'clsx'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { sortBy } from 'lodash' import { sortBy } from 'lodash'
import { filterDefined } from 'common/util/array'
import { ContractMetrics } from 'common/calculate-metrics'
import { CPMMBinaryContract, CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { ContractCardProbChange } from './contract-card' import { ContractCardProbChange } from './contract-card'
export function ProfitChangeTable(props: {
contracts: CPMMBinaryContract[]
metrics: ContractMetrics[]
}) {
const { contracts, metrics } = props
const contractProfit = metrics.map(
(m) => [m.contractId, m.from?.day.profit ?? 0] as const
)
const positiveProfit = sortBy(
contractProfit.filter(([, profit]) => profit > 0),
([, profit]) => profit
).reverse()
const positive = filterDefined(
positiveProfit.map(([contractId]) =>
contracts.find((c) => c.id === contractId)
)
)
const negativeProfit = sortBy(
contractProfit.filter(([, profit]) => profit < 0),
([, profit]) => profit
)
const negative = filterDefined(
negativeProfit.map(([contractId]) =>
contracts.find((c) => c.id === contractId)
)
)
if (positive.length === 0 && negative.length === 0)
return <div className="px-4 text-gray-500">None</div>
return (
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
<Col className="flex-1">
{positive.map((contract) => (
<ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))}
</Col>
<Col className="flex-1">
{negative.map((contract) => (
<ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))}
</Col>
</Col>
)
}
export function ProbChangeTable(props: { export function ProbChangeTable(props: {
changes: CPMMContract[] | undefined changes: CPMMContract[] | undefined
full?: boolean full?: boolean
@ -39,12 +98,20 @@ export function ProbChangeTable(props: {
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row"> <Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
<Col className="flex-1"> <Col className="flex-1">
{filteredPositiveChanges.map((contract) => ( {filteredPositiveChanges.map((contract) => (
<ContractCardProbChange key={contract.id} contract={contract} /> <ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))} ))}
</Col> </Col>
<Col className="flex-1"> <Col className="flex-1">
{filteredNegativeChanges.map((contract) => ( {filteredNegativeChanges.map((contract) => (
<ContractCardProbChange key={contract.id} contract={contract} /> <ContractCardProbChange
key={contract.id}
contract={contract}
showPosition
/>
))} ))}
</Col> </Col>
</Col> </Col>
@ -61,7 +128,7 @@ export function ProbChange(props: {
probChanges: { day: change }, probChanges: { day: change },
} = contract } = contract
const color = change >= 0 ? 'text-green-500' : 'text-red-500' const color = change >= 0 ? 'text-teal-500' : 'text-red-400'
return ( return (
<Col className={clsx('flex flex-col items-end', className)}> <Col className={clsx('flex flex-col items-end', className)}>

View File

@ -166,14 +166,14 @@ export function QuickBet(props: {
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
upHover ? 'text-green-500' : 'text-gray-400' upHover ? 'text-teal-500' : 'text-gray-400'
)} )}
/> />
) : ( ) : (
<TriangleFillIcon <TriangleFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
upHover ? 'text-green-500' : 'text-gray-200' upHover ? 'text-teal-500' : 'text-gray-200'
)} )}
/> />
)} )}
@ -201,14 +201,14 @@ export function QuickBet(props: {
<TriangleDownFillIcon <TriangleDownFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
downHover ? 'text-red-500' : 'text-gray-400' downHover ? 'text-red-400' : 'text-gray-400'
)} )}
/> />
) : ( ) : (
<TriangleDownFillIcon <TriangleDownFillIcon
className={clsx( className={clsx(
'mx-auto h-5 w-5', 'mx-auto h-5 w-5',
downHover ? 'text-red-500' : 'text-gray-200' downHover ? 'text-red-400' : 'text-gray-200'
)} )}
/> />
)} )}

View File

@ -1,7 +1,8 @@
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { formatMoney } from 'common/util/format'
import clsx from 'clsx' import clsx from 'clsx'
import { HeartIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { formatMoney, shortFormatNumber } from 'common/util/format'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Tooltip } from '../tooltip' import { Tooltip } from '../tooltip'
@ -16,9 +17,15 @@ export function TipButton(props: {
const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } = const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } =
props props
const tipDisplay = shortFormatNumber(Math.ceil(totalTipped / 10))
return ( return (
<Tooltip <Tooltip
text={disabled ? 'Tips' : `Tip ${formatMoney(tipAmount)}`} text={
disabled
? `Tips (${formatMoney(totalTipped)})`
: `Tip ${formatMoney(tipAmount)}`
}
placement="bottom" placement="bottom"
noTap noTap
noFade noFade
@ -39,19 +46,19 @@ export function TipButton(props: {
className={clsx( className={clsx(
'h-5 w-5 sm:h-6 sm:w-6', 'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '', totalTipped > 0 ? 'mr-2' : '',
userTipped ? 'fill-green-700 text-green-700' : '' userTipped ? 'fill-teal-500 text-teal-500' : ''
)} )}
/> />
{totalTipped > 0 && ( {totalTipped > 0 && (
<div <div
className={clsx( className={clsx(
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1', 'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99 tipDisplay.length > 2
? 'text-[0.4rem] sm:text-[0.5rem]' ? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]' : 'sm:text-2xs text-[0.5rem]'
)} )}
> >
{totalTipped} {tipDisplay}
</div> </div>
)} )}
</Col> </Col>

View File

@ -1,15 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor' import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api' import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router' import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post' import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts' import { postPath } from 'web/lib/firebase/posts'
import { Group } from 'common/group' import { Group } from 'common/group'
import { ExpandingInput } from './expanding-input'
import { Button } from './button'
export function CreatePost(props: { group?: Group }) { export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@ -21,6 +21,7 @@ export function CreatePost(props: { group?: Group }) {
const { group } = props const { group } = props
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
key: `post ${group?.id || ''}`,
disabled: isSubmitting, disabled: isSubmitting,
}) })
@ -45,6 +46,7 @@ export function CreatePost(props: { group?: Group }) {
return e return e
}) })
if (result.post) { if (result.post) {
editor.commands.clearContent(true)
await Router.push(postPath(result.post.slug)) await Router.push(postPath(result.post.slug))
} }
} }
@ -60,9 +62,8 @@ export function CreatePost(props: { group?: Group }) {
Title<span className={'text-red-700'}> *</span> Title<span className={'text-red-700'}> *</span>
</span> </span>
</label> </label>
<Textarea <ExpandingInput
placeholder="e.g. Elon Mania Post" placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus autoFocus
maxLength={MAX_POST_TITLE_LENGTH} maxLength={MAX_POST_TITLE_LENGTH}
value={title} value={title}
@ -74,9 +75,8 @@ export function CreatePost(props: { group?: Group }) {
Subtitle<span className={'text-red-700'}> *</span> Subtitle<span className={'text-red-700'}> *</span>
</span> </span>
</label> </label>
<Textarea <ExpandingInput
placeholder="e.g. How Elon Musk is getting everyone's attention" placeholder="e.g. How Elon Musk is getting everyone's attention"
className="input input-bordered resize-none"
autoFocus autoFocus
maxLength={MAX_POST_TITLE_LENGTH} maxLength={MAX_POST_TITLE_LENGTH}
value={subtitle} value={subtitle}
@ -91,12 +91,10 @@ export function CreatePost(props: { group?: Group }) {
<TextEditor editor={editor} upload={upload} /> <TextEditor editor={editor} upload={upload} />
<Spacer h={6} /> <Spacer h={6} />
<button <Button
type="submit" type="submit"
className={clsx( loading={isSubmitting}
'btn btn-primary normal-case', size="xl"
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading} disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => { onClick={async () => {
setIsSubmitting(true) setIsSubmitting(true)
@ -105,7 +103,7 @@ export function CreatePost(props: { group?: Group }) {
}} }}
> >
{isSubmitting ? 'Creating...' : 'Create a post'} {isSubmitting ? 'Creating...' : 'Create a post'}
</button> </Button>
{error !== '' && <div className="text-red-700">{error}</div>} {error !== '' && <div className="text-red-700">{error}</div>}
</div> </div>
</form> </form>

View File

@ -8,20 +8,21 @@ import {
Content, Content,
Editor, Editor,
mergeAttributes, mergeAttributes,
Extensions,
} from '@tiptap/react' } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { Image } from '@tiptap/extension-image' import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link' import { Link } from '@tiptap/extension-link'
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Linkify } from './linkify' import { Linkify } from './linkify'
import { uploadImage } from 'web/lib/firebase/storage' import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query' import { useMutation } from 'react-query'
import { linkClass } from './site-link' import { linkClass } from './site-link'
import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention' import { DisplayMention } from './editor/mention'
import { contractMentionSuggestion } from './editor/contract-mention-suggestion'
import { DisplayContractMention } from './editor/contract-mention' import { DisplayContractMention } from './editor/contract-mention'
import GridComponent from './editor/tiptap-grid-cards'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
import TiptapTweet from './editor/tiptap-tweet' import TiptapTweet from './editor/tiptap-tweet'
import { EmbedModal } from './editor/embed-modal' import { EmbedModal } from './editor/embed-modal'
@ -42,6 +43,12 @@ import LinkIcon from 'web/lib/icons/link-icon'
import { getUrl } from 'common/util/parse' import { getUrl } from 'common/util/parse'
import { TiptapSpoiler } from 'common/util/tiptap-spoiler' import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
import { ImageModal } from './editor/image-modal' import { ImageModal } from './editor/image-modal'
import {
storageStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
import { debounce } from 'lodash'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
@ -64,6 +71,23 @@ const DisplayLink = Link.extend({
}, },
}) })
export const editorExtensions = (simple = false): Extensions => [
StarterKit.configure({
heading: simple ? false : { levels: [1, 2, 3] },
horizontalRule: simple ? false : {},
}),
simple ? DisplayImage : Image,
DisplayLink,
DisplayMention,
DisplayContractMention,
GridComponent,
Iframe,
TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
}),
]
const proseClass = clsx( const proseClass = clsx(
'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed', 'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
'font-light prose-a:font-light prose-blockquote:font-light' 'font-light prose-a:font-light prose-blockquote:font-light'
@ -75,45 +99,44 @@ export function useTextEditor(props: {
defaultValue?: Content defaultValue?: Content
disabled?: boolean disabled?: boolean
simple?: boolean simple?: boolean
key?: string // unique key for autosave. If set, plz call `clearContent(true)` on submit to clear autosave
}) { }) {
const { placeholder, max, defaultValue = '', disabled, simple } = props const { placeholder, max, defaultValue, disabled, simple, key } = props
const [content, saveContent] = usePersistentState<JSONContent | undefined>(
undefined,
{
key: `text ${key}`,
store: storageStore(safeLocalStorage()),
}
)
// eslint-disable-next-line react-hooks/exhaustive-deps
const save = useCallback(debounce(saveContent, 500), [])
const editorClass = clsx( const editorClass = clsx(
proseClass, proseClass,
!simple && 'min-h-[6em]', !simple && 'min-h-[6em]',
'outline-none pt-2 px-4', 'outline-none pt-2 px-4',
'prose-img:select-auto', 'prose-img:select-auto',
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, embeds
) )
const editor = useEditor({ const editor = useEditor({
editorProps: { attributes: { class: editorClass } }, editorProps: {
attributes: { class: editorClass, spellcheck: simple ? 'true' : 'false' },
},
onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined,
extensions: [ extensions: [
StarterKit.configure({ ...editorExtensions(simple),
heading: simple ? false : { levels: [1, 2, 3] },
horizontalRule: simple ? false : {},
}),
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
emptyEditorClass: emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text', 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
}), }),
CharacterCount.configure({ limit: max }), CharacterCount.configure({ limit: max }),
simple ? DisplayImage : Image,
DisplayLink,
DisplayMention.configure({
suggestion: mentionSuggestion,
}),
DisplayContractMention.configure({
suggestion: contractMentionSuggestion,
}),
Iframe,
TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
}),
], ],
content: defaultValue, content: defaultValue ?? (key && content ? content : ''),
}) })
const upload = useUploadMutation(editor) const upload = useUploadMutation(editor)
@ -342,10 +365,8 @@ export function RichContent(props: {
smallImage ? DisplayImage : Image, smallImage ? DisplayImage : Image,
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
DisplayMention, DisplayMention,
DisplayContractMention.configure({ DisplayContractMention,
// Needed to set a different PluginKey for Prosemirror GridComponent,
suggestion: contractMentionSuggestion,
}),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({ TiptapSpoiler.configure({

View File

@ -6,16 +6,29 @@ import {
} from '@tiptap/react' } from '@tiptap/react'
import clsx from 'clsx' import clsx from 'clsx'
import { useContract } from 'web/hooks/use-contract' import { useContract } from 'web/hooks/use-contract'
import { ContractMention } from '../contract/contract-mention' import { ContractMention } from 'web/components/contract/contract-mention'
import Link from 'next/link'
import { contractMentionSuggestion } from './contract-mention-suggestion'
const name = 'contract-mention-component' const name = 'contract-mention-component'
const ContractMentionComponent = (props: any) => { const ContractMentionComponent = (props: any) => {
const contract = useContract(props.node.attrs.id) const { label, id } = props.node.attrs
const contract = useContract(id)
return ( return (
<NodeViewWrapper className={clsx(name, 'not-prose inline')}> <NodeViewWrapper className={clsx(name, 'not-prose inline')}>
{contract && <ContractMention contract={contract} />} {contract ? (
<ContractMention contract={contract} />
) : label ? (
<Link href={label}>
<a className="rounded-sm !text-indigo-700 hover:bg-indigo-50">
{label}
</a>
</Link>
) : (
'[loading...]'
)}
</NodeViewWrapper> </NodeViewWrapper>
) )
} }
@ -29,8 +42,5 @@ export const DisplayContractMention = Mention.extend({
name: 'contract-mention', name: 'contract-mention',
parseHTML: () => [{ tag: name }], parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () => addNodeView: () => ReactNodeViewRenderer(ContractMentionComponent),
ReactNodeViewRenderer(ContractMentionComponent, { }).configure({ suggestion: contractMentionSuggestion })
// On desktop, render cards below half-width so you can stack two
}),
})

View File

@ -1,7 +1,7 @@
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { SelectMarketsModal } from '../contract-select-modal' import { SelectMarketsModal } from '../contract-select-modal'
import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { embedContractCode } from '../share-embed-button'
import { insertContent } from './utils' import { insertContent } from './utils'
export function MarketModal(props: { export function MarketModal(props: {
@ -15,7 +15,10 @@ export function MarketModal(props: {
if (contracts.length == 1) { if (contracts.length == 1) {
insertContent(editor, embedContractCode(contracts[0])) insertContent(editor, embedContractCode(contracts[0]))
} else if (contracts.length > 1) { } else if (contracts.length > 1) {
insertContent(editor, embedContractGridCode(contracts)) insertContent(
editor,
`<grid-cards-component contractIds="${contracts.map((c) => c.id)}" />`
)
} }
} }

View File

@ -6,6 +6,7 @@ import {
} from '@tiptap/react' } from '@tiptap/react'
import clsx from 'clsx' import clsx from 'clsx'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
import { mentionSuggestion } from './mention-suggestion'
const name = 'mention-component' const name = 'mention-component'
@ -27,4 +28,4 @@ export const DisplayMention = Mention.extend({
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () => addNodeView: () =>
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }), ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
}) }).configure({ suggestion: mentionSuggestion })

View File

@ -0,0 +1,55 @@
import { mergeAttributes, Node } from '@tiptap/core'
import React from 'react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { ContractsGrid } from '../contract/contracts-grid'
import { useContractsFromIds } from 'web/hooks/use-contract'
import { LoadingIndicator } from '../loading-indicator'
export default Node.create({
name: 'gridCardsComponent',
group: 'block',
atom: true,
addAttributes() {
return {
contractIds: [],
}
},
parseHTML() {
return [
{
tag: 'grid-cards-component',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['grid-cards-component', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return ReactNodeViewRenderer(GridComponent)
},
})
export function GridComponent(props: any) {
const contractIds = props.node.attrs.contractIds
const contracts = useContractsFromIds(contractIds.split(','))
return (
<NodeViewWrapper className="grid-cards-component">
{contracts ? (
<ContractsGrid
contracts={contracts}
breakpointColumns={{ default: 2, 650: 1 }}
/>
) : (
<LoadingIndicator />
)}
</NodeViewWrapper>
)
}

View File

@ -0,0 +1,16 @@
import clsx from 'clsx'
import Textarea from 'react-expanding-textarea'
/** Expanding `<textarea>` with same style as input.tsx */
export const ExpandingInput = (props: Parameters<typeof Textarea>[0]) => {
const { className, ...rest } = props
return (
<Textarea
className={clsx(
'textarea textarea-bordered resize-none text-[16px] md:text-[14px]',
className
)}
{...rest}
/>
)
}

View File

@ -268,6 +268,7 @@ export function ContractCommentInput(props: {
parentAnswerOutcome={parentAnswerOutcome} parentAnswerOutcome={parentAnswerOutcome}
parentCommentId={parentCommentId} parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment} onSubmitComment={onSubmitComment}
pageId={contract.id}
className={className} className={className}
/> />
) )

View File

@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { Input } from './input'
export function FilterSelectUsers(props: { export function FilterSelectUsers(props: {
setSelectedUsers: (users: User[]) => void setSelectedUsers: (users: User[]) => void
@ -50,13 +51,13 @@ export function FilterSelectUsers(props: {
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> <UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div> </div>
<input <Input
type="text" type="text"
name="user name" name="user name"
id="user name" id="user name"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="input input-bordered block w-full pl-10 focus:border-gray-300 " className="block w-full pl-10"
placeholder="Austin Chen" placeholder="Austin Chen"
/> />
</div> </div>

View File

@ -5,57 +5,52 @@ import { useFollows } from 'web/hooks/use-follows'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { follow, unfollow } from 'web/lib/firebase/users' import { follow, unfollow } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { Button } from './button'
export function FollowButton(props: { export function FollowButton(props: {
isFollowing: boolean | undefined isFollowing: boolean | undefined
onFollow: () => void onFollow: () => void
onUnfollow: () => void onUnfollow: () => void
small?: boolean
className?: string className?: string
}) { }) {
const { isFollowing, onFollow, onUnfollow, small, className } = props const { isFollowing, onFollow, onUnfollow, className } = props
const user = useUser() const user = useUser()
const smallStyle =
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
if (!user || isFollowing === undefined) if (!user || isFollowing === undefined)
return ( return (
<button <Button size="sm" color="gray" className={clsx(className, 'invisible')}>
className={clsx('btn btn-sm invisible', small && smallStyle, className)}
>
Follow Follow
</button> </Button>
) )
if (isFollowing) { if (isFollowing) {
return ( return (
<button <Button
className={clsx( size="sm"
'btn btn-outline btn-sm', color="gray-outline"
small && smallStyle, className={clsx('my-auto', className)}
className
)}
onClick={withTracking(onUnfollow, 'unfollow')} onClick={withTracking(onUnfollow, 'unfollow')}
> >
Following Following
</button> </Button>
) )
} }
return ( return (
<button <Button
className={clsx('btn btn-sm', small && smallStyle, className)} size="sm"
color="indigo"
className={clsx(className, 'my-auto')}
onClick={withTracking(onFollow, 'follow')} onClick={withTracking(onFollow, 'follow')}
> >
Follow Follow
</button> </Button>
) )
} }
export function UserFollowButton(props: { userId: string; small?: boolean }) { export function UserFollowButton(props: { userId: string }) {
const { userId, small } = props const { userId } = props
const user = useUser() const user = useUser()
const following = useFollows(user?.id) const following = useFollows(user?.id)
const isFollowing = following?.includes(userId) const isFollowing = following?.includes(userId)
@ -67,7 +62,6 @@ export function UserFollowButton(props: { userId: string; small?: boolean }) {
isFollowing={isFollowing} isFollowing={isFollowing}
onFollow={() => follow(user.id, userId)} onFollow={() => follow(user.id, userId)}
onUnfollow={() => unfollow(user.id, userId)} onUnfollow={() => unfollow(user.id, userId)}
small={small}
/> />
) )
} }

View File

@ -8,6 +8,7 @@ import { Title } from '../title'
import { User } from 'common/user' import { User } from 'common/user'
import { MAX_GROUP_NAME_LENGTH } from 'common/group' import { MAX_GROUP_NAME_LENGTH } from 'common/group'
import { createGroup } from 'web/lib/firebase/api' import { createGroup } from 'web/lib/firebase/api'
import { Input } from '../input'
export function CreateGroupButton(props: { export function CreateGroupButton(props: {
user: User user: User
@ -104,9 +105,8 @@ export function CreateGroupButton(props: {
<div className="form-control w-full"> <div className="form-control w-full">
<label className="mb-2 ml-1 mt-0">Group name</label> <label className="mb-2 ml-1 mt-0">Group name</label>
<input <Input
placeholder={'Your group name'} placeholder={'Your group name'}
className="input input-bordered resize-none"
disabled={isSubmitting} disabled={isSubmitting}
value={name} value={name}
maxLength={MAX_GROUP_NAME_LENGTH} maxLength={MAX_GROUP_NAME_LENGTH}

View File

@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal'
import { FilterSelectUsers } from 'web/components/filter-select-users' import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user' import { User } from 'common/user'
import { useMemberIds } from 'web/hooks/use-group' import { useMemberIds } from 'web/hooks/use-group'
import { Input } from '../input'
export function EditGroupButton(props: { group: Group; className?: string }) { export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props const { group, className } = props
@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<span className="mb-1">Group name</span> <span className="mb-1">Group name</span>
</label> </label>
<input <Input
placeholder="Your group name" placeholder="Your group name"
className="input input-bordered resize-none"
disabled={isSubmitting} disabled={isSubmitting}
value={name} value={name}
onChange={(e) => setName(e.target.value || '')} onChange={(e) => setName(e.target.value || '')}

View File

@ -231,7 +231,7 @@ export function PinnedItems(props: {
return pinned.length > 0 || isEditable ? ( return pinned.length > 0 || isEditable ? (
<div> <div>
<Row className="mb-3 items-center justify-between"> <Row className="mb-3 items-center justify-between">
<SectionHeader label={'Pinned'} /> <SectionHeader label={'Featured'} href={`#`} />
{isEditable && ( {isEditable && (
<Button <Button
color="gray" color="gray"

22
web/components/input.tsx Normal file
View File

@ -0,0 +1,22 @@
import clsx from 'clsx'
import React from 'react'
/** Text input. Wraps html `<input>` */
export const Input = (props: JSX.IntrinsicElements['input']) => {
const { className, ...rest } = props
return (
<input
className={clsx('input input-bordered text-base md:text-sm', className)}
{...rest}
/>
)
}
/*
TODO: replace daisyui style with our own. For reference:
james: text-lg placeholder:text-gray-400
inga: placeholder:text-greyscale-4 border-greyscale-2 rounded-md
austin: border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm
*/

View File

@ -140,12 +140,9 @@ function LimitBet(props: {
{isCancelling ? ( {isCancelling ? (
<LoadingIndicator /> <LoadingIndicator />
) : ( ) : (
<button <Button size="2xs" color="gray-outline" onClick={onCancel}>
className="btn btn-xs btn-outline my-auto normal-case"
onClick={onCancel}
>
Cancel Cancel
</button> </Button>
)} )}
</td> </td>
)} )}

View File

@ -7,12 +7,13 @@ import { User } from 'common/user'
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
import { createManalink } from 'web/lib/firebase/manalinks' import { createManalink } from 'web/lib/firebase/manalinks'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import Textarea from 'react-expanding-textarea'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Button } from '../button' import { Button } from '../button'
import { getManalinkUrl } from 'web/pages/links' import { getManalinkUrl } from 'web/pages/links'
import { DuplicateIcon } from '@heroicons/react/outline' import { DuplicateIcon } from '@heroicons/react/outline'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { Input } from '../input'
import { ExpandingInput } from '../expanding-input'
export function CreateLinksButton(props: { export function CreateLinksButton(props: {
user: User user: User
@ -120,8 +121,8 @@ function CreateManalinkForm(props: {
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> <span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
M$ M$
</span> </span>
<input <Input
className="input input-bordered w-full pl-10" className="w-full pl-10"
type="number" type="number"
min="1" min="1"
value={newManalink.amount} value={newManalink.amount}
@ -136,8 +137,7 @@ function CreateManalinkForm(props: {
<div className="flex flex-col gap-2 md:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
<div className="form-control w-full md:w-1/2"> <div className="form-control w-full md:w-1/2">
<label className="label">Uses</label> <label className="label">Uses</label>
<input <Input
className="input input-bordered"
type="number" type="number"
min="1" min="1"
value={newManalink.maxUses ?? ''} value={newManalink.maxUses ?? ''}
@ -146,7 +146,7 @@ function CreateManalinkForm(props: {
return { ...m, maxUses: parseInt(e.target.value) } return { ...m, maxUses: parseInt(e.target.value) }
}) })
} }
></input> />
</div> </div>
<div className="form-control w-full md:w-1/2"> <div className="form-control w-full md:w-1/2">
<label className="label">Expires in</label> <label className="label">Expires in</label>
@ -165,10 +165,9 @@ function CreateManalinkForm(props: {
</div> </div>
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label">Message</label> <label className="label">Message</label>
<Textarea <ExpandingInput
placeholder={defaultMessage} placeholder={defaultMessage}
maxLength={200} maxLength={200}
className="input input-bordered resize-none"
autoFocus autoFocus
value={newManalink.message} value={newManalink.message}
rows="3" rows="3"

View File

@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) {
return buildArray( return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' }, { name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Refer a friend', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' }, { name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -215,7 +215,7 @@ function getMoreMobileNav() {
return buildArray<MenuItem>( return buildArray<MenuItem>(
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Refer a friend', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' }, { name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },

View File

@ -139,6 +139,7 @@ export function NotificationSettings(props: {
'loan_income', 'loan_income',
'limit_order_fills', 'limit_order_fills',
'tips_on_your_comments', 'tips_on_your_comments',
'badges_awarded',
], ],
} }
const userInteractions: SectionData = { const userInteractions: SectionData = {

View File

@ -4,6 +4,7 @@ import { ReactNode } from 'react'
import React from 'react' import React from 'react'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { Input } from './input'
export function NumberInput(props: { export function NumberInput(props: {
numberString: string numberString: string
@ -32,9 +33,9 @@ export function NumberInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group"> <label className="input-group">
<input <Input
className={clsx( className={clsx(
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', 'max-w-[200px] !text-lg',
error && 'input-error', error && 'input-error',
inputClassName inputClassName
)} )}

View File

@ -70,7 +70,7 @@ export function PinnedSelectModal(props: {
return ( return (
<Modal open={open} setOpen={setOpen} className={' sm:p-0'} size={'lg'}> <Modal open={open} setOpen={setOpen} className={' sm:p-0'} size={'lg'}>
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white"> <Col className=" h-[85vh] w-full gap-4 overflow-scroll rounded-md bg-white">
<div className=" p-8 pb-0"> <div className=" p-8 pb-0">
<Row> <Row>
<div className={'text-xl text-indigo-700'}>{title}</div> <div className={'text-xl text-indigo-700'}>{title}</div>

View File

@ -3,12 +3,12 @@ import { DocumentIcon } from '@heroicons/react/solid'
import clsx from 'clsx' import clsx from 'clsx'
import { Post } from 'common/post' import { Post } from 'common/post'
import Link from 'next/link' import Link from 'next/link'
import { useUserById } from 'web/hooks/use-user'
import { postPath } from 'web/lib/firebase/posts' import { postPath } from 'web/lib/firebase/posts'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Card } from './card' import { Card } from './card'
import { CardHighlightOptions } from './contract/contracts-grid' import { CardHighlightOptions } from './contract/contracts-grid'
import { Row } from './layout/row'
import { UserLink } from './user-link' import { UserLink } from './user-link'
export function PostCard(props: { export function PostCard(props: {
@ -17,38 +17,31 @@ export function PostCard(props: {
highlightOptions?: CardHighlightOptions highlightOptions?: CardHighlightOptions
}) { }) {
const { post, onPostClick, highlightOptions } = props const { post, onPostClick, highlightOptions } = props
const creatorId = post.creatorId
const user = useUserById(creatorId)
const { itemIds: itemIds, highlightClassName } = highlightOptions || {} const { itemIds: itemIds, highlightClassName } = highlightOptions || {}
if (!user) return <> </>
return ( return (
<div className="relative py-1">
<Card <Card
className={clsx( className={clsx(
'relative flex gap-3 py-2 px-3', 'group relative flex gap-3 py-2 px-3',
itemIds?.includes(post.id) && highlightClassName itemIds?.includes(post.id) && highlightClassName
)} )}
> >
<div className="flex-shrink-0"> <Row className="flex grow justify-between">
<Avatar className="h-12 w-12" username={user?.username} />
</div>
<div className=""> <div className="">
<div className="text-sm text-gray-500"> <Row className="items-center text-sm ">
<Avatar className="mx-1 h-7 w-7" username={post.creatorUsername} />
<UserLink <UserLink
className="text-neutral" className=" text-gray-400"
name={user?.name} name={post.creatorName}
username={user?.username} username={post.creatorUsername}
/> />
<span className="mx-1"></span> <span className="mx-1 text-gray-400"></span>
<span className="text-gray-500">{fromNow(post.createdTime)}</span> <span className="text-gray-400">{fromNow(post.createdTime)}</span>
</div> </Row>
<div className=" break-words text-lg font-medium text-gray-900"> <div className=" break-words text-lg font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2">
{post.title} {post.title}
</div> </div>
<div className="font-small text-md break-words text-gray-500"> <div className="font-small text-md break-words text-indigo-400">
{post.subtitle} {post.subtitle}
</div> </div>
</div> </div>
@ -58,7 +51,7 @@ export function PostCard(props: {
Post Post
</span> </span>
</div> </div>
</Card> </Row>
{onPostClick ? ( {onPostClick ? (
<a <a
className="absolute top-0 left-0 right-0 bottom-0" className="absolute top-0 left-0 right-0 bottom-0"
@ -89,7 +82,7 @@ export function PostCard(props: {
/> />
</Link> </Link>
)} )}
</div> </Card>
) )
} }

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { getPseudoProbability } from 'common/pseudo-numeric' import { getPseudoProbability } from 'common/pseudo-numeric'
import { BucketInput } from './bucket-input' import { BucketInput } from './bucket-input'
import { Input } from './input'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -30,11 +31,8 @@ export function ProbabilityInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group"> <label className="input-group">
<input <Input
className={clsx( className={clsx('max-w-[200px] !text-lg', inputClassName)}
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
inputClassName
)}
type="number" type="number"
max={99} max={99}
min={1} min={1}

View File

@ -1,3 +1,4 @@
import { Input } from './input'
import { Row } from './layout/row' import { Row } from './layout/row'
export function ProbabilitySelector(props: { export function ProbabilitySelector(props: {
@ -10,10 +11,10 @@ export function ProbabilitySelector(props: {
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<label className="input-group input-group-lg text-lg"> <label className="input-group input-group-lg text-lg">
<input <Input
type="number" type="number"
value={probabilityInt} value={probabilityInt}
className="input input-bordered input-md w-28 text-lg" className="input-md w-28 !text-lg"
disabled={isSubmitting} disabled={isSubmitting}
min={1} min={1}
max={99} max={99}

View File

@ -0,0 +1,226 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { PAST_BETS, User } from 'common/user'
import clsx from 'clsx'
import {
Badge,
calculateBadgeRarity,
MarketCreatorBadge,
ProvenCorrectBadge,
rarities,
StreakerBadge,
} from 'common/badge'
import { groupBy } from 'lodash'
import { Row } from 'web/components/layout/row'
import { SiteLink } from 'web/components/site-link'
import { contractPathWithoutContract } from 'web/lib/firebase/contracts'
import { Tooltip } from 'web/components/tooltip'
import {
bronzeClassName,
goldClassName,
silverClassName,
} from 'web/components/badge-display'
import { formatMoney } from 'common/util/format'
export function BadgesModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
user: User
}) {
const { isOpen, setOpen, user } = props
const { provenCorrect, marketCreator, streaker } = user.achievements ?? {}
const badges = [
...(provenCorrect?.badges ?? []),
...(streaker?.badges ?? []),
...(marketCreator?.badges ?? []),
]
// group badges by their rarities
const badgesByRarity = groupBy(badges, (badge) => calculateBadgeRarity(badge))
return (
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={clsx('text-8xl')}>🏅</span>
<span className="text-xl">{user.name + "'s"} badges</span>
<Row className={'flex-wrap gap-2'}>
<Col
className={clsx(
'min-w-full gap-2 rounded-md border-2 border-amber-900 border-opacity-40 p-2 text-center'
)}
>
<span className={clsx(' ', bronzeClassName)}>Bronze</span>
<Row className={'flex-wrap justify-center gap-4'}>
{badgesByRarity['bronze'] ? (
badgesByRarity['bronze'].map((badge, i) => (
<BadgeToItem badge={badge} key={i} rarity={'bronze'} />
))
) : (
<span className={'text-gray-500'}>None yet</span>
)}
</Row>
</Col>
<Col
className={clsx(
'min-w-full gap-2 rounded-md border-2 border-gray-500 border-opacity-40 p-2 text-center '
)}
>
<span className={clsx(' ', silverClassName)}>Silver</span>
<Row className={'flex-wrap justify-center gap-4'}>
{badgesByRarity['silver'] ? (
badgesByRarity['silver'].map((badge, i) => (
<BadgeToItem badge={badge} key={i} rarity={'silver'} />
))
) : (
<span className={'text-gray-500'}>None yet</span>
)}
</Row>
</Col>
<Col
className={clsx(
'min-w-full gap-2 rounded-md border-2 border-amber-400 p-2 text-center '
)}
>
<span className={clsx('', goldClassName)}>Gold</span>
<Row className={'flex-wrap justify-center gap-4'}>
{badgesByRarity['gold'] ? (
badgesByRarity['gold'].map((badge, i) => (
<BadgeToItem badge={badge} key={i} rarity={'gold'} />
))
) : (
<span className={'text-gray-500'}>None yet</span>
)}
</Row>
</Col>
</Row>
</Col>
</Modal>
)
}
function BadgeToItem(props: { badge: Badge; rarity: rarities }) {
const { badge, rarity } = props
if (badge.type === 'PROVEN_CORRECT')
return (
<ProvenCorrectBadgeItem
badge={badge as ProvenCorrectBadge}
rarity={rarity}
/>
)
else if (badge.type === 'STREAKER')
return <StreakerBadgeItem badge={badge as StreakerBadge} rarity={rarity} />
else if (badge.type === 'MARKET_CREATOR')
return (
<MarketCreatorBadgeItem
badge={badge as MarketCreatorBadge}
rarity={rarity}
/>
)
else return null
}
function ProvenCorrectBadgeItem(props: {
badge: ProvenCorrectBadge
rarity: rarities
}) {
const { badge, rarity } = props
const { betAmount, contractSlug, contractCreatorUsername } = badge.data
return (
<SiteLink
href={contractPathWithoutContract(contractCreatorUsername, contractSlug)}
>
<Col className={'text-center'}>
<Medal rarity={rarity} />
<Tooltip
text={`Make a comment attached to a winning bet worth ${formatMoney(
betAmount
)}`}
>
<span
className={
rarity === 'gold'
? goldClassName
: rarity === 'silver'
? silverClassName
: bronzeClassName
}
>
Proven Correct
</span>
</Tooltip>
</Col>
</SiteLink>
)
}
function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) {
const { badge, rarity } = props
const { totalBettingStreak } = badge.data
return (
<Col className={'cursor-default text-center'}>
<Medal rarity={rarity} />
<Tooltip
text={`Make ${PAST_BETS} ${totalBettingStreak} day${
totalBettingStreak > 1 ? 's' : ''
} in a row`}
>
<span
className={
rarity === 'gold'
? goldClassName
: rarity === 'silver'
? silverClassName
: bronzeClassName
}
>
Prediction Streak
</span>
</Tooltip>
</Col>
)
}
function MarketCreatorBadgeItem(props: {
badge: MarketCreatorBadge
rarity: rarities
}) {
const { badge, rarity } = props
const { totalContractsCreated } = badge.data
return (
<Col className={'cursor-default text-center'}>
<Medal rarity={rarity} />
<Tooltip
text={`Make ${totalContractsCreated} market${
totalContractsCreated > 1 ? 's' : ''
}`}
>
<span
className={
rarity === 'gold'
? goldClassName
: rarity === 'silver'
? silverClassName
: bronzeClassName
}
>
Market Creator
</span>
</Tooltip>
</Col>
)
}
function Medal(props: { rarity: rarities }) {
const { rarity } = props
return (
<span
className={
rarity === 'gold'
? goldClassName
: rarity === 'silver'
? silverClassName
: bronzeClassName
}
>
{rarity === 'gold' ? '🥇' : rarity === 'silver' ? '🥈' : '🥉'}
</span>
)
}

View File

@ -13,7 +13,7 @@ import clsx from 'clsx'
export function BettingStreakModal(props: { export function BettingStreakModal(props: {
isOpen: boolean isOpen: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
currentUser?: User | null currentUser: User | null | undefined
}) { }) {
const { isOpen, setOpen, currentUser } = props const { isOpen, setOpen, currentUser } = props
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser) const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)

View File

@ -35,8 +35,8 @@ export function LoansModal(props: {
</span> </span>
<span className={'text-indigo-700'}> What is an example?</span> <span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}> <span className={'ml-2'}>
For example, if you bet M$1000 on "Will I become a millionare?" For example, if you bet M$1000 on "Will I become a millionare?", you
today, you will get M$20 back tomorrow. will get M$20 back tomorrow.
</span> </span>
<span className={'ml-2'}> <span className={'ml-2'}>
Previous loans count against your total bet amount. So on the next Previous loans count against your total bet amount. So on the next

View File

@ -1,4 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ENV_CONFIG } from 'common/envs/constants'
export function ProfitBadge(props: { export function ProfitBadge(props: {
profitPercent: number profitPercent: number
@ -26,3 +27,31 @@ export function ProfitBadge(props: {
</span> </span>
) )
} }
export function ProfitBadgeMana(props: {
amount: number
gray?: boolean
className?: string
}) {
const { amount, gray, className } = props
const colors = gray
? 'bg-gray-100 text-gray-700'
: amount > 0
? 'bg-gray-100 text-green-800'
: 'bg-gray-100 text-red-800'
const formatted =
ENV_CONFIG.moneyMoniker + (amount > 0 ? '+' : '') + amount.toFixed(0)
return (
<span
className={clsx(
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
colors,
className
)}
>
{formatted}
</span>
)
}

View File

@ -8,6 +8,7 @@ import { OutcomeLabel } from './outcome-label'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares' import { useSaveBinaryShares } from './use-save-binary-shares'
import { SellSharesModal } from './sell-modal' import { SellSharesModal } from './sell-modal'
import { Button } from './button'
export function SellRow(props: { export function SellRow(props: {
contract: BinaryContract | PseudoNumericContract contract: BinaryContract | PseudoNumericContract
@ -37,17 +38,14 @@ export function SellRow(props: {
shares shares
</div> </div>
<button <Button
className="btn btn-sm" className="my-auto"
style={{ size="xs"
backgroundColor: 'white', color="gray-outline"
border: '2px solid',
color: '#3D4451',
}}
onClick={() => setShowSellModal(true)} onClick={() => setShowSellModal(true)}
> >
Sell Sell
</button> </Button>
</Row> </Row>
</Col> </Col>
{showSellModal && ( {showSellModal && (

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