Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-08-25 14:06:34 -07:00 committed by GitHub
commit 127e6930f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 1861 additions and 831 deletions

View File

@ -11,11 +11,8 @@ import {
import { User } from './user' import { User } from './user'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees' import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants'
import { Answer } from './answer' import { Answer } from './answer'
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id

View File

@ -61,5 +61,3 @@ export type fill = {
// I.e. -fill.shares === matchedBet.shares // I.e. -fill.shares === matchedBet.shares
isSale?: boolean isSale?: boolean
} }
export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -57,6 +57,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[] uniqueBettorIds?: string[]
uniqueBettorCount?: number uniqueBettorCount?: number
popularityScore?: number popularityScore?: number
followerCount?: number
featuredOnHomeRank?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary

17
common/economy.ts Normal file
View File

@ -0,0 +1,17 @@
import { ENV_CONFIG } from './envs/constants'
const econ = ENV_CONFIG.economy
export const FIXED_ANTE = econ?.FIXED_ANTE ?? 100
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5

View File

@ -44,3 +44,7 @@ export const CORS_ORIGIN_VERCEL = new RegExp(
) )
// Any localhost server on any port // Any localhost server on any port
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
export function firestoreConsolePath(contractId: string) {
return `https://console.firebase.google.com/project/${PROJECT_ID}/firestore/data/~2Fcontracts~2F${contractId}`
}

View File

@ -19,10 +19,23 @@ export type EnvConfig = {
navbarLogoPath?: string navbarLogoPath?: string
newQuestionPlaceholders: string[] newQuestionPlaceholders: string[]
// Currency controls economy?: Economy
fixedAnte?: number }
startingBalance?: number
referralBonus?: number export type Economy = {
FIXED_ANTE?: number
STARTING_BALANCE?: number
SUS_STARTING_BALANCE?: number
REFERRAL_AMOUNT?: number
UNIQUE_BETTOR_BONUS_AMOUNT?: number
BETTING_STREAK_BONUS_AMOUNT?: number
BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
} }
type FirebaseConfig = { type FirebaseConfig = {
@ -58,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
'taowell@gmail.com', // Stephen 'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair 'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold 'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid
], ],
visibility: 'PUBLIC', visibility: 'PUBLIC',

View File

@ -1,9 +1,9 @@
export const PLATFORM_FEE = 0 export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0.1 export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0 export const LIQUIDITY_FEE = 0
export const DPM_PLATFORM_FEE = 0.01 export const DPM_PLATFORM_FEE = 0.0
export const DPM_CREATOR_FEE = 0.04 export const DPM_CREATOR_FEE = 0.0
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
export type Fees = { export type Fees = {

138
common/loans.ts Normal file
View File

@ -0,0 +1,138 @@
import { Dictionary, groupBy, sumBy, minBy } from 'lodash'
import { Bet } from './bet'
import { getContractBetMetrics } from './calculate'
import {
Contract,
CPMMContract,
FreeResponseContract,
MultipleChoiceContract,
} from './contract'
import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array'
const LOAN_DAILY_RATE = 0.01
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal
return netValue * LOAN_DAILY_RATE
}
export const getLoanUpdates = (
users: User[],
contractsById: { [contractId: string]: Contract },
portfolioByUser: { [userId: string]: PortfolioMetrics | undefined },
betsByUser: { [userId: string]: Bet[] }
) => {
const eligibleUsers = filterDefined(
users.map((user) =>
isUserEligibleForLoan(portfolioByUser[user.id]) ? user : undefined
)
)
const betUpdates = eligibleUsers
.map((user) => {
const updates = calculateLoanBetUpdates(
betsByUser[user.id] ?? [],
contractsById
).betUpdates
return updates.map((update) => ({ ...update, user }))
})
.flat()
const updatesByUser = groupBy(betUpdates, (update) => update.userId)
const userPayouts = Object.values(updatesByUser).map((updates) => {
return {
user: updates[0].user,
payout: sumBy(updates, (update) => update.newLoan),
}
})
return {
betUpdates,
userPayouts,
}
}
const isUserEligibleForLoan = (portfolio: PortfolioMetrics | undefined) => {
if (!portfolio) return true
const { balance, investmentValue } = portfolio
return balance + investmentValue > 0
}
const calculateLoanBetUpdates = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contracts = filterDefined(
Object.keys(betsByContract).map((contractId) => contractsById[contractId])
).filter((c) => !c.isResolved)
const betUpdates = filterDefined(
contracts
.map((c) => {
if (c.mechanism === 'cpmm-1') {
return getBinaryContractLoanUpdate(c, betsByContract[c.id])
} else if (
c.outcomeType === 'FREE_RESPONSE' ||
c.outcomeType === 'MULTIPLE_CHOICE'
)
return getFreeResponseContractLoanUpdate(c, betsByContract[c.id])
else {
// Unsupported contract / mechanism for loans.
return []
}
})
.flat()
)
const totalNewLoan = sumBy(betUpdates, (loanUpdate) => loanUpdate.loanTotal)
return {
totalNewLoan,
betUpdates,
}
}
const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
const { invested } = getContractBetMetrics(contract, bets)
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const oldestBet = minBy(bets, (bet) => bet.createdTime)
const newLoan = calculateNewLoan(invested, loanAmount)
if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
return {
userId: oldestBet.userId,
contractId: contract.id,
betId: oldestBet.id,
newLoan,
loanTotal,
}
}
const getFreeResponseContractLoanUpdate = (
contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[]
) => {
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
return openBets.map((bet) => {
const loanAmount = bet.loanAmount ?? 0
const newLoan = calculateNewLoan(bet.amount, loanAmount)
const loanTotal = loanAmount + newLoan
if (!isFinite(newLoan) || newLoan <= 0) return undefined
return {
userId: bet.userId,
contractId: contract.id,
betId: bet.id,
newLoan,
loanTotal,
}
})
}

View File

@ -1,6 +1,6 @@
import { sortBy, sum, sumBy } from 'lodash' import { sortBy, sum, sumBy } from 'lodash'
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { Bet, fill, LimitBet, NumericBet } from './bet'
import { import {
calculateDpmShares, calculateDpmShares,
getDpmProbability, getDpmProbability,
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
export const getNewBinaryDpmBetInfo = ( export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: DPMBinaryContract, contract: DPMBinaryContract
loanAmount: number
) => { ) => {
const { YES: yesPool, NO: noPool } = contract.pool const { YES: yesPool, NO: noPool } = contract.pool
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
const newBet: CandidateBet = { const newBet: CandidateBet = {
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount, loanAmount: 0,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
loanAmount: number
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
const newBet: CandidateBet = { const newBet: CandidateBet = {
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount, loanAmount: 0,
shares, shares,
outcome, outcome,
probBefore, probBefore,
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
return { newBet, newPool, newTotalShares, newTotalBets } return { newBet, newPool, newTotalShares, newTotalBets }
} }
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min(
newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount
)
return loanAmount
}

View File

@ -39,6 +39,7 @@ export type notification_source_types =
| 'bonus' | 'bonus'
| 'challenge' | 'challenge'
| 'betting_streak_bonus' | 'betting_streak_bonus'
| 'loan'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -68,3 +69,5 @@ export type notification_reason_types =
| 'user_joined_from_your_group_invite' | 'user_joined_from_your_group_invite'
| 'challenge_accepted' | 'challenge_accepted'
| 'betting_streak_incremented' | 'betting_streak_incremented'
| 'loan_income'
| 'you_follow_contract'

View File

@ -3,7 +3,3 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
export const BETTING_STREAK_BONUS_AMOUNT = 5
export const BETTING_STREAK_BONUS_MAX = 100
export const BETTING_STREAK_RESET_HOUR = 0

View File

@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares) const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares) const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0) const shares = Math.max(Math.min(yesShares, noShares), 0)
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = Math.min(loanAmount, shares) const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment const netAmount = shares - loanPayment
return { shares, loanPayment, netAmount } return { shares, loanPayment, netAmount }
} }

View File

@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
const { id: betId, amount, shares, outcome } = bet const { id: betId, amount, shares, outcome, loanAmount } = bet
const adjShareValue = calculateDpmShareValue(contract, bet) const adjShareValue = calculateDpmShareValue(contract, bet)
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
betId, betId,
}, },
fees, fees,
loanAmount: -(loanAmount ?? 0),
} }
return { return {
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
contract: CPMMContract, contract: CPMMContract,
prevLoanAmount: number, unfilledBets: LimitBet[],
unfilledBets: LimitBet[] loanPaid: number
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
unfilledBets unfilledBets
) )
const loanPaid = Math.min(prevLoanAmount, saleValue)
const probBefore = getCpmmProbability(pool, p) const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)

View File

@ -1,5 +1,3 @@
import { ENV_CONFIG } from './envs/constants'
export type User = { export type User = {
id: string id: string
createdTime: number createdTime: number
@ -32,6 +30,7 @@ export type User = {
allTime: number allTime: number
} }
nextLoanCached: number
followerCountCached: number followerCountCached: number
followedCategories?: string[] followedCategories?: string[]
@ -43,13 +42,10 @@ export type User = {
shouldShowWelcome?: boolean shouldShowWelcome?: boolean
lastBetTime?: number lastBetTime?: number
currentBettingStreak?: number currentBettingStreak?: number
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
} }
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = { export type PrivateUser = {
id: string // same as User.id id: string // same as User.id
username: string // denormalized from User username: string // denormalized from User
@ -60,6 +56,7 @@ export type PrivateUser = {
unsubscribedFromAnswerEmails?: boolean unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean unsubscribedFromGenericEmails?: boolean
unsubscribedFromWeeklyTrendingEmails?: boolean unsubscribedFromWeeklyTrendingEmails?: boolean
weeklyTrendingEmailSent?: boolean
manaBonusEmailSent?: boolean manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string

2
dev.sh
View File

@ -24,7 +24,7 @@ then
npx concurrently \ npx concurrently \
-n FIRESTORE,FUNCTIONS,NEXT,TS \ -n FIRESTORE,FUNCTIONS,NEXT,TS \
-c green,white,magenta,cyan \ -c green,white,magenta,cyan \
"yarn --cwd=functions firestore" \ "yarn --cwd=functions localDbScript" \
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \ "cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \ "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \ NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \

View File

@ -97,7 +97,6 @@ Requires no authorization.
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c", "creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
"closeTime":1653893940000, "closeTime":1653893940000,
"question":"Will I write a new blog post today?", "question":"Will I write a new blog post today?",
"description":"I'm supposed to, or else Beeminder charges me $90.\nTentative topic ideas:\n- \"Manifold funding, a history\"\n- \"Markets and bounties allow trades through time\"\n- \"equity vs money vs time\"\n\nClose date updated to 2022-05-29 11:59 pm",
"tags":[ "tags":[
"personal", "personal",
"commitments" "commitments"
@ -135,8 +134,6 @@ Requires no authorization.
// Market attributes. All times are in milliseconds since epoch // Market attributes. All times are in milliseconds since epoch
closeTime?: number // Min of creator's chosen date, and resolutionTime closeTime?: number // Min of creator's chosen date, and resolutionTime
question: string question: string
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds
// A list of tags on each market. Any user can add tags to any market. // A list of tags on each market. Any user can add tags to any market.
// This list also includes the predefined categories shown as filters on the home page. // This list also includes the predefined categories shown as filters on the home page.
@ -398,6 +395,8 @@ Requires no authorization.
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
answers?: Answer[] answers?: Answer[]
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds
} }
type Bet = { type Bet = {

View File

@ -10,7 +10,9 @@ service cloud.firestore {
'akrolsmir@gmail.com', 'akrolsmir@gmail.com',
'jahooma@gmail.com', 'jahooma@gmail.com',
'taowell@gmail.com', 'taowell@gmail.com',
'manticmarkets@gmail.com' 'abc.sinclair@gmail.com',
'manticmarkets@gmail.com',
'iansphilips@gmail.com'
] ]
} }
@ -22,7 +24,7 @@ service cloud.firestore {
allow read; allow read;
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
// 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()
@ -43,6 +45,11 @@ service cloud.firestore {
allow read; allow read;
} }
match /contracts/{contractId}/follows/{userId} {
allow read;
allow create, delete: if userId == request.auth.uid;
}
match /contracts/{contractId}/challenges/{challengeId}{ match /contracts/{contractId}/challenges/{challengeId}{
allow read; allow read;
allow create: if request.auth.uid == request.resource.data.creatorId; allow create: if request.auth.uid == request.resource.data.creatorId;

View File

@ -13,8 +13,8 @@
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"dev": "nodemon src/serve.ts", "dev": "nodemon src/serve.ts",
"firestore": "firebase emulators:start --only firestore --import=./firestore_export", "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",

View File

@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
} }
transaction.create(newAnswerDoc, answer) transaction.create(newAnswerDoc, answer)
const loanAmount = 0
const { newBet, newPool, newTotalShares, newTotalBets } = const { newBet, newPool, newTotalShares, newTotalBets } =
getNewMultiBetInfo(answerId, amount, contract, loanAmount) getNewMultiBetInfo(answerId, amount, contract)
const newBalance = user.balance - amount const newBalance = user.balance - amount
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc() const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()

View File

@ -15,15 +15,17 @@ import {
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { chargeUser, getContract } from './utils' import { chargeUser, getContract, isProd } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api' import { APIError, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import { import {
FIXED_ANTE, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
getCpmmInitialLiquidity, getCpmmInitialLiquidity,
getFreeAnswerAnte, getFreeAnswerAnte,
getMultipleChoiceAntes, getMultipleChoiceAntes,
getNumericAnte, getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes' } from '../../common/antes'
import { Answer, getNoneAnswer } from '../../common/answer' import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash' import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { FieldValue } from 'firebase-admin/firestore'
const descScehma: z.ZodType<JSONContent> = z.lazy(() => const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const user = userDoc.data() as User const user = userDoc.data() as User
const ante = FIXED_ANTE const ante = FIXED_ANTE
const deservesFreeMarket =
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
// TODO: this is broken because it's not in a transaction // TODO: this is broken because it's not in a transaction
if (ante > user.balance) if (ante > user.balance && !deservesFreeMarket)
throw new APIError(400, `Balance must be at least ${ante}.`) throw new APIError(400, `Balance must be at least ${ante}.`)
let group: Group | null = null let group: Group | null = null
@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
visibility visibility
) )
if (ante) await chargeUser(user.id, ante, true) const providerId = deservesFreeMarket
? isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
: user.id
if (ante) await chargeUser(providerId, ante, true)
if (deservesFreeMarket)
await firestore
.collection('users')
.doc(user.id)
.update({ freeMarketsCreated: FieldValue.increment(1) })
await contractRef.create(contract) await contractRef.create(contract)
@ -221,8 +236,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
} }
} }
const providerId = user.id
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`) .collection(`contracts/${contract.id}/liquidity`)

View File

@ -7,7 +7,7 @@ import {
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getValues } from './utils' import { getValues, log } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
@ -33,19 +33,12 @@ export const createNotification = async (
sourceText: string, sourceText: string,
miscData?: { miscData?: {
contract?: Contract contract?: Contract
relatedSourceType?: notification_source_types
recipients?: string[] recipients?: string[]
slug?: string slug?: string
title?: string title?: string
} }
) => { ) => {
const { const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
contract: sourceContract,
relatedSourceType,
recipients,
slug,
title,
} = miscData ?? {}
const shouldGetNotification = ( const shouldGetNotification = (
userId: string, userId: string,
@ -90,24 +83,6 @@ export const createNotification = async (
) )
} }
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts,
contract: Contract
) => {
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (!shouldGetNotification(userId, userToReasonTexts)) return
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUsersFollowers = async ( const notifyUsersFollowers = async (
userToReasonTexts: user_to_reason_texts userToReasonTexts: user_to_reason_texts
) => { ) => {
@ -129,23 +104,6 @@ export const createNotification = async (
}) })
} }
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
const notifyFollowedUser = ( const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
followedUserId: string followedUserId: string
@ -182,71 +140,6 @@ export const createNotification = async (
} }
} }
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUserAddedToGroup = ( const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
relatedUserId: string relatedUserId: string
@ -266,9 +159,9 @@ export const createNotification = async (
} }
} }
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && recipients?.[0]) { if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0]) notifyFollowedUser(userToReasonTexts, recipients[0])
} else if ( } else if (
@ -277,47 +170,278 @@ export const createNotification = async (
recipients recipients
) { ) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
} } else if (
sourceType === 'contract' &&
// The following functions need sourceContract to be defined. sourceUpdateType === 'created' &&
if (!sourceContract) return userToReasonTexts sourceContract
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
) { ) {
if (sourceType === 'comment') {
if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts) await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? []) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') { } else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract, { await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true, force: true,
}) })
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { } else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract) await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') { } else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it // Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus( await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts, userToReasonTexts,
sourceContract.creatorId sourceContract.creatorId
) )
} }
return userToReasonTexts
await createUsersNotifications(userToReasonTexts)
} }
const userToReasonTexts = await getUsersToNotify() export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract: Contract,
miscData?: {
relatedSourceType?: notification_source_types
repliedUserId?: string
taggedUserIds?: string[]
}
) => {
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceUpdateType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract.creatorUsername,
sourceContractTitle: sourceContract.question,
sourceContractSlug: sourceContract.slug,
sourceSlug: sourceContract.slug,
sourceTitle: sourceContract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${sourceContract.id}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
log('contractFollowerIds', contractFollowersIds)
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
const shouldGetNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
) => {
return (
sourceUser.id != userId &&
!Object.keys(userToReasonTexts).includes(userId)
)
}
const notifyContractFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
for (const userId of contractFollowersIds) {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'you_follow_contract',
}
}
}
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts
) => {
if (
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
stillFollowingContract(sourceContract.creatorId)
)
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (
shouldGetNotification(relatedUserId, userToReasonTexts) &&
stillFollowingContract(relatedUserId)
) {
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
}
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[]
) => {
userIds.forEach((id) => {
console.log('tagged user: ', id)
// Allowing non-following users to get tagged
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts
) => {
const liquidityProviders = await firestore
.collection(`contracts/${sourceContract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
) {
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
}
})
}
const userToReasonTexts: user_to_reason_texts = {}
if (sourceType === 'comment') {
if (repliedUserId && relatedSourceType)
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
}
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyLiquidityProviders(userToReasonTexts)
await notifyBettorsOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
// if they weren't added previously, add them now
await notifyContractFollowers(userToReasonTexts)
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
} }
@ -471,6 +595,32 @@ export const createReferralNotification = async (
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
} }
export const createLoanIncomeNotification = async (
toUser: User,
idempotencyKey: string,
income: number
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: 'loan_income',
createdTime: Date.now(),
isSeen: false,
sourceId: idempotencyKey,
sourceType: 'loan',
sourceUpdateType: 'updated',
sourceUserName: toUser.name,
sourceUserUsername: toUser.username,
sourceUserAvatarUrl: toUser.avatarUrl,
sourceText: income.toString(),
sourceTitle: 'Loan',
}
await notificationRef.set(removeUndefinedProps(notification))
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}` const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async ( export const createChallengeAcceptedNotification = async (

View File

@ -2,15 +2,8 @@ import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { import { PrivateUser, User } from '../../common/user'
MANIFOLD_AVATAR_URL, import { getUser, getUserByUsername, getValues } from './utils'
MANIFOLD_USERNAME,
PrivateUser,
STARTING_BALANCE,
SUS_STARTING_BALANCE,
User,
} from '../../common/user'
import { getUser, getUserByUsername, getValues, isProd } from './utils'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { import {
cleanDisplayName, cleanDisplayName,
@ -25,10 +18,7 @@ import {
import { track } from './analytics' import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import { import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
const bodySchema = z.object({ const bodySchema = z.object({
deviceToken: z.string().optional(), deviceToken: z.string().optional(),
@ -75,6 +65,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
createdTime: Date.now(), createdTime: Date.now(),
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
nextLoanCached: 0,
followerCountCached: 0, followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true, shouldShowWelcome: true,
@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
.update({ .update({
memberIds: uniq(group.memberIds.concat(user.id)), memberIds: uniq(group.memberIds.concat(user.id)),
}) })
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
if (slug === 'welcome') {
const welcomeCommentDoc = firestore
.collection(`groups/${group.id}/comments`)
.doc()
await welcomeCommentDoc.create({
id: welcomeCommentDoc.id,
groupId: group.id,
userId: manifoldAccount,
text: `Welcome, @${user.username} aka ${user.name}!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: MANIFOLD_USERNAME,
userAvatarUrl: MANIFOLD_AVATAR_URL,
})
}
} }
} }

View File

@ -444,7 +444,7 @@
style=" style="
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
" target="_blank">click here to unsubscribe</a>. " target="_blank">click here to unsubscribe</a> from future recommended markets.
</p> </p>
</div> </div>
</td> </td>

View File

@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}` const subject = `Resolved ${outcome}: ${contract.question}`
const creatorPayoutText = // const creatorPayoutText =
userId === creator.id // userId === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)` // ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: '' // : ''
const emailType = 'market-resolved' const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
question: contract.question, question: contract.question,
outcome, outcome,
investment: `${Math.floor(investment)}`, investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}${creatorPayoutText}`, payout: `${Math.floor(payout)}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl, unsubscribeUrl,
} }
@ -116,7 +116,9 @@ const toDisplayResolution = (
} }
if (contract.outcomeType === 'PSEUDO_NUMERIC') { if (contract.outcomeType === 'PSEUDO_NUMERIC') {
const { resolutionValue } = contract const { resolution, resolutionValue } = contract
if (resolution === 'CANCEL') return 'N/A'
return resolutionValue return resolutionValue
? formatLargeNumber(resolutionValue) ? formatLargeNumber(resolutionValue)

View File

@ -0,0 +1,36 @@
import * as admin from 'firebase-admin'
const firestore = admin.firestore()
export const addUserToContractFollowers = async (
contractId: string,
userId: string
) => {
const followerDoc = await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.get()
if (followerDoc.exists) return
await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.set({
id: userId,
createdTime: Date.now(),
})
}
export const removeUserFromContractFollowers = async (
contractId: string,
userId: string
) => {
const followerDoc = await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.get()
if (!followerDoc.exists) return
await firestore
.collection(`contracts/${contractId}/follows`)
.doc(userId)
.delete()
}

View File

@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export * from './update-metrics' export * from './update-metrics'
export * from './update-stats' export * from './update-stats'
export * from './update-loans'
export * from './backup-db' export * from './backup-db'
export * from './market-close-notifications' export * from './market-close-notifications'
export * from './on-create-answer' export * from './on-create-answer'
@ -28,6 +29,8 @@ export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
export * from './weekly-markets-emails' export * from './weekly-markets-emails'
export * from './reset-betting-streaks' export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow'
// v2 // v2
export * from './health' export * from './health'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils' import { getContract, getUser } from './utils'
import { createNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
export const onCreateAnswer = functions.firestore export const onCreateAnswer = functions.firestore
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
const answerCreator = await getUser(answer.userId) const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator') if (!answerCreator) throw new Error('Could not find answer creator')
await createCommentOrAnswerOrUpdatedContractNotification(
await createNotification(
answer.id, answer.id,
'answer', 'answer',
'created', 'created',
answerCreator, answerCreator,
eventId, eventId,
answer.text, answer.text,
{ contract } contract
) )
}) })

View File

@ -17,7 +17,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,
} from '../../common/numeric-constants' } from '../../common/economy'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,

View File

@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { createNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
const commentCreator = await getUser(comment.userId) const commentCreator = await getUser(comment.userId)
if (!commentCreator) throw new Error('Could not find comment creator') if (!commentCreator) throw new Error('Could not find comment creator')
await addUserToContractFollowers(contract.id, commentCreator.id)
await firestore await firestore
.collection('contracts') .collection('contracts')
.doc(contract.id) .doc(contract.id)
@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
? comments.find((c) => c.id === comment.replyToCommentId)?.userId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId : answer?.userId
const recipients = uniq( await createCommentOrAnswerOrUpdatedContractNotification(
compact([...parseMentions(comment.content), repliedUserId])
)
await createNotification(
comment.id, comment.id,
'comment', 'comment',
'created', 'created',
commentCreator, commentCreator,
eventId, eventId,
richTextToString(comment.content), richTextToString(comment.content),
{ contract, relatedSourceType, recipients } contract,
{
relatedSourceType,
repliedUserId,
taggedUserIds: compact(parseMentions(comment.content)),
}
) )
const recipientUserIds = uniq([ const recipientUserIds = uniq([

View File

@ -5,6 +5,7 @@ import { createNotification } 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'
export const onCreateContract = functions export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
@ -18,6 +19,7 @@ export const onCreateContract = functions
const desc = contract.description as JSONContent const desc = contract.description as JSONContent
const mentioned = parseMentions(desc) const mentioned = parseMentions(desc)
await addUserToContractFollowers(contract.id, contractCreator.id)
await createNotification( await createNotification(
contract.id, contract.id,

View File

@ -1,7 +1,13 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils' import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification' import { createNotification } from './create-notification'
import { LiquidityProvision } from 'common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
export const onCreateLiquidityProvision = functions.firestore export const onCreateLiquidityProvision = functions.firestore
.document('contracts/{contractId}/liquidity/{liquidityId}') .document('contracts/{contractId}/liquidity/{liquidityId}')
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
const { eventId } = context const { eventId } = context
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return if (
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
liquidity.amount === FIXED_ANTE
)
return
log(`onCreateLiquidityProvision: ${JSON.stringify(liquidity)}`)
const contract = await getContract(liquidity.contractId) const contract = await getContract(liquidity.contractId)
if (!contract) if (!contract)
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
const liquidityProvider = await getUser(liquidity.userId) const liquidityProvider = await getUser(liquidity.userId)
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 createNotification( await createNotification(
contract.id, contract.id,

View File

@ -0,0 +1,45 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
export const onDeleteContractFollow = functions.firestore
.document('contracts/{contractId}/follows/{userId}')
.onDelete(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const firestore = admin.firestore()
const contract = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
if (!contract.exists) throw new Error('Could not find contract')
await firestore
.collection(`contracts`)
.doc(contractId)
.update({
followerCount: FieldValue.increment(-1),
})
})
export const onCreateContractFollow = functions.firestore
.document('contracts/{contractId}/follows/{userId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const firestore = admin.firestore()
const contract = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
if (!contract.exists) throw new Error('Could not find contract')
await firestore
.collection(`contracts`)
.doc(contractId)
.update({
followerCount: FieldValue.increment(1),
})
})

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
export const onUpdateContract = functions.firestore export const onUpdateContract = functions.firestore
@ -29,40 +29,37 @@ export const onUpdateContract = functions.firestore
resolutionText = `${contract.resolutionValue}` resolutionText = `${contract.resolutionValue}`
} }
await createNotification( await createCommentOrAnswerOrUpdatedContractNotification(
contract.id, contract.id,
'contract', 'contract',
'resolved', 'resolved',
contractUpdater, contractUpdater,
eventId, eventId,
resolutionText, resolutionText,
{ contract } contract
) )
} else if ( } else if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
previousValue.description !== contract.description previousValue.question !== contract.question
) { ) {
let sourceText = '' let sourceText = ''
if (previousValue.closeTime !== contract.closeTime && contract.closeTime) if (
previousValue.closeTime !== contract.closeTime &&
contract.closeTime
) {
sourceText = contract.closeTime.toString() sourceText = contract.closeTime.toString()
else { } else if (previousValue.question !== contract.question) {
const oldTrimmedDescription = previousValue.description.trim() sourceText = contract.question
const newTrimmedDescription = contract.description.trim()
if (oldTrimmedDescription === '') sourceText = newTrimmedDescription
else
sourceText = newTrimmedDescription
.split(oldTrimmedDescription)[1]
.trim()
} }
await createNotification( await createCommentOrAnswerOrUpdatedContractNotification(
contract.id, contract.id,
'contract', 'contract',
'updated', 'updated',
contractUpdater, contractUpdater,
eventId, eventId,
sourceText, sourceText,
{ contract } contract
) )
} }
}) })

View File

@ -1,13 +1,14 @@
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 { REFERRAL_AMOUNT, User } from '../../common/user' import { User } from '../../common/user'
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification' import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn' import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { LimitBet } from 'common/bet' import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore' import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from 'common/group' import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()
export const onUpdateUser = functions.firestore export const onUpdateUser = functions.firestore

View File

@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math' import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -59,7 +60,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const user = userSnap.data() as User const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.') if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
const loanAmount = 0
const { closeTime, outcomeType, mechanism, collectedFees, volume } = const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract contract
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
@ -119,7 +119,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const answerDoc = contractDoc.collection('answers').doc(outcome) const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc) const answerSnap = await trans.get(answerDoc)
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer') if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
return getNewMultiBetInfo(outcome, amount, contract, loanAmount) return getNewMultiBetInfo(outcome, amount, contract)
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') { } else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
const { outcome, value } = validate(numericSchema, req.body) const { outcome, value } = validate(numericSchema, req.body)
return getNumericBetsInfo(value, outcome, amount, contract) return getNumericBetsInfo(value, outcome, amount, contract)
@ -168,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
return { betId: betDoc.id, makers, newBet } return { betId: betDoc.id, makers, newBet }
}) })
await addUserToContractFollowers(contractId, auth.uid)
log('Main transaction finished.') log('Main transaction finished.')
if (result.newBet.amount !== 0) { if (result.newBet.amount !== 0) {

View File

@ -4,12 +4,12 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
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 { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants' import { BETTING_STREAK_RESET_HOUR } from '../../common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()
export const resetBettingStreaksForUsers = functions.pubsub export const resetBettingStreaksForUsers = functions.pubsub
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
.timeZone('utc') .timeZone('Etc/UTC')
.onRun(async () => { .onRun(async () => {
await resetBettingStreaksInternal() await resetBettingStreaksInternal()
}) })

View File

@ -0,0 +1,24 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getAllPrivateUsers } from './utils'
export const resetWeeklyEmailsFlag = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
.pubsub.schedule('0 7 * * 1')
.timeZone('Etc/UTC')
.onRun(async () => {
const privateUsers = await getAllPrivateUsers()
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return !user.unsubscribedFromWeeklyTrendingEmails
})
const firestore = admin.firestore()
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false,
})
})
)
})

View File

@ -0,0 +1,75 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { Contract } from 'common/lib/contract'
import { Comment } from 'common/lib/comment'
import { uniq } from 'lodash'
import { Bet } from 'common/lib/bet'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/lib/antes'
const firestore = admin.firestore()
async function backfillContractFollowers() {
console.log('Backfilling contract followers')
const contracts = await getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
)
let count = 0
for (const contract of contracts) {
const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contract.id).collection('comments')
)
const commenterIds = uniq(comments.map((comment) => comment.userId))
const betsSnap = await firestore
.collection(`contracts/${contract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const bettorIds = uniq(bets.map((bet) => bet.userId))
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
// exclude free market liquidity provider
).filter(
(id) =>
id !== HOUSE_LIQUIDITY_PROVIDER_ID ||
id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID
)
const followerIds = uniq([
...commenterIds,
...bettorIds,
...liquidityProvidersIds,
contract.creatorId,
])
for (const followerId of followerIds) {
await firestore
.collection(`contracts/${contract.id}/follows`)
.doc(followerId)
.set({ id: followerId, createdTime: Date.now() })
}
// Perhaps handled by the trigger?
// const followerCount = followerIds.length
// await firestore
// .collection(`contracts`)
// .doc(contract.id)
// .update({ followerCount: followerCount })
count += 1
if (count % 100 === 0) {
console.log(`${count} contracts processed`)
}
}
}
if (require.main === module) {
backfillContractFollowers()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -3,7 +3,8 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
import { PrivateUser, STARTING_BALANCE, User } from '../../../common/user' import { PrivateUser, User } from 'common/user'
import { STARTING_BALANCE } from 'common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const saleAmount = newBet.sale!.amount const saleAmount = newBet.sale!.amount
const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0) const newBalance = user.balance + saleAmount + (newBet.loanAmount ?? 0)
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
transaction.update(userDoc, { balance: newBalance }) transaction.update(userDoc, { balance: newBalance })
transaction.update(betDoc, { isSold: true }) transaction.update(betDoc, { isSold: true })
// Note: id should have been newBetDoc.id! But leaving it for now so it's consistent.
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet }) transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
transaction.update( transaction.update(
contractDoc, contractDoc,

View File

@ -7,12 +7,13 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { getCpmmSellBetInfo } from '../../common/sell-bet' import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object' import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues, log } from './utils' import { log } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -28,12 +29,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBets] = await Promise.all([ const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
await Promise.all([
transaction.getAll(contractDoc, userDoc), transaction.getAll(contractDoc, userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction?? transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)),
]) ])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const user = userSnap.data() as User const user = userSnap.data() as User
@ -45,7 +50,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.') throw new APIError(400, 'Trading is closed.')
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome) const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
const sharesByOutcome = mapValues(betsByOutcome, (bets) => const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
sumBy(bets, (b) => b.shares) sumBy(bets, (b) => b.shares)
@ -77,18 +82,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
throw new APIError(400, `You can only sell up to ${maxShares} shares.`) throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const soldShares = Math.min(sharesToSell, maxShares) const soldShares = Math.min(sharesToSell, maxShares)
const saleFrac = soldShares / maxShares
const unfilledBetsSnap = await transaction.get( let loanPaid = saleFrac * loanAmount
getUnfilledBetsQuery(contractDoc) if (!isFinite(loanPaid)) loanPaid = 0
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares, soldShares,
chosenOutcome, chosenOutcome,
contract, contract,
prevLoanAmount, unfilledBets,
unfilledBets loanPaid
) )
if ( if (
@ -104,7 +107,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
updateMakers(makers, newBetDoc.id, contractDoc, transaction) updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.update(userDoc, { transaction.update(userDoc, {
balance: FieldValue.increment(-newBet.amount), balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
}) })
transaction.create(newBetDoc, { transaction.create(newBetDoc, {
id: newBetDoc.id, id: newBetDoc.id,
@ -121,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
}) })
) )
return { newBet, makers } return { newBet, makers, maxShares, soldShares }
}) })
if (result.maxShares === result.soldShares) {
await removeUserFromContractFollowers(contractId, auth.uid)
}
const userIds = uniq(result.makers.map((maker) => maker.bet.userId)) const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
log('Share redemption transaction finished.') log('Share redemption transaction finished.')

View File

@ -69,6 +69,10 @@ export const unsubscribe: EndpointDefinition = {
res.send( res.send(
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.` `${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
) )
else if (type === 'weekly-trending')
res.send(
`${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.`
)
else res.send(`${name}, you have been unsubscribed.`) else res.send(`${name}, you have been unsubscribed.`)
}, },
} }

View File

@ -0,0 +1,92 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, keyBy } from 'lodash'
import { getValues, log, payUser, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification'
const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
// Run every day at midnight.
.pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')
.onRun(updateLoansCore)
async function updateLoansCore() {
log('Updating loans...')
const [users, contracts, bets] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
),
getValues<Bet>(firestore.collectionGroup('bets')),
])
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
const userPortfolios = await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
)
log(`Loaded ${userPortfolios.length} portfolios`)
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
const contractsById = Object.fromEntries(
contracts.map((contract) => [contract.id, contract])
)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const { betUpdates, userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
log(`${betUpdates.length} bet updates.`)
const betDocUpdates = betUpdates.map((update) => ({
doc: firestore
.collection('contracts')
.doc(update.contractId)
.collection('bets')
.doc(update.betId),
fields: {
loanAmount: update.loanTotal,
},
}))
await writeAsync(firestore, betDocUpdates)
log(`${userPayouts.length} user payouts`)
await Promise.all(
userPayouts.map(({ user, payout }) => payUser(user.id, payout))
)
const today = new Date().toDateString().replace(' ', '-')
const key = `loan-notifications-${today}`
await Promise.all(
userPayouts
// Don't send a notification if the payout is < M$1,
// because a M$0 loan is confusing.
.filter(({ payout }) => payout >= 1)
.map(({ user, payout }) =>
createLoanIncomeNotification(user, key, payout)
)
)
log('Notifications sent!')
}

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, sum, sumBy } from 'lodash' import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils' import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate' import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash' import { last } from 'lodash'
import { getLoanUpdates } from '../../common/loans'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -21,7 +22,9 @@ const computeInvestmentValue = (
if (bet.sale || bet.isSold) return 0 if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT') const payout = calculatePayout(contract, bet, 'MKT')
return payout - (bet.loanAmount ?? 0) const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
}) })
} }
@ -71,7 +74,8 @@ export const updateMetricsCore = async () => {
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 portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userUpdates = 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 = portfolioHistoryByUser[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? []
@ -93,7 +97,29 @@ export const updateMetricsCore = async () => {
newPortfolio, newPortfolio,
didProfitChange didProfitChange
) )
return {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didProfitChange,
}
})
const portfolioByUser = Object.fromEntries(
userMetrics.map(({ user, newPortfolio }) => [user.id, newPortfolio])
)
const { userPayouts } = getLoanUpdates(
users,
contractsById,
portfolioByUser,
betsByUser
)
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
fieldUpdates: { fieldUpdates: {
doc: firestore.collection('users').doc(user.id), doc: firestore.collection('users').doc(user.id),
@ -102,6 +128,7 @@ export const updateMetricsCore = async () => {
...(didProfitChange && { ...(didProfitChange && {
profitCached: newProfit, profitCached: newProfit,
}), }),
nextLoanCached,
}, },
}, },
@ -118,7 +145,8 @@ export const updateMetricsCore = async () => {
}, },
}, },
} }
}) }
)
await writeAsync( await writeAsync(
firestore, firestore,
userUpdates.map((u) => u.fieldUpdates) userUpdates.map((u) => u.fieldUpdates)
@ -234,6 +262,6 @@ const calculateNewProfit = (
} }
export const updateMetrics = functions export const updateMetrics = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 }) .runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes') .pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore) .onRun(updateMetricsCore)

View File

@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
} }
export const updateStats = functions export const updateStats = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 }) .runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes') .pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore) .onRun(updateStatsCore)

View File

@ -9,9 +9,9 @@ import { DAY_MS } from '../../common/util/time'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
// every Monday at 12pm PT (UTC -07:00) // every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('0 19 * * 1') .pubsub.schedule('* 19 * * 1')
.timeZone('utc') .timeZone('Etc/UTC')
.onRun(async () => { .onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers() await sendTrendingMarketsEmailsToAllUsers()
}) })
@ -37,17 +37,33 @@ async function sendTrendingMarketsEmailsToAllUsers() {
const privateUsers = await getAllPrivateUsers() const privateUsers = await getAllPrivateUsers()
// get all users that haven't unsubscribed from weekly emails // get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => { const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return !user.unsubscribedFromWeeklyTrendingEmails return (
!user.unsubscribedFromWeeklyTrendingEmails &&
!user.weeklyTrendingEmailSent
)
}) })
log(
'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length,
'users'
)
const trendingContracts = (await getTrendingContracts()) const trendingContracts = (await getTrendingContracts())
.filter( .filter(
(contract) => (contract) =>
!( !(
contract.question.toLowerCase().includes('trump') && contract.question.toLowerCase().includes('trump') &&
contract.question.toLowerCase().includes('president') contract.question.toLowerCase().includes('president')
) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS ) &&
(contract?.closeTime ?? 0) > Date.now() + DAY_MS &&
!contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e')
) )
.slice(0, 20) .slice(0, 20)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
)
for (const privateUser of privateUsersToSendEmailsTo) { for (const privateUser of privateUsersToSendEmailsTo) {
if (!privateUser.email) { if (!privateUser.email) {
log(`No email for ${privateUser.username}`) log(`No email for ${privateUser.username}`)
@ -70,12 +86,17 @@ async function sendTrendingMarketsEmailsToAllUsers() {
if (!user) continue if (!user) continue
await sendInterestingMarketsEmail(user, privateUser, contractsToSend) await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
})
} }
} }
function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000 const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString() const seed = Math.round(Date.now() / fiveMinutes).toString()
shuffle(contracts, createRNG(seed)) const rng = createRNG(seed)
function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng)
return contracts.slice(0, count) return contracts.slice(0, count)
} }

View File

@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
import clsx from 'clsx' import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline' import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { Col } from 'web/components/layout/col'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
export function NotificationSettings() { export function NotificationSettings() {
const user = useUser() const user = useUser()
@ -17,6 +19,7 @@ export function NotificationSettings() {
const [emailNotificationSettings, setEmailNotificationSettings] = const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all') useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => { useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser) if (user) listenForPrivateUser(user.id, setPrivateUser)
@ -121,12 +124,20 @@ export function NotificationSettings() {
} }
function NotificationSettingLine(props: { function NotificationSettingLine(props: {
label: string label: string | React.ReactNode
highlight: boolean highlight: boolean
onClick?: () => void
}) { }) {
const { label, highlight } = props const { label, highlight, onClick } = props
return ( return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}> <Row
className={clsx(
'my-1 gap-1 text-gray-300',
highlight && '!text-black',
onClick ? 'cursor-pointer' : ''
)}
onClick={onClick}
>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />} {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label} {label}
</Row> </Row>
@ -148,31 +159,45 @@ export function NotificationSettings() {
toggleClassName={'w-24'} toggleClassName={'w-24'}
/> />
<div className={'mt-4 text-sm'}> <div className={'mt-4 text-sm'}>
<div> <Col className={''}>
<div className={''}> <Row className={'my-1'}>
You will receive notifications for: You will receive notifications for these general events:
<NotificationSettingLine </Row>
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine <NotificationSettingLine
highlight={notificationSettings !== 'none'} highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"} label={"Income & referral bonuses you've received"}
/> />
<Row className={'my-1'}>
You will receive new comment, answer, & resolution notifications on
questions:
</Row>
<NotificationSettingLine <NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"} highlight={notificationSettings !== 'none'}
highlight={notificationSettings === 'all'} label={
<span>
That <span className={'font-bold'}>you watch </span>- you
auto-watch questions if:
</span>
}
onClick={() => setShowModal(true)}
/> />
</div> <Col
</div> className={clsx(
'mb-2 ml-8',
'gap-1 text-gray-300',
notificationSettings !== 'none' && '!text-black'
)}
>
<Row> You create it</Row>
<Row> You bet, comment on, or answer it</Row>
<Row> You add liquidity to it</Row>
<Row>
If you select 'Less' and you've commented on or answered a
question, you'll only receive notification on direct replies to
your comments or answers
</Row>
</Col>
</Col>
</div> </div>
<div className={'mt-4'}>Email Notifications</div> <div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup <ChoicesToggleGroup
@ -205,6 +230,7 @@ export function NotificationSettings() {
/> />
</div> </div>
</div> </div>
<FollowMarketModal setOpen={setShowModal} open={showModal} />
</div> </div>
) )
} }

View File

@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useWindowSize } from 'web/hooks/use-window-size'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -33,7 +34,8 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(amount) const isInvalid = !str || isNaN(amount)
onChange(isInvalid ? undefined : amount) onChange(isInvalid ? undefined : amount)
} }
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group mb-4"> <label className="input-group mb-4">
@ -50,6 +52,7 @@ export function AmountInput(props: {
inputMode="numeric" inputMode="numeric"
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
autoFocus={!isMobile}
value={amount ?? ''} value={amount ?? ''}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}

View File

@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
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 { Col } from './layout/col' import { Col } from './layout/col'
import { Button } from 'web/components/button'
import { firebaseLogin } from 'web/lib/firebase/users'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -30,23 +32,27 @@ export default function BetButton(props: {
return ( return (
<> <>
<Col className={clsx('items-center', className)}> <Col className={clsx('items-center', className)}>
<button <Button
className={clsx( size={'lg'}
'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24', className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
btnClassName onClick={() => {
)} !user ? firebaseLogin() : setOpen(true)
onClick={() => setOpen(true)} }}
> >
Bet {user ? 'Bet' : 'Sign up to Bet'}
</button> </Button>
{user && (
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
{hasYesShares {hasYesShares
? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})` ? `(${Math.floor(yesShares)} ${
isPseudoNumeric ? 'HIGHER' : 'YES'
})`
: hasNoShares : hasNoShares
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
: ''} : ''}
</div> </div>
)}
</Col> </Col>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen}>

View File

@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
export function BetInline(props: { export function BetInline(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
className?: string className?: string
setProbAfter: (probAfter: number) => void setProbAfter: (probAfter: number | undefined) => void
onClose: () => void onClose: () => void
}) { }) {
const { contract, className, setProbAfter, onClose } = props const { contract, className, setProbAfter, onClose } = props
@ -82,7 +82,7 @@ export function BetInline(props: {
<div className="text-xl">Bet</div> <div className="text-xl">Bet</div>
<YesNoSelector <YesNoSelector
className="space-x-0" className="space-x-0"
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl" btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
selected={outcome} selected={outcome}
onSelect={setOutcome} onSelect={setOutcome}
isPseudoNumeric={isPseudoNumeric} isPseudoNumeric={isPseudoNumeric}
@ -113,7 +113,12 @@ export function BetInline(props: {
</Button> </Button>
)} )}
<SignUpPrompt size="xs" /> <SignUpPrompt size="xs" />
<button onClick={onClose}> <button
onClick={() => {
setProbAfter(undefined)
onClose()
}}
>
<XIcon className="ml-1 h-6 w-6" /> <XIcon className="ml-1 h-6 w-6" />
</button> </button>
</Row> </Row>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { clamp, partition, sum, sumBy } from 'lodash' import { clamp, partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
@ -9,7 +9,6 @@ import { Row } from './layout/row'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { import {
formatMoney, formatMoney,
formatMoneyWithDecimals,
formatPercent, formatPercent,
formatWithCommas, formatWithCommas,
} from 'common/util/format' } from 'common/util/format'
@ -18,7 +17,6 @@ import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet' import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api' import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input' import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { import {
BinaryOutcomeLabel, BinaryOutcomeLabel,
HigherLabel, HigherLabel,
@ -261,8 +259,6 @@ function BuyPanel(props: {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn) const currentReturnPercent = formatPercent(currentReturn)
const totalFees = sum(Object.values(newBet.fees))
const format = getFormattedMappedValue(contract) const format = getFormattedMappedValue(contract)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
@ -346,9 +342,9 @@ function BuyPanel(props: {
</> </>
)} )}
</div> </div>
<InfoTooltip {/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`} text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
/> /> */}
</Row> </Row>
<div> <div>
<span className="mr-2 whitespace-nowrap"> <span className="mr-2 whitespace-nowrap">
@ -665,9 +661,9 @@ function LimitOrderPanel(props: {
</> </>
)} )}
</div> </div>
<InfoTooltip {/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`} text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
/> /> */}
</Row> </Row>
<div> <div>
<span className="mr-2 whitespace-nowrap"> <span className="mr-2 whitespace-nowrap">
@ -689,9 +685,9 @@ function LimitOrderPanel(props: {
</> </>
)} )}
</div> </div>
<InfoTooltip {/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`} text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
/> /> */}
</Row> </Row>
<div> <div>
<span className="mr-2 whitespace-nowrap"> <span className="mr-2 whitespace-nowrap">

View File

@ -21,7 +21,7 @@ import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/antes' import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor' import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'

View File

@ -91,6 +91,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage?: boolean useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean useQuerySortUrlParams?: boolean
isWholePage?: boolean isWholePage?: boolean
maxItems?: number
noControls?: boolean
}) { }) {
const { const {
user, user,
@ -105,6 +107,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage, useQuerySortLocalStorage,
useQuerySortUrlParams, useQuerySortUrlParams,
isWholePage, isWholePage,
maxItems,
noControls,
} = props } = props
const [numPages, setNumPages] = useState(1) const [numPages, setNumPages] = useState(1)
@ -158,6 +162,8 @@ export function ContractSearch(props: {
const contracts = pages const contracts = pages
.flat() .flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts =
pages.length === 0 ? undefined : contracts.slice(0, maxItems)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} /> return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -175,10 +181,11 @@ export function ContractSearch(props: {
useQuerySortUrlParams={useQuerySortUrlParams} useQuerySortUrlParams={useQuerySortUrlParams}
user={user} user={user}
onSearchParametersChanged={onSearchParametersChanged} onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls}
/> />
<ContractsGrid <ContractsGrid
contracts={pages.length === 0 ? undefined : contracts} contracts={renderedContracts}
loadMore={performQuery} loadMore={noControls ? undefined : performQuery}
showTime={showTime} showTime={showTime}
onContractClick={onContractClick} onContractClick={onContractClick}
highlightOptions={highlightOptions} highlightOptions={highlightOptions}
@ -198,6 +205,7 @@ function ContractSearchControls(props: {
useQuerySortLocalStorage?: boolean useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean useQuerySortUrlParams?: boolean
user?: User | null user?: User | null
noControls?: boolean
}) { }) {
const { const {
className, className,
@ -209,6 +217,7 @@ function ContractSearchControls(props: {
useQuerySortLocalStorage, useQuerySortLocalStorage,
useQuerySortUrlParams, useQuerySortUrlParams,
user, user,
noControls,
} = props } = props
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
@ -329,6 +338,10 @@ function ContractSearchControls(props: {
}) })
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
if (noControls) {
return <></>
}
return ( return (
<Col <Col
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)} className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}

View File

@ -0,0 +1,9 @@
import { SparklesIcon } from '@heroicons/react/solid'
export function FeaturedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured
</span>
)
}

View File

@ -32,6 +32,8 @@ import { groupPath } from 'web/lib/firebase/groups'
import { insertContent } from '../editor/utils' import { insertContent } from '../editor/utils'
import clsx from 'clsx' import clsx from 'clsx'
import { contractMetrics } from 'common/contract-details' import { contractMetrics } from 'common/contract-details'
import { User } from 'common/user'
import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -72,6 +74,8 @@ export function MiscDetails(props: {
{'Resolved '} {'Resolved '}
{fromNow(resolutionTime || 0)} {fromNow(resolutionTime || 0)}
</Row> </Row>
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
<FeaturedContractBadge />
) : volume > 0 || !isNew ? ( ) : volume > 0 || !isNew ? (
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row> <Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
) : ( ) : (
@ -134,6 +138,7 @@ export function AbbrContractDetails(props: {
export function ContractDetails(props: { export function ContractDetails(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
user: User | null | undefined
isCreator?: boolean isCreator?: boolean
disabled?: boolean disabled?: boolean
}) { }) {
@ -157,7 +162,7 @@ export function ContractDetails(props: {
) )
return ( return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2">
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<Avatar <Avatar
username={creatorUsername} username={creatorUsername}
@ -179,6 +184,8 @@ export function ContractDetails(props: {
<Row> <Row>
{disabled ? ( {disabled ? (
groupInfo groupInfo
) : !groupToDisplay && !user ? (
<div />
) : ( ) : (
<Button <Button
size={'xs'} size={'xs'}
@ -206,10 +213,9 @@ export function ContractDetails(props: {
{(!!closeTime || !!resolvedDate) && ( {(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1"> <Row className="items-center gap-1">
<ClockIcon className="h-5 w-5" />
{resolvedDate && contract.resolutionTime ? ( {resolvedDate && contract.resolutionTime ? (
<> <>
<ClockIcon className="h-5 w-5" />
<DateTimeTooltip <DateTimeTooltip
text="Market resolved:" text="Market resolved:"
time={dayjs(contract.resolutionTime)} time={dayjs(contract.resolutionTime)}
@ -219,8 +225,9 @@ export function ContractDetails(props: {
</> </>
) : null} ) : null}
{!resolvedDate && closeTime && ( {!resolvedDate && closeTime && user && (
<> <>
<ClockIcon className="h-5 w-5" />
<EditableCloseDate <EditableCloseDate
closeTime={closeTime} closeTime={closeTime}
contract={contract} contract={contract}
@ -230,14 +237,15 @@ export function ContractDetails(props: {
)} )}
</Row> </Row>
)} )}
{user && (
<>
<Row className="items-center gap-1"> <Row className="items-center gap-1">
<DatabaseIcon className="h-5 w-5" /> <DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div> <div className="whitespace-nowrap">{volumeLabel}</div>
</Row> </Row>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />} {!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
</>
)}
</Row> </Row>
) )
} }

View File

@ -7,12 +7,16 @@ import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { contractPool } from 'web/lib/firebase/contracts' import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityPanel } from '../liquidity-panel' import { LiquidityPanel } from '../liquidity-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'
import { InfoTooltip } from '../info-tooltip' import { InfoTooltip } from '../info-tooltip'
import { useAdmin, useDev } from 'web/hooks/use-admin'
import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore'
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'
@ -21,10 +25,15 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props const { contract, bets } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [featured, setFeatured] = useState(
(contract?.featuredOnHomeRank ?? 0) > 0
)
const isDev = useDev()
const isAdmin = useAdmin()
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z') const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z')
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType } = const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
contract contract
const tradersCount = uniqBy( const tradersCount = uniqBy(
@ -105,10 +114,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<td>{formatMoney(contract.volume)}</td> <td>{formatMoney(contract.volume)}</td>
</tr> </tr>
<tr> {/* <tr>
<td>Creator earnings</td> <td>Creator earnings</td>
<td>{formatMoney(contract.collectedFees.creatorFee)}</td> <td>{formatMoney(contract.collectedFees.creatorFee)}</td>
</tr> </tr> */}
<tr> <tr>
<td>Traders</td> <td>Traders</td>
@ -121,6 +130,60 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</td> </td>
<td>{contractPool(contract)}</td> <td>{contractPool(contract)}</td>
</tr> </tr>
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
{(isAdmin || isDev) && (
<tr>
<td>[DEV] Firestore</td>
<td>
<SiteLink href={firestoreConsolePath(id)}>
Console link
</SiteLink>
</td>
</tr>
)}
{isAdmin && (
<tr>
<td>Set featured</td>
<td>
<select
className="select select-bordered"
value={featured ? 'true' : 'false'}
onChange={(e) => {
const newVal = e.target.value === 'true'
if (
newVal &&
(contract.featuredOnHomeRank === 0 ||
!contract?.featuredOnHomeRank)
)
updateContract(id, {
featuredOnHomeRank: 1,
})
.then(() => {
setFeatured(true)
})
.catch(console.error)
else if (
!newVal &&
(contract?.featuredOnHomeRank ?? 0) > 0
)
updateContract(id, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
featuredOnHomeRank: deleteField(),
})
.then(() => {
setFeatured(false)
})
.catch(console.error)
}}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? ( return users && users.length > 0 ? (
<Leaderboard <Leaderboard
title="🏅 Top traders" title="🏅 Top bettors"
users={users || []} users={users || []}
columns={[ columns={[
{ {

View File

@ -3,7 +3,6 @@ import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer'
import { ContractProbGraph } from './contract-prob-graph' import { ContractProbGraph } from './contract-prob-graph'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row' import { Row } from '../layout/row'
@ -39,15 +38,15 @@ export const ContractOverview = (props: {
return ( return (
<Col className={clsx('mb-6', className)}> <Col className={clsx('mb-6', className)}>
<Col className="gap-4 px-2"> <Col className="gap-3 px-2 sm:gap-4">
<Row className="justify-between gap-4"> <Row className="justify-between gap-4">
<div className="text-2xl text-indigo-700 md:text-3xl"> <div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={question} /> <Linkify text={question} />
</div> </div>
<Row className={'hidden gap-3 xl:flex'}>
{isBinary && ( {isBinary && (
<BinaryResolutionOrChance <BinaryResolutionOrChance
className="hidden items-end xl:flex" className="items-end"
contract={contract} contract={contract}
large large
/> />
@ -56,35 +55,46 @@ export const ContractOverview = (props: {
{isPseudoNumeric && ( {isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation <PseudoNumericResolutionOrExpectation
contract={contract} contract={contract}
className="hidden items-end xl:flex" className="items-end"
/> />
)} )}
{outcomeType === 'NUMERIC' && ( {outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation <NumericResolutionOrExpectation
contract={contract} contract={contract}
className="hidden items-end xl:flex" className="items-end"
/> />
)} )}
</Row> </Row>
</Row>
{isBinary ? ( {isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} /> <BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<Col>
<BetButton contract={contract as CPMMBinaryContract} /> <BetButton contract={contract as CPMMBinaryContract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)} )}
</Row> </Row>
) : isPseudoNumeric ? ( ) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} /> <PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetButton contract={contract} />} {tradingAllowed(contract) && (
</Row> <Col>
) : isPseudoNumeric ? ( <BetButton contract={contract} />
<Row className="items-center justify-between gap-4 xl:hidden"> {!user && (
<PseudoNumericResolutionOrExpectation contract={contract} /> <div className="mt-1 text-center text-sm text-gray-500">
{tradingAllowed(contract) && <BetButton contract={contract} />} (with play money!)
</div>
)}
</Col>
)}
</Row> </Row>
) : ( ) : (
(outcomeType === 'FREE_RESPONSE' || (outcomeType === 'FREE_RESPONSE' ||
@ -107,9 +117,10 @@ export const ContractOverview = (props: {
contract={contract} contract={contract}
bets={bets} bets={bets}
isCreator={isCreator} isCreator={isCreator}
user={user}
/> />
</Col> </Col>
<Spacer h={4} /> <div className={'my-1 md:my-2'}></div>
{(isBinary || isPseudoNumeric) && ( {(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} /> <ContractProbGraph contract={contract} bets={bets} />
)}{' '} )}{' '}

View File

@ -58,7 +58,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const { width } = useWindowSize() const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5 const numXTickValues = !width || width < 800 ? 2 : 5
const hoursAgo = latestTime.subtract(5, 'hours') const hoursAgo = latestTime.subtract(1, 'hours')
const startDate = dayjs(times[0]).isBefore(hoursAgo) const startDate = dayjs(times[0]).isBefore(hoursAgo)
? times[0] ? times[0]
: hoursAgo.toDate() : hoursAgo.toDate()

View File

@ -86,10 +86,12 @@ export function ContractsGrid(props: {
/> />
))} ))}
</Masonry> </Masonry>
{loadMore && (
<VisibilityObserver <VisibilityObserver
onVisibilityUpdated={onVisibilityUpdated} onVisibilityUpdated={onVisibilityUpdated}
className="relative -top-96 h-1" className="relative -top-96 h-1"
/> />
)}
</Col> </Col>
) )
} }

View File

@ -0,0 +1,40 @@
import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal'
import { EyeIcon } from '@heroicons/react/outline'
import React from 'react'
import clsx from 'clsx'
export const FollowMarketModal = (props: {
open: boolean
setOpen: (b: boolean) => void
title?: string
}) => {
const { open, setOpen, title } = props
return (
<Modal open={open} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<EyeIcon className={clsx('h-20 w-20')} aria-hidden="true" />
<span className="text-xl">{title ? title : 'Watching questions'}</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What is watching?</span>
<span className={'ml-2'}>
You can receive notifications on questions you're interested in by
clicking the
<EyeIcon
className={clsx('ml-1 inline h-6 w-6 align-top')}
aria-hidden="true"
/>
button on a question.
</span>
<span className={'text-indigo-700'}>
What types of notifications will I receive?
</span>
<span className={'ml-2'}>
You'll receive in-app notifications for new comments, answers, and
updates to the question.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -14,9 +14,10 @@ import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { REFERRAL_AMOUNT, User } from 'common/user' import { User } from 'common/user'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { REFERRAL_AMOUNT } from 'common/economy'
export function ShareModal(props: { export function ShareModal(props: {
contract: Contract contract: Contract

View File

@ -10,6 +10,7 @@ import { User } from 'common/user'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import { ShareModal } from './share-modal' import { ShareModal } from './share-modal'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { FollowMarketButton } from 'web/components/follow-market-button'
export function ShareRow(props: { export function ShareRow(props: {
contract: Contract contract: Contract
@ -25,7 +26,7 @@ export function ShareRow(props: {
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
return ( return (
<Row className="mt-2"> <Row className="mt-0.5 sm:mt-2">
<Button <Button
size="lg" size="lg"
color="gray-white" color="gray-white"
@ -62,6 +63,7 @@ export function ShareRow(props: {
/> />
</Button> </Button>
)} )}
<FollowMarketButton contract={contract} user={user} />
</Row> </Row>
) )
} }

View File

@ -1,9 +1,7 @@
import { DuplicateIcon } from '@heroicons/react/outline' import { DuplicateIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { ENV_CONFIG } from 'common/envs/constants'
import { getMappedValue } from 'common/pseudo-numeric' import { getMappedValue } from 'common/pseudo-numeric'
import { contractPath } from 'web/lib/firebase/contracts'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
export function DuplicateContractButton(props: { export function DuplicateContractButton(props: {
@ -33,22 +31,29 @@ export function DuplicateContractButton(props: {
// Pass along the Uri to create a new contract // Pass along the Uri to create a new contract
function duplicateContractHref(contract: Contract) { function duplicateContractHref(contract: Contract) {
const descriptionString = JSON.stringify(contract.description)
// Don't set a closeTime that's in the past
const closeTime =
(contract?.closeTime ?? 0) <= Date.now() ? 0 : contract.closeTime
const params = { const params = {
q: contract.question, q: contract.question,
closeTime: contract.closeTime || 0, closeTime,
description: description: descriptionString,
(contract.description ? `${contract.description}\n\n` : '') +
`(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`,
outcomeType: contract.outcomeType, outcomeType: contract.outcomeType,
} as Record<string, any> } as Record<string, any>
if (contract.outcomeType === 'PSEUDO_NUMERIC') { if (contract.outcomeType === 'PSEUDO_NUMERIC') {
params.min = contract.min params.min = contract.min
params.max = contract.max params.max = contract.max
params.isLogScale = contract.isLogScale if (contract.isLogScale) {
// Conditional, because `?isLogScale=false` evaluates to `true`
params.isLogScale = true
}
params.initValue = getMappedValue(contract)(contract.initialProbability) params.initValue = getMappedValue(contract)(contract.initialProbability)
} }
// TODO: Support multiple choice markets?
if (contract.groupLinks && contract.groupLinks.length > 0) { if (contract.groupLinks && contract.groupLinks.length > 0) {
params.groupId = contract.groupLinks[0].groupId params.groupId = contract.groupLinks[0].groupId
} }

View File

@ -6,6 +6,7 @@ import {
JSONContent, JSONContent,
Content, Content,
Editor, Editor,
mergeAttributes,
} 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'
@ -38,7 +39,16 @@ const DisplayImage = Image.configure({
}, },
}) })
const DisplayLink = Link.configure({ const DisplayLink = Link.extend({
renderHTML({ HTMLAttributes }) {
delete HTMLAttributes.class // only use our classes (don't duplicate on paste)
return [
'a',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
]
},
}).configure({
HTMLAttributes: { HTMLAttributes: {
class: clsx('no-underline !text-indigo-700', linkClass), class: clsx('no-underline !text-indigo-700', linkClass),
}, },

View File

@ -0,0 +1,77 @@
import { Button } from 'web/components/button'
import {
Contract,
followContract,
unFollowContract,
} from 'web/lib/firebase/contracts'
import toast from 'react-hot-toast'
import { CheckIcon, EyeIcon, EyeOffIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { User } from 'common/user'
import { useContractFollows } from 'web/hooks/use-follows'
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
import { useState } from 'react'
import { Row } from 'web/components/layout/row'
export const FollowMarketButton = (props: {
contract: Contract
user: User | undefined | null
}) => {
const { contract, user } = props
const followers = useContractFollows(contract.id)
const [open, setOpen] = useState(false)
return (
<Button
size={'lg'}
color={'gray-white'}
onClick={async () => {
if (!user) return firebaseLogin()
if (followers?.includes(user.id)) {
await unFollowContract(contract.id, user.id)
toast("You'll no longer receive notifications from this market", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Unwatch Market', {
slug: contract.slug,
})
} else {
await followContract(contract.id, user.id)
toast("You'll now receive notifications from this market!", {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
track('Watch Market', {
slug: contract.slug,
})
}
if (!user.hasSeenContractFollowModal) {
await updateUser(user.id, {
hasSeenContractFollowModal: true,
})
setOpen(true)
}
}}
>
{followers?.includes(user?.id ?? 'nope') ? (
<Row className={'gap-2'}>
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Unwatch
</Row>
) : (
<Row className={'gap-2'}>
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
Watch
</Row>
)}
<FollowMarketModal
open={open}
setOpen={setOpen}
title={`You ${
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
} a question!`}
/>
</Button>
)
}

View File

@ -163,13 +163,15 @@ export function OrderBookButton(props: {
const { limitBets, contract, className } = props const { limitBets, contract, className } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const yesBets = sortBy( const sortedBets = sortBy(
limitBets.filter((bet) => bet.outcome === 'YES'), limitBets,
(bet) => -1 * bet.limitProb, (bet) => -1 * bet.limitProb,
(bet) => bet.createdTime (bet) => bet.createdTime
) )
const yesBets = sortedBets.filter((bet) => bet.outcome === 'YES')
const noBets = sortBy( const noBets = sortBy(
limitBets.filter((bet) => bet.outcome === 'NO'), sortedBets.filter((bet) => bet.outcome === 'NO'),
(bet) => bet.limitProb, (bet) => bet.limitProb,
(bet) => bet.createdTime (bet) => bet.createdTime
) )
@ -202,7 +204,7 @@ export function OrderBookButton(props: {
</Row> </Row>
<Col className="md:hidden"> <Col className="md:hidden">
<LimitOrderTable <LimitOrderTable
limitBets={limitBets} limitBets={sortedBets}
contract={contract} contract={contract}
isYou={false} isYou={false}
/> />

View File

@ -9,7 +9,7 @@ import {
import { Transition, Dialog } from '@headlessui/react' import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react' import { useState, Fragment } from 'react'
import Sidebar, { Item } from './sidebar' import Sidebar, { Item } from './sidebar'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import clsx from 'clsx' import clsx from 'clsx'
@ -17,8 +17,6 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
function getNavigation() { function getNavigation() {
return [ return [
@ -44,7 +42,6 @@ export function BottomNavBar() {
const currentPage = router.pathname const currentPage = router.pathname
const user = useUser() const user = useUser()
const privateUser = usePrivateUser()
const isIframe = useIsIframe() const isIframe = useIsIframe()
if (isIframe) { if (isIframe) {
@ -85,11 +82,7 @@ export function BottomNavBar() {
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" /> <MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
{privateUser ? ( More
<MoreMenuWithGroupNotifications privateUser={privateUser} />
) : (
'More'
)}
</div> </div>
<MobileSidebar <MobileSidebar
@ -100,22 +93,6 @@ export function BottomNavBar() {
) )
} }
function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) {
const { privateUser } = props
const preferredNotifications = useUnseenPreferredNotifications(privateUser, {
customHref: '/group/',
})
return (
<span
className={
preferredNotifications.length > 0 ? 'font-bold' : 'font-normal'
}
>
More
</span>
)
}
function NavBarItem(props: { item: Item; currentPage: string }) { function NavBarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props const { item, currentPage } = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)

View File

@ -12,22 +12,20 @@ import {
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import Router, { useRouter } from 'next/router' import Router, { useRouter } from 'next/router'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users' import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo' import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu' import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import React, { useMemo, useState } from 'react' import React, { useState } from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button' import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics' import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Group } from 'common/group'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
@ -58,7 +56,14 @@ function getNavigation() {
function getMoreNavigation(user?: User | null) { function getMoreNavigation(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) { if (IS_PRIVATE_MANIFOLD) {
return [{ name: 'Leaderboards', href: '/leaderboards' }] return [
{ name: 'Leaderboards', href: '/leaderboards' },
{
name: 'Sign out',
href: '#',
onClick: logout,
},
]
} }
if (!user) { if (!user) {
@ -88,7 +93,7 @@ function getMoreNavigation(user?: User | null) {
href: 'https://salemcenter.manifold.markets/', href: 'https://salemcenter.manifold.markets/',
}, },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' }, { name: 'Help & About', href: 'https://help.manifold.markets/' },
{ {
name: 'Sign out', name: 'Sign out',
href: '#', href: '#',
@ -102,16 +107,16 @@ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon }, { name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon }, { name: 'Explore', href: '/home', icon: SearchIcon },
{ {
name: 'About', name: 'Help & About',
href: 'https://docs.manifold.markets/$how-to', href: 'https://help.manifold.markets/',
icon: BookOpenIcon, icon: BookOpenIcon,
}, },
] ]
const signedOutMobileNavigation = [ const signedOutMobileNavigation = [
{ {
name: 'About', name: 'Help & About',
href: 'https://docs.manifold.markets/$how-to', href: 'https://help.manifold.markets/',
icon: BookOpenIcon, icon: BookOpenIcon,
}, },
{ name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Charity', href: '/charity', icon: HeartIcon },
@ -125,8 +130,8 @@ const signedInMobileNavigation = [
? [] ? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
{ {
name: 'About', name: 'Help & About',
href: 'https://docs.manifold.markets/$how-to', href: 'https://help.manifold.markets/',
icon: BookOpenIcon, icon: BookOpenIcon,
}, },
] ]
@ -221,8 +226,6 @@ export default function Sidebar(props: { className?: string }) {
const currentPage = router.pathname const currentPage = router.pathname
const user = useUser() const user = useUser()
const privateUser = usePrivateUser()
// usePing(user?.id)
const navigationOptions = !user ? signedOutNavigation : getNavigation() const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user const mobileNavigationOptions = !user
@ -230,11 +233,9 @@ export default function Sidebar(props: { className?: string }) {
: signedInMobileNavigation : signedInMobileNavigation
const memberItems = ( const memberItems = (
useMemberGroups( useMemberGroups(user?.id, undefined, {
user?.id, by: 'mostRecentContractAddedTime',
{ withChatEnabled: true }, }) ?? []
{ by: 'mostRecentChatActivityTime' }
) ?? []
).map((group: Group) => ({ ).map((group: Group) => ({
name: group.name, name: group.name,
href: `${groupPath(group.slug)}`, href: `${groupPath(group.slug)}`,
@ -268,13 +269,7 @@ export default function Sidebar(props: { className?: string }) {
{memberItems.length > 0 && ( {memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" /> <hr className="!my-4 mr-2 border-gray-300" />
)} )}
{privateUser && ( <GroupsList currentPage={router.asPath} memberItems={memberItems} />
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
privateUser={privateUser}
/>
)}
</div> </div>
{/* Desktop navigation */} {/* Desktop navigation */}
@ -289,46 +284,36 @@ export default function Sidebar(props: { className?: string }) {
{/* Spacer if there are any groups */} {/* Spacer if there are any groups */}
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />} {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
{privateUser && ( <GroupsList currentPage={router.asPath} memberItems={memberItems} />
<GroupsList
currentPage={router.asPath}
memberItems={memberItems}
privateUser={privateUser}
/>
)}
</div> </div>
</nav> </nav>
) )
} }
function GroupsList(props: { function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
currentPage: string const { currentPage, memberItems } = props
memberItems: Item[]
privateUser: PrivateUser
}) {
const { currentPage, memberItems, privateUser } = props
const preferredNotifications = useUnseenPreferredNotifications(
privateUser,
{
customHref: '/group/',
},
memberItems.length > 0 ? memberItems.length : undefined
)
const { height } = useWindowSize() const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0) const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
const notifIsForThisItem = useMemo( // const preferredNotifications = useUnseenPreferredNotifications(
() => (itemHref: string) => // privateUser,
preferredNotifications.some( // {
(n) => // customHref: '/group/',
!n.isSeen && // },
(n.isSeenOnHref === itemHref || // memberItems.length > 0 ? memberItems.length : undefined
n.isSeenOnHref?.replace('/chat', '') === itemHref) // )
), // const notifIsForThisItem = useMemo(
[preferredNotifications] // () => (itemHref: string) =>
) // preferredNotifications.some(
// (n) =>
// !n.isSeen &&
// (n.isSeenOnHref === itemHref ||
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
// ),
// [preferredNotifications]
// )
return ( return (
<> <>
@ -344,16 +329,12 @@ function GroupsList(props: {
> >
{memberItems.map((item) => ( {memberItems.map((item) => (
<a <a
href={ href={item.href}
item.href +
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
}
key={item.name} key={item.name}
onClick={trackCallback('sidebar: ' + item.name)} onClick={trackCallback('click sidebar group', { name: item.name })}
className={clsx( className={clsx(
'cursor-pointer truncate', 'cursor-pointer truncate',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900'
notifIsForThisItem(item.href) && 'font-bold'
)} )}
> >
{item.name} {item.name}

View File

@ -14,7 +14,7 @@ export const PortfolioValueSection = memo(
}) { }) {
const { disableSelector, userId } = props const { disableSelector, userId } = props
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
const [portfolioHistory, setUsersPortfolioHistory] = useState< const [portfolioHistory, setUsersPortfolioHistory] = useState<
PortfolioMetrics[] PortfolioMetrics[]
>([]) >([])
@ -53,13 +53,15 @@ export const PortfolioValueSection = memo(
{!disableSelector && ( {!disableSelector && (
<select <select
className="select select-bordered self-start" className="select select-bordered self-start"
value={portfolioPeriod}
onChange={(e) => { onChange={(e) => {
setPortfolioPeriod(e.target.value as Period) setPortfolioPeriod(e.target.value as Period)
}} }}
> >
<option value="allTime">{allTimeLabel}</option> <option value="allTime">{allTimeLabel}</option>
<option value="weekly">7 days</option> <option value="weekly">Last 7d</option>
<option value="daily">24 hours</option> {/* Note: 'daily' seems to be broken? */}
{/* <option value="daily">Last 24h</option> */}
</select> </select>
)} )}
</Row> </Row>

View File

@ -3,7 +3,7 @@ import { Col } from 'web/components/layout/col'
import { import {
BETTING_STREAK_BONUS_AMOUNT, BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_BONUS_MAX, BETTING_STREAK_BONUS_MAX,
} from 'common/numeric-constants' } from 'common/economy'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
export function BettingStreakModal(props: { export function BettingStreakModal(props: {
@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🔥</span> <span className={'text-8xl'}>🔥</span>
<span>Daily betting streaks</span> <span className="text-xl">Daily betting streaks</span>
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are they?</span> <span className={'text-indigo-700'}> What are they?</span>
<span className={'ml-2'}> <span className={'ml-2'}>

View File

@ -0,0 +1,48 @@
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
export function LoansModal(props: {
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { isOpen, setOpen } = props
return (
<Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span>
<span className="text-xl">Daily loans on your bets</span>
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}>
Every day at midnight PT, get 1% of your total bet amount back as a
loan.
</span>
<span className={'text-indigo-700'}>
Do I have to pay back a loan?
</span>
<span className={'ml-2'}>
Yes, don't worry! You will automatically pay back loans when the
market resolves or you sell your bet.
</span>
<span className={'text-indigo-700'}>
What is the purpose of loans?
</span>
<span className={'ml-2'}>
Loans make it worthwhile to bet on markets that won't resolve for
months or years, because your investment won't be locked up as long.
</span>
<span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}>
For example, if you bet M$1000 on "Will I become a millionare?" on
Monday, you will get M$10 back on Tuesday.
</span>
<span className={'ml-2'}>
Previous loans count against your total bet amount. So on Wednesday,
you would get back 1% of M$990 = M$9.9.
</span>
</Col>
</Col>
</Modal>
)
}

View File

@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button' import { ResolveConfirmationButton } from './confirmation-button'
import { APIError, resolveMarket } from 'web/lib/firebase/api' import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { ProbabilitySelector } from './probability-selector' import { ProbabilitySelector } from './probability-selector'
import { DPM_CREATOR_FEE } from 'common/fees'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { BinaryContract, resolution } from 'common/contract' import { BinaryContract, resolution } from 'common/contract'
import { formatMoney } from 'common/util/format'
export function ResolutionPanel(props: { export function ResolutionPanel(props: {
creator: User creator: User
@ -20,10 +18,10 @@ export function ResolutionPanel(props: {
}) { }) {
const { contract, className } = props const { contract, className } = props
const earnedFees = // const earnedFees =
contract.mechanism === 'dpm-2' // contract.mechanism === 'dpm-2'
? `${DPM_CREATOR_FEE * 100}% of trader profits` // ? `${DPM_CREATOR_FEE * 100}% of trader profits`
: `${formatMoney(contract.collectedFees.creatorFee)} in fees` // : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
const [outcome, setOutcome] = useState<resolution | undefined>() const [outcome, setOutcome] = useState<resolution | undefined>()
@ -86,16 +84,16 @@ export function ResolutionPanel(props: {
{outcome === 'YES' ? ( {outcome === 'YES' ? (
<> <>
Winnings will be paid out to YES bettors. Winnings will be paid out to YES bettors.
{/* <br />
<br /> <br />
<br /> You will earn {earnedFees}. */}
You will earn {earnedFees}.
</> </>
) : outcome === 'NO' ? ( ) : outcome === 'NO' ? (
<> <>
Winnings will be paid out to NO bettors. Winnings will be paid out to NO bettors.
{/* <br />
<br /> <br />
<br /> You will earn {earnedFees}. */}
You will earn {earnedFees}.
</> </>
) : outcome === 'CANCEL' ? ( ) : outcome === 'CANCEL' ? (
<>All trades will be returned with no fees.</> <>All trades will be returned with no fees.</>
@ -106,7 +104,7 @@ export function ResolutionPanel(props: {
probabilityInt={Math.round(prob)} probabilityInt={Math.round(prob)}
setProbabilityInt={setProb} setProbabilityInt={setProb}
/> />
You will earn {earnedFees}. {/* You will earn {earnedFees}. */}
</Col> </Col>
) : ( ) : (
<>Resolving this market will immediately pay out traders.</> <>Resolving this market will immediately pay out traders.</>

View File

@ -29,6 +29,8 @@ import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button' import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
import { REFERRAL_AMOUNT } from 'common/economy'
import { LoansModal } from './profile/loans-modal'
export function UserLink(props: { export function UserLink(props: {
name: string name: string
@ -67,6 +69,7 @@ export function UserPage(props: { user: User }) {
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
const [showLoansModal, setShowLoansModal] = useState(false)
useEffect(() => { useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes' const claimedMana = router.query['claimed-mana'] === 'yes'
@ -74,6 +77,9 @@ export function UserPage(props: { user: User }) {
setShowBettingStreakModal(showBettingStreak) setShowBettingStreakModal(showBettingStreak)
setShowConfetti(claimedMana || showBettingStreak) setShowConfetti(claimedMana || showBettingStreak)
const showLoansModel = router.query['show'] === 'loans'
setShowLoansModal(showLoansModel)
const query = { ...router.query } const query = { ...router.query }
if (query.claimedMana || query.show) { if (query.claimedMana || query.show) {
delete query['claimed-mana'] delete query['claimed-mana']
@ -106,6 +112,9 @@ export function UserPage(props: { user: User }) {
isOpen={showBettingStreakModal} isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal} setOpen={setShowBettingStreakModal}
/> />
{showLoansModal && (
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
)}
{/* Banner image up top, with an circle avatar overlaid */} {/* Banner image up top, with an circle avatar overlaid */}
<div <div
className="h-32 w-full bg-cover bg-center sm:h-40" className="h-32 w-full bg-cover bg-center sm:h-40"
@ -127,7 +136,7 @@ export function UserPage(props: { user: User }) {
<div className="absolute right-0 top-0 mt-2 mr-4"> <div className="absolute right-0 top-0 mt-2 mr-4">
{!isCurrentUser && <UserFollowButton userId={user.id} />} {!isCurrentUser && <UserFollowButton userId={user.id} />}
{isCurrentUser && ( {isCurrentUser && (
<SiteLink className="sm:btn-md btn-sm btn" href="/profile"> <SiteLink className="btn-sm btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '} <PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div> <div className="ml-2">Edit</div>
</SiteLink> </SiteLink>
@ -137,9 +146,14 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */} {/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6"> <Col className="mx-4 -mt-6">
<Row className={'justify-between'}> <Row className={'flex-wrap justify-between gap-y-2'}>
<Col> <Col>
<span className="text-2xl font-bold">{user.name}</span> <span
className="text-2xl font-bold"
style={{ wordBreak: 'break-word' }}
>
{user.name}
</span>
<span className="text-gray-500">@{user.username}</span> <span className="text-gray-500">@{user.username}</span>
</Col> </Col>
<Col className={'justify-center'}> <Col className={'justify-center'}>
@ -162,6 +176,17 @@ export function UserPage(props: { user: User }) {
<span>🔥 {user.currentBettingStreak ?? 0}</span> <span>🔥 {user.currentBettingStreak ?? 0}</span>
<span>streak</span> <span>streak</span>
</Col> </Col>
<Col
className={
'flex-shrink-0 cursor-pointer items-center text-gray-500'
}
onClick={() => setShowLoansModal(true)}
>
<span className="text-green-600">
🏦 {formatMoney(user.nextLoanCached ?? 0)}
</span>
<span>next loan</span>
</Col>
</Row> </Row>
</Col> </Col>
</Row> </Row>
@ -226,7 +251,7 @@ export function UserPage(props: { user: User }) {
)} )}
</Row> </Row>
<Spacer h={5} /> <Spacer h={5} />
{currentUser?.id === user.id && ( {currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
<Row <Row
className={ className={
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600' 'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
@ -234,7 +259,7 @@ export function UserPage(props: { user: User }) {
> >
<span> <span>
<SiteLink href="/referrals"> <SiteLink href="/referrals">
Earn {formatMoney(500)} when you refer a friend! Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
</SiteLink>{' '} </SiteLink>{' '}
You have <ReferralsButton user={user} currentUser={currentUser} /> You have <ReferralsButton user={user} currentUser={currentUser} />
</span> </span>
@ -278,10 +303,7 @@ export function UserPage(props: { user: User }) {
> >
<FollowingButton user={user} /> <FollowingButton user={user} />
<FollowersButton user={user} /> <FollowersButton user={user} />
{currentUser && <ReferralsButton user={user} />
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
currentUser.username
) && <ReferralsButton user={user} />}
<GroupsButton user={user} /> <GroupsButton user={user} />
</Row> </Row>
), ),

View File

@ -5,3 +5,7 @@ export const useAdmin = () => {
const privateUser = usePrivateUser() const privateUser = usePrivateUser()
return isAdmin(privateUser?.email || '') return isAdmin(privateUser?.email || '')
} }
export const useDev = () => {
return process.env.NODE_ENV === 'development'
}

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
export const useFollows = (userId: string | null | undefined) => { export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>() const [followIds, setFollowIds] = useState<string[] | undefined>()
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
return followerIds return followerIds
} }
export const useContractFollows = (contractId: string) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
useEffect(() => {
return listenForContractFollows(contractId, setFollowIds)
}, [contractId])
return followIds
}

View File

@ -5,7 +5,7 @@ import {
getNotificationsQuery, getNotificationsQuery,
listenForNotifications, listenForNotifications,
} from 'web/lib/firebase/notifications' } from 'web/lib/firebase/notifications'
import { groupBy, map } from 'lodash' import { groupBy, map, partition } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroupsByDay = groupBy(notifications, (notification) => const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString() new Date(notification.createdTime).toDateString()
) )
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
Object.keys(notificationGroupsByDay).forEach((day) => { Object.keys(notificationGroupsByDay).forEach((day) => {
const notificationsGroupedByDay = notificationGroupsByDay[day] const notificationsGroupedByDay = notificationGroupsByDay[day]
const incomeNotifications = notificationsGroupedByDay.filter( const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
notificationsGroupedByDay,
(notification) => (notification) =>
notification.sourceType === 'bonus' || incomeSourceTypes.includes(notification.sourceType ?? '')
notification.sourceType === 'tip' ||
notification.sourceType === 'betting_streak_bonus'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
(notification) =>
notification.sourceType !== 'bonus' &&
notification.sourceType !== 'tip' &&
notification.sourceType !== 'betting_streak_bonus'
) )
if (incomeNotifications.length > 0) { if (incomeNotifications.length > 0) {
notificationGroups = notificationGroups.concat({ notificationGroups = notificationGroups.concat({
@ -152,6 +147,7 @@ export function useUnseenPreferredNotifications(
const lessPriorityReasons = [ const lessPriorityReasons = [
'on_contract_with_users_comment', 'on_contract_with_users_comment',
'on_contract_with_users_answer', 'on_contract_with_users_answer',
// Notifications not currently generated for users who've sold their shares
'on_contract_with_users_shares_out', 'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less: // Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in', // 'on_contract_with_users_shares_in',

View File

@ -125,9 +125,10 @@ export async function listTaggedContractsCaseInsensitive(
export async function listAllContracts( export async function listAllContracts(
n: number, n: number,
before?: string before?: string,
sortDescBy = 'createdTime'
): Promise<Contract[]> { ): Promise<Contract[]> {
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n)) let q = query(contracts, orderBy(sortDescBy, 'desc'), limit(n))
if (before != null) { if (before != null) {
const snap = await getDoc(doc(contracts, before)) const snap = await getDoc(doc(contracts, before))
q = query(q, startAfter(snap)) q = query(q, startAfter(snap))
@ -211,6 +212,29 @@ export function listenForContract(
return listenForValue<Contract>(contractRef, setContract) return listenForValue<Contract>(contractRef, setContract)
} }
export function listenForContractFollows(
contractId: string,
setFollowIds: (followIds: string[]) => void
) {
const follows = collection(contracts, contractId, 'follows')
return listenForValues<{ id: string }>(follows, (docs) =>
setFollowIds(docs.map(({ id }) => id))
)
}
export async function followContract(contractId: string, userId: string) {
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
return await setDoc(followDoc, {
id: userId,
createdTime: Date.now(),
})
}
export async function unFollowContract(contractId: string, userId: string) {
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
await deleteDoc(followDoc)
}
function chooseRandomSubset(contracts: Contract[], count: number) { function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000 const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString() const seed = Math.round(Date.now() / fiveMinutes).toString()
@ -271,6 +295,26 @@ export async function getClosingSoonContracts() {
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime) return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
} }
export const getRandTopCreatorContracts = async (
creatorId: string,
count: number,
excluding: string[] = []
) => {
const creatorContractsQuery = query(
contracts,
where('isResolved', '==', false),
where('creatorId', '==', creatorId),
orderBy('popularityScore', 'desc'),
limit(Math.max(count * 2, 15))
)
const data = await getValues<Contract>(creatorContractsQuery)
const open = data
.filter((c) => c.closeTime && c.closeTime > Date.now())
.filter((c) => !excluding.includes(c.id))
return chooseRandomSubset(open, count)
}
export async function getRecentBetsAndComments(contract: Contract) { export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(contracts, contract.id) const contractDoc = doc(contracts, contract.id)

View File

@ -1,15 +0,0 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.SITE_URL || 'https://manifold.markets',
changefreq: 'hourly',
priority: 0.7, // Set high priority by default
exclude: ['/admin', '/server-sitemap.xml'],
generateRobotsTxt: true,
robotsTxtOptions: {
additionalSitemaps: [
'https://manifold.markets/server-sitemap.xml', // <==== Add here
],
},
// Other options: https://github.com/iamvishnusankar/next-sitemap#configuration-options
}

View File

@ -15,7 +15,6 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"format": "npx prettier --write .", "format": "npx prettier --write .",
"postbuild": "next-sitemap",
"verify": "(cd .. && yarn verify)", "verify": "(cd .. && yarn verify)",
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit" "verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
}, },

View File

@ -1,17 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline' import { ArrowLeftIcon } from '@heroicons/react/outline'
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractOverview } from 'web/components/contract/contract-overview' import { ContractOverview } from 'web/components/contract/contract-overview'
import { BetPanel } from 'web/components/bet-panel' import { BetPanel } from 'web/components/bet-panel'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser, useUserById } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel' import { ResolutionPanel } from 'web/components/resolution-panel'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { import {
Contract, Contract,
getContractFromSlug, getContractFromSlug,
getRandTopCreatorContracts,
tradingAllowed, tradingAllowed,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404' import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel' import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Leaderboard } from 'web/components/leaderboard'
import { resolvedPayout } from 'common/calculate'
import { formatMoney } from 'common/util/format'
import { ContractTabs } from 'web/components/contract/contract-tabs' import { ContractTabs } from 'web/components/contract/contract-tabs'
import { FullscreenConfetti } from 'web/components/fullscreen-confetti' import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
import { NumericBetPanel } from 'web/components/numeric-bet-panel' import { NumericBetPanel } from 'web/components/numeric-bet-panel'
@ -34,15 +31,17 @@ import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box' import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user' import { User } from 'common/user'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { listUsers } from 'web/lib/firebase/users'
import { FeedComment } from 'web/components/feed/feed-comments'
import { Title } from 'web/components/title'
import { FeedBet } from 'web/components/feed/feed-bets'
import { getOpenGraphProps } from 'common/contract-details' import { getOpenGraphProps } from 'common/contract-details'
import {
ContractLeaderboard,
ContractTopTrades,
} from 'web/components/contract/contract-leaderboard'
import { Subtitle } from 'web/components/subtitle'
import { ContractsGrid } from 'web/components/contract/contracts-grid'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
const contract = (await getContractFromSlug(contractSlug)) || null const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id const contractId = contract?.id
const [bets, comments] = await Promise.all([ const [bets, comments, recommendedContracts] = await Promise.all([
contractId ? listAllBets(contractId) : [], contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [], contractId ? listAllComments(contractId) : [],
contract
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
: [],
]) ])
return { return {
@ -65,6 +67,7 @@ export async function getStaticPropz(props: {
// Limit the data sent to the client. Client will still load all bets and comments directly. // Limit the data sent to the client. Client will still load all bets and comments directly.
bets: bets.slice(0, 5000), bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000), comments: comments.slice(0, 1000),
recommendedContracts,
}, },
revalidate: 60, // regenerate after a minute revalidate: 60, // regenerate after a minute
@ -81,6 +84,7 @@ export default function ContractPage(props: {
bets: Bet[] bets: Bet[]
comments: ContractComment[] comments: ContractComment[]
slug: string slug: string
recommendedContracts: Contract[]
backToHome?: () => void backToHome?: () => void
}) { }) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
@ -88,6 +92,7 @@ export default function ContractPage(props: {
username: '', username: '',
comments: [], comments: [],
bets: [], bets: [],
recommendedContracts: [],
slug: '', slug: '',
} }
@ -149,7 +154,7 @@ export function ContractPageContent(
user?: User | null user?: User | null
} }
) { ) {
const { backToHome, comments, user } = props const { backToHome, comments, user, recommendedContracts } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
@ -263,128 +268,13 @@ export function ContractPageContent(
comments={comments} comments={comments}
/> />
</Col> </Col>
{recommendedContracts.length > 0 && (
<Col className="gap-2 px-2 sm:px-0">
<Subtitle text="Recommended" />
<ContractsGrid contracts={recommendedContracts} />
</Col>
)}
</Page> </Page>
) )
} }
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [userProfits, top5Ids])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top bettors"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}

View File

@ -10,6 +10,7 @@ import { mapKeys } from 'lodash'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { firestoreConsolePath } from 'common/envs/constants'
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')
@ -198,7 +199,7 @@ function ContractsTable() {
html(`<a html(`<a
class="hover:underline hover:decoration-indigo-400 hover:decoration-2" class="hover:underline hover:decoration-indigo-400 hover:decoration-2"
target="_blank" target="_blank"
href="https://console.firebase.google.com/project/mantic-markets/firestore/data/~2Fcontracts~2F${cell}">${cell}</a>`), href="${firestoreConsolePath(cell as string)}">${cell}</a>`),
}, },
]} ]}
search={true} search={true}

View File

@ -22,8 +22,6 @@ export type LiteMarket = {
// Market attributes. All times are in milliseconds since epoch // Market attributes. All times are in milliseconds since epoch
closeTime?: number closeTime?: number
question: string question: string
description: string | JSONContent
textDescription: string // string version of description
tags: string[] tags: string[]
url: string url: string
outcomeType: string outcomeType: string
@ -54,6 +52,8 @@ export type FullMarket = LiteMarket & {
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
answers?: ApiAnswer[] answers?: ApiAnswer[]
description: string | JSONContent
textDescription: string // string version of description
} }
export type ApiError = { export type ApiError = {
@ -81,7 +81,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
creatorAvatarUrl, creatorAvatarUrl,
closeTime, closeTime,
question, question,
description,
tags, tags,
slug, slug,
pool, pool,
@ -118,11 +117,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
? Math.min(resolutionTime, closeTime) ? Math.min(resolutionTime, closeTime)
: closeTime, : closeTime,
question, question,
description,
textDescription:
typeof description === 'string'
? description
: richTextToString(description),
tags, tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`, url: `https://manifold.markets/${creatorUsername}/${slug}`,
pool, pool,
@ -158,11 +152,18 @@ export function toFullMarket(
) )
: undefined : undefined
const { description } = contract
return { return {
...liteMarket, ...liteMarket,
answers, answers,
comments, comments,
bets, bets,
description,
textDescription:
typeof description === 'string'
? description
: richTextToString(description),
} }
} }

View File

@ -10,7 +10,7 @@ const queryParams = z
.object({ .object({
limit: z limit: z
.number() .number()
.default(1000) .default(500)
.or(z.string().regex(/\d+/).transform(Number)) .or(z.string().regex(/\d+/).transform(Number))
.refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'), .refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'),
before: z.string().optional(), before: z.string().optional(),

View File

@ -39,8 +39,8 @@ export async function getStaticProps() {
]) ])
const matches = quadraticMatches(txns, totalRaised) const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length const numDonors = uniqBy(txns, (txn) => txn.fromId).length
const mostRecentDonor = await getUser(txns[0].fromId) const mostRecentDonor = txns[0] ? await getUser(txns[0].fromId) : null
const mostRecentCharity = txns[0].toId const mostRecentCharity = txns[0]?.toId ?? ''
return { return {
props: { props: {
@ -94,8 +94,8 @@ export default function Charity(props: {
matches: { [charityId: string]: number } matches: { [charityId: string]: number }
txns: Txn[] txns: Txn[]
numDonors: number numDonors: number
mostRecentDonor: User mostRecentDonor?: User | null
mostRecentCharity: string mostRecentCharity?: string
}) { }) {
const { const {
totalRaised, totalRaised,
@ -159,8 +159,8 @@ export default function Charity(props: {
}, },
{ {
name: 'Most recent donor', name: 'Most recent donor',
stat: mostRecentDonor.name ?? 'Nobody', stat: mostRecentDonor?.name ?? 'Nobody',
url: `/${mostRecentDonor.username}`, url: `/${mostRecentDonor?.username}`,
}, },
{ {
name: 'Most recent donation', name: 'Most recent donation',

View File

@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts' import { Contract, contractPath } from 'web/lib/firebase/contracts'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
import { FIXED_ANTE } from 'common/antes' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from 'common/economy'
import { InfoTooltip } from 'web/components/info-tooltip' import { InfoTooltip } from 'web/components/info-tooltip'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
@ -158,6 +158,8 @@ export function NewContract(props: {
: undefined : undefined
const balance = creator.balance || 0 const balance = creator.balance || 0
const deservesFreeMarket =
(creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
const min = minString ? parseFloat(minString) : undefined const min = minString ? parseFloat(minString) : undefined
const max = maxString ? parseFloat(maxString) : undefined const max = maxString ? parseFloat(maxString) : undefined
@ -177,7 +179,7 @@ export function NewContract(props: {
question.length > 0 && question.length > 0 &&
ante !== undefined && ante !== undefined &&
ante !== null && ante !== null &&
ante <= balance && (ante <= balance || deservesFreeMarket) &&
// closeTime must be in the future // closeTime must be in the future
closeTime && closeTime &&
closeTime > Date.now() && closeTime > Date.now() &&
@ -207,6 +209,7 @@ export function NewContract(props: {
max: MAX_DESCRIPTION_LENGTH, max: MAX_DESCRIPTION_LENGTH,
placeholder: descriptionPlaceholder, placeholder: descriptionPlaceholder,
disabled: isSubmitting, disabled: isSubmitting,
defaultValue: JSON.parse(params?.description ?? '{}'),
}) })
const isEditorFilled = editor != null && !editor.isEmpty const isEditorFilled = editor != null && !editor.isEmpty
@ -460,12 +463,25 @@ export function NewContract(props: {
text={`Cost to create your question. This amount is used to subsidize betting.`} text={`Cost to create your question. This amount is used to subsidize betting.`}
/> />
</label> </label>
{!deservesFreeMarket ? (
<div className="label-text text-neutral pl-1"> <div className="label-text text-neutral pl-1">
{formatMoney(ante)} {formatMoney(ante)}
</div> </div>
) : (
<div>
<div className="label-text text-primary pl-1">
FREE{' '}
<span className="label-text pl-1 text-gray-500">
(You have{' '}
{FREE_MARKETS_PER_USER_MAX -
(creator?.freeMarketsCreated ?? 0)}{' '}
free markets left)
</span>
</div>
</div>
)}
{ante > balance && ( {ante > balance && !deservesFreeMarket && (
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide"> <div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
<span className="mr-2 text-red-500">Insufficient balance</span> <span className="mr-2 text-red-500">Insufficient balance</span>
<button <button

View File

@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
contract={contract} contract={contract}
bets={bets} bets={bets}
isCreator={false} isCreator={false}
user={null}
disabled disabled
/> />

View File

@ -0,0 +1,118 @@
import React from 'react'
import { useRouter } from 'next/router'
import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch } from 'web/components/contract-search'
import { User } from 'common/user'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { Sort } from 'web/hooks/use-sort-and-query-params'
import { Button } from 'web/components/button'
import { Spacer } from 'web/components/layout/spacer'
import { useMemberGroups } from 'web/hooks/use-group'
import { Group } from 'common/group'
import { Title } from 'web/components/title'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } | null }) => {
const user = props.auth ? props.auth.user : null
const router = useRouter()
useTracking('view home')
useSaveReferral()
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => group.contractIds.length > 0
)
return (
<Page>
<Col className="mx-auto mb-8 w-full">
<SearchSection label="Trending" sort="score" user={user} />
<SearchSection label="Newest" sort="newest" user={user} />
<SearchSection label="Closing soon" sort="close-date" user={user} />
{memberGroups.map((group) => (
<GroupSection key={group.id} group={group} user={user} />
))}
</Col>
<button
type="button"
className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden"
onClick={() => {
router.push('/create')
track('mobile create button')
}}
>
<PlusSmIcon className="h-8 w-8" aria-hidden="true" />
</button>
</Page>
)
}
function SearchSection(props: {
label: string
user: User | null
sort: Sort
}) {
const { label, user, sort } = props
const router = useRouter()
return (
<Col>
<Title className="mx-2 !text-gray-800 sm:mx-0" text={label} />
<Spacer h={2} />
<ContractSearch user={user} defaultSort={sort} maxItems={4} noControls />
<Button
className="self-end"
color="blue"
size="sm"
onClick={() => router.push(`/home?s=${sort}`)}
>
See more
</Button>
</Col>
)
}
function GroupSection(props: { group: Group; user: User | null }) {
const { group, user } = props
const router = useRouter()
return (
<Col className="">
<Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} />
<Spacer h={2} />
<ContractSearch
user={user}
defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }}
maxItems={4}
noControls
/>
<Button
className="mr-2 self-end"
color="blue"
size="sm"
onClick={() => router.push(`/group/${group.slug}`)}
>
See more
</Button>
</Col>
)
}
export default Home

View File

@ -33,12 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { REFERRAL_AMOUNT } from 'common/user'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import { FollowList } from 'web/components/follow-list' import { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline' import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { CopyLinkButton } from 'web/components/copy-link-button' import { CopyLinkButton } from 'web/components/copy-link-button'
@ -47,7 +44,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { GroupComment } from 'common/comment' import { GroupComment } from 'common/comment'
import { GroupChat } from 'web/components/groups/group-chat' import { REFERRAL_AMOUNT } from 'common/economy'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -149,9 +146,6 @@ export default function GroupPage(props: {
const page = slugs?.[1] as typeof groupSubpages[number] const page = slugs?.[1] as typeof groupSubpages[number]
const group = useGroup(props.group?.id) ?? props.group const group = useGroup(props.group?.id) ?? props.group
const tips = useTipTxns({ groupId: group?.id })
const messages = useCommentsOnGroup(group?.id) ?? props.messages
const user = useUser() const user = useUser()
@ -201,21 +195,12 @@ export default function GroupPage(props: {
/> />
) )
const chatTab = (
<GroupChat messages={messages} group={group} user={user} tips={tips} />
)
const tabs = [ const tabs = [
{ {
title: 'Markets', title: 'Markets',
content: questionsTab, content: questionsTab,
href: groupPath(group.slug, 'markets'), href: groupPath(group.slug, 'markets'),
}, },
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, 'chat'),
},
{ {
title: 'Leaderboards', title: 'Leaderboards',
content: leaderboard, content: leaderboard,

View File

@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
backToHome={() => { backToHome={() => {
history.back() history.back()
}} }}
recommendedContracts={[]}
/> />
)} )}
</> </>

View File

@ -25,8 +25,8 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { ManalinkCardFromView } from 'web/components/manalink-card' import { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
import { Manalink } from 'common/manalink' import { Manalink } from 'common/manalink'
import { REFERRAL_AMOUNT } from 'common/user'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { REFERRAL_AMOUNT } from 'common/economy'
const LINKS_PER_PAGE = 24 const LINKS_PER_PAGE = 24

View File

@ -34,7 +34,7 @@ import { groupPath } from 'web/lib/firebase/groups'
import { import {
BETTING_STREAK_BONUS_AMOUNT, BETTING_STREAK_BONUS_AMOUNT,
UNIQUE_BETTOR_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT,
} from 'common/numeric-constants' } from 'common/economy'
import { groupBy, sum, uniq } from 'lodash' import { groupBy, sum, uniq } from 'lodash'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
@ -44,6 +44,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings' import { NotificationSettings } from 'web/components/NotificationSettings'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { useUser } from 'web/hooks/use-user'
export const NOTIFICATIONS_PER_PAGE = 30 export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers' const MULTIPLE_USERS_KEY = 'multipleUsers'
@ -165,7 +166,7 @@ function NotificationsList(props: {
if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div /> if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div />
return ( return (
<div className={'min-h-[100vh]'}> <div className={'min-h-[100vh] text-sm'}>
{paginatedGroupedNotifications.length === 0 && ( {paginatedGroupedNotifications.length === 0 && (
<div className={'mt-2'}> <div className={'mt-2'}>
You don't have any notifications. Try changing your settings to see You don't have any notifications. Try changing your settings to see
@ -271,9 +272,17 @@ function IncomeNotificationGroupItem(props: {
} }
return newNotifications return newNotifications
} }
const combinedNotifs = combineNotificationsByAddingNumericSourceTexts(
const combinedNotifs = notifications.filter((n) => n.sourceType !== 'betting_streak_bonus')
combineNotificationsByAddingNumericSourceTexts(notifications) )
// Because the server's reset time will never align with the client's, we may
// erroneously sum 2 betting streak bonuses, therefore just show the most recent
const mostRecentBettingStreakBonus = notifications
.filter((n) => n.sourceType === 'betting_streak_bonus')
.sort((a, b) => a.createdTime - b.createdTime)
.pop()
if (mostRecentBettingStreakBonus)
combinedNotifs.unshift(mostRecentBettingStreakBonus)
return ( return (
<div <div
@ -370,6 +379,8 @@ function IncomeNotificationItem(props: {
const [highlighted] = useState(!notification.isSeen) const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = (width && width < 768) || false const isMobile = (width && width < 768) || false
const user = useUser()
useEffect(() => { useEffect(() => {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
}, [notification]) }, [notification])
@ -388,20 +399,30 @@ function IncomeNotificationItem(props: {
reasonText = !simple ? `tipped you on` : `in tips on` reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus') { } else if (sourceType === 'betting_streak_bonus') {
reasonText = 'for your' reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
reasonText = `of your invested bets returned as a`
} }
const streakInDays =
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
: user?.currentBettingStreak ?? 0
const bettingStreakText = const bettingStreakText =
sourceType === 'betting_streak_bonus' && sourceType === 'betting_streak_bonus' &&
(sourceText (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
? `🔥 ${
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
} day Betting Streak`
: 'Betting Streak')
return ( return (
<> <>
{reasonText} {reasonText}
{sourceType === 'betting_streak_bonus' ? ( {sourceType === 'loan' ? (
simple ? (
<span className={'ml-1 font-bold'}>🏦 Loan</span>
) : (
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
🏦 Loan
</SiteLink>
)
) : sourceType === 'betting_streak_bonus' ? (
simple ? ( simple ? (
<span className={'ml-1 font-bold'}>{bettingStreakText}</span> <span className={'ml-1 font-bold'}>{bettingStreakText}</span>
) : ( ) : (
@ -445,6 +466,7 @@ function IncomeNotificationItem(props: {
if (sourceType === 'challenge') return `${sourceSlug}` if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus') if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak` return `/${sourceUserUsername}/?show=betting-streak`
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
if (sourceContractCreatorUsername && sourceContractSlug) if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '', sourceId ?? '',

View File

@ -5,11 +5,11 @@ import { useUser } from 'web/hooks/use-user'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { REFERRAL_AMOUNT } from 'common/user'
import { CopyLinkButton } from 'web/components/copy-link-button' import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { InfoBox } from 'web/components/info-box' import { InfoBox } from 'web/components/info-box'
import { QRCode } from 'web/components/qr-code' import { QRCode } from 'web/components/qr-code'
import { REFERRAL_AMOUNT } from 'common/economy'
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')

View File

@ -1,23 +1,20 @@
import { sortBy } from 'lodash'
import { GetServerSideProps } from 'next' import { GetServerSideProps } from 'next'
import { getServerSideSitemap, ISitemapField } from 'next-sitemap' import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
import { DOMAIN } from 'common/envs/constants' import { listAllContracts } from 'web/lib/firebase/contracts'
import { LiteMarket } from './api/v0/_types'
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
// Fetching data from https://manifold.markets/api const contracts = await listAllContracts(1000, undefined, 'popularityScore')
const response = await fetch(`https://${DOMAIN}/api/v0/markets`)
const liteMarkets = (await response.json()) as LiteMarket[] const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1))
const sortedMarkets = sortBy(liteMarkets, (m) => -m.volume24Hours)
const fields = sortedMarkets.map((market) => ({ const fields = contracts
// See https://www.sitemaps.org/protocol.html .sort((x) => x.popularityScore ?? 0)
loc: market.url, .map((market) => ({
loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`,
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily', changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1, priority: score(market.popularityScore ?? 0),
// TODO: Add `lastmod` aka last modified time lastmod: market.lastUpdatedTime,
})) as ISitemapField[] })) as ISitemapField[]
return await getServerSideSitemap(ctx, fields) return await getServerSideSitemap(ctx, fields)

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
</urlset>

View File

@ -1,4 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<sitemap><loc>https://manifold.markets/sitemap-0.xml</loc></sitemap> <url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
</sitemapindex> <url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/add-funds</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/challenges</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/charity</loc><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://manifold.markets/groups</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
</urlset>