diff --git a/common/antes.ts b/common/antes.ts
index b9914451..d4e624b1 100644
--- a/common/antes.ts
+++ b/common/antes.ts
@@ -11,11 +11,8 @@ import {
import { User } from './user'
import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees'
-import { ENV_CONFIG } from './envs/constants'
import { Answer } from './answer'
-export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
-
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
diff --git a/common/bet.ts b/common/bet.ts
index 3d9d6a5a..8afebcd8 100644
--- a/common/bet.ts
+++ b/common/bet.ts
@@ -61,5 +61,3 @@ export type fill = {
// I.e. -fill.shares === matchedBet.shares
isSale?: boolean
}
-
-export const MAX_LOAN_PER_CONTRACT = 20
diff --git a/common/contract.ts b/common/contract.ts
index 2a8f897a..2b330201 100644
--- a/common/contract.ts
+++ b/common/contract.ts
@@ -57,6 +57,8 @@ export type Contract = {
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
+ followerCount?: number
+ featuredOnHomeRank?: number
} & T
export type BinaryContract = Contract & Binary
diff --git a/common/economy.ts b/common/economy.ts
new file mode 100644
index 00000000..c1449d4f
--- /dev/null
+++ b/common/economy.ts
@@ -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
diff --git a/common/envs/constants.ts b/common/envs/constants.ts
index 48f9bf63..89d040e8 100644
--- a/common/envs/constants.ts
+++ b/common/envs/constants.ts
@@ -44,3 +44,7 @@ export const CORS_ORIGIN_VERCEL = new RegExp(
)
// Any localhost server on any port
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}`
+}
diff --git a/common/envs/prod.ts b/common/envs/prod.ts
index 5bd12095..2b1ee70e 100644
--- a/common/envs/prod.ts
+++ b/common/envs/prod.ts
@@ -19,10 +19,23 @@ export type EnvConfig = {
navbarLogoPath?: string
newQuestionPlaceholders: string[]
- // Currency controls
- fixedAnte?: number
- startingBalance?: number
- referralBonus?: number
+ economy?: Economy
+}
+
+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 = {
@@ -58,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold
+ 'iansphilips@gmail.com', // Ian
+ 'd4vidchee@gmail.com', // D4vid
],
visibility: 'PUBLIC',
diff --git a/common/fees.ts b/common/fees.ts
index 0a537edc..f944933c 100644
--- a/common/fees.ts
+++ b/common/fees.ts
@@ -1,9 +1,9 @@
export const PLATFORM_FEE = 0
-export const CREATOR_FEE = 0.1
+export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0
-export const DPM_PLATFORM_FEE = 0.01
-export const DPM_CREATOR_FEE = 0.04
+export const DPM_PLATFORM_FEE = 0.0
+export const DPM_CREATOR_FEE = 0.0
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
export type Fees = {
diff --git a/common/loans.ts b/common/loans.ts
new file mode 100644
index 00000000..cb956c09
--- /dev/null
+++ b/common/loans.ts
@@ -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
+) => {
+ 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,
+ }
+ })
+}
diff --git a/common/new-bet.ts b/common/new-bet.ts
index 576f35f8..7085a4fe 100644
--- a/common/new-bet.ts
+++ b/common/new-bet.ts
@@ -1,6 +1,6 @@
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 {
calculateDpmShares,
getDpmProbability,
@@ -276,8 +276,7 @@ export const getBinaryBetStats = (
export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO',
amount: number,
- contract: DPMBinaryContract,
- loanAmount: number
+ contract: DPMBinaryContract
) => {
const { YES: yesPool, NO: noPool } = contract.pool
@@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
const newBet: CandidateBet = {
contractId: contract.id,
amount,
- loanAmount,
+ loanAmount: 0,
shares,
outcome,
probBefore,
@@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
outcome: string,
amount: number,
contract: FreeResponseContract | MultipleChoiceContract,
- loanAmount: number
) => {
const { pool, totalShares, totalBets } = contract
@@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
const newBet: CandidateBet = {
contractId: contract.id,
amount,
- loanAmount,
+ loanAmount: 0,
shares,
outcome,
probBefore,
@@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
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
-}
diff --git a/common/notification.ts b/common/notification.ts
index 99f9d852..f10bd3f6 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -39,6 +39,7 @@ export type notification_source_types =
| 'bonus'
| 'challenge'
| 'betting_streak_bonus'
+ | 'loan'
export type notification_source_update_types =
| 'created'
@@ -68,3 +69,5 @@ export type notification_reason_types =
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'
| 'betting_streak_incremented'
+ | 'loan_income'
+ | 'you_follow_contract'
diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts
index 3e5af0d3..ef364b74 100644
--- a/common/numeric-constants.ts
+++ b/common/numeric-constants.ts
@@ -3,7 +3,3 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
-export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
-export const BETTING_STREAK_BONUS_AMOUNT = 5
-export const BETTING_STREAK_BONUS_MAX = 100
-export const BETTING_STREAK_RESET_HOUR = 0
diff --git a/common/redeem.ts b/common/redeem.ts
index 4a4080f6..e0839ff8 100644
--- a/common/redeem.ts
+++ b/common/redeem.ts
@@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
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 loanPayment = Math.min(loanAmount, shares)
+ const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment
return { shares, loanPayment, netAmount }
}
diff --git a/common/sell-bet.ts b/common/sell-bet.ts
index e1fd9c5d..bc8fe596 100644
--- a/common/sell-bet.ts
+++ b/common/sell-bet.ts
@@ -13,7 +13,7 @@ export type CandidateBet = Omit
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
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)
@@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
betId,
},
fees,
+ loanAmount: -(loanAmount ?? 0),
}
return {
@@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
shares: number,
outcome: 'YES' | 'NO',
contract: CPMMContract,
- prevLoanAmount: number,
- unfilledBets: LimitBet[]
+ unfilledBets: LimitBet[],
+ loanPaid: number
) => {
const { pool, p } = contract
@@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
unfilledBets
)
- const loanPaid = Math.min(prevLoanAmount, saleValue)
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
diff --git a/common/user.ts b/common/user.ts
index 2910c54e..48a3d59c 100644
--- a/common/user.ts
+++ b/common/user.ts
@@ -1,5 +1,3 @@
-import { ENV_CONFIG } from './envs/constants'
-
export type User = {
id: string
createdTime: number
@@ -32,6 +30,7 @@ export type User = {
allTime: number
}
+ nextLoanCached: number
followerCountCached: number
followedCategories?: string[]
@@ -43,13 +42,10 @@ export type User = {
shouldShowWelcome?: boolean
lastBetTime?: 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 = {
id: string // same as User.id
username: string // denormalized from User
@@ -60,6 +56,7 @@ export type PrivateUser = {
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
unsubscribedFromWeeklyTrendingEmails?: boolean
+ weeklyTrendingEmailSent?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
initialIpAddress?: string
diff --git a/dev.sh b/dev.sh
index ca3246ac..d392646e 100755
--- a/dev.sh
+++ b/dev.sh
@@ -24,7 +24,7 @@ then
npx concurrently \
-n FIRESTORE,FUNCTIONS,NEXT,TS \
-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 NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
diff --git a/docs/docs/api.md b/docs/docs/api.md
index 7b0058c2..c02a5141 100644
--- a/docs/docs/api.md
+++ b/docs/docs/api.md
@@ -97,7 +97,6 @@ Requires no authorization.
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
"closeTime":1653893940000,
"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":[
"personal",
"commitments"
@@ -135,8 +134,6 @@ Requires no authorization.
// Market attributes. All times are in milliseconds since epoch
closeTime?: number // Min of creator's chosen date, and resolutionTime
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.
// This list also includes the predefined categories shown as filters on the home page.
@@ -398,6 +395,8 @@ Requires no authorization.
bets: Bet[]
comments: Comment[]
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 = {
diff --git a/firestore.rules b/firestore.rules
index c0d17dac..4cd718d3 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -10,7 +10,9 @@ service cloud.firestore {
'akrolsmir@gmail.com',
'jahooma@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 update: if userId == request.auth.uid
&& 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
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@@ -43,6 +45,11 @@ service cloud.firestore {
allow read;
}
+ match /contracts/{contractId}/follows/{userId} {
+ allow read;
+ allow create, delete: if userId == request.auth.uid;
+ }
+
match /contracts/{contractId}/challenges/{challengeId}{
allow read;
allow create: if request.auth.uid == request.resource.data.creatorId;
diff --git a/functions/package.json b/functions/package.json
index d6278c25..c8f295fc 100644
--- a/functions/package.json
+++ b/functions/package.json
@@ -13,8 +13,8 @@
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log",
"dev": "nodemon src/serve.ts",
- "firestore": "firebase emulators:start --only firestore --import=./firestore_export",
- "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,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,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: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)",
diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts
index 2abaf44d..0b8b4e7a 100644
--- a/functions/src/create-answer.ts
+++ b/functions/src/create-answer.ts
@@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
}
transaction.create(newAnswerDoc, answer)
- const loanAmount = 0
-
const { newBet, newPool, newTotalShares, newTotalBets } =
- getNewMultiBetInfo(answerId, amount, contract, loanAmount)
+ getNewMultiBetInfo(answerId, amount, contract)
const newBalance = user.balance - amount
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts
index 5b0d1daf..e9804f90 100644
--- a/functions/src/create-market.ts
+++ b/functions/src/create-market.ts
@@ -15,15 +15,17 @@ import {
import { slugify } from '../../common/util/slugify'
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 { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import {
- FIXED_ANTE,
+ DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
getCpmmInitialLiquidity,
getFreeAnswerAnte,
getMultipleChoiceAntes,
getNumericAnte,
+ HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
@@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet'
+import { FieldValue } from 'firebase-admin/firestore'
const descScehma: z.ZodType = z.lazy(() =>
z.intersection(
@@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const user = userDoc.data() as User
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
- if (ante > user.balance)
+ if (ante > user.balance && !deservesFreeMarket)
throw new APIError(400, `Balance must be at least ${ante}.`)
let group: Group | null = null
@@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
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)
@@ -221,8 +236,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
}
}
- const providerId = user.id
-
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index 90250e73..035126c5 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -7,7 +7,7 @@ import {
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
-import { getValues } from './utils'
+import { getValues, log } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
@@ -33,19 +33,12 @@ export const createNotification = async (
sourceText: string,
miscData?: {
contract?: Contract
- relatedSourceType?: notification_source_types
recipients?: string[]
slug?: string
title?: string
}
) => {
- const {
- contract: sourceContract,
- relatedSourceType,
- recipients,
- slug,
- title,
- } = miscData ?? {}
+ const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const shouldGetNotification = (
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 (
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 = (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
@@ -182,71 +140,6 @@ export const createNotification = async (
}
}
- const notifyOtherAnswerersOnContract = async (
- userToReasonTexts: user_to_reason_texts,
- sourceContract: Contract
- ) => {
- const answers = await getValues(
- 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(
- 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 = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
@@ -266,58 +159,289 @@ export const createNotification = async (
}
}
- const getUsersToNotify = async () => {
- const userToReasonTexts: user_to_reason_texts = {}
- // The following functions modify the userToReasonTexts object in place.
- if (sourceType === 'follow' && recipients?.[0]) {
- notifyFollowedUser(userToReasonTexts, recipients[0])
- } else if (
- sourceType === 'group' &&
- sourceUpdateType === 'created' &&
- recipients
- ) {
- recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
- }
+ const userToReasonTexts: user_to_reason_texts = {}
+ // The following functions modify the userToReasonTexts object in place.
- // The following functions need sourceContract to be defined.
- if (!sourceContract) return userToReasonTexts
-
- if (
- sourceType === 'comment' ||
- sourceType === 'answer' ||
- (sourceType === 'contract' &&
- (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
- ) {
- if (sourceType === 'comment') {
- if (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)
- notifyTaggedUsers(userToReasonTexts, recipients ?? [])
- } else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
- await notifyContractCreator(userToReasonTexts, sourceContract, {
- force: true,
- })
- } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
- await notifyContractCreator(userToReasonTexts, sourceContract)
- } else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
- // Note: the daily bonus won't have a contract attached to it
- await notifyContractCreatorOfUniqueBettorsBonus(
- userToReasonTexts,
- sourceContract.creatorId
- )
- }
- return userToReasonTexts
+ if (sourceType === 'follow' && recipients?.[0]) {
+ notifyFollowedUser(userToReasonTexts, recipients[0])
+ } else if (
+ sourceType === 'group' &&
+ sourceUpdateType === 'created' &&
+ recipients
+ ) {
+ recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
+ } else if (
+ sourceType === 'contract' &&
+ sourceUpdateType === 'created' &&
+ sourceContract
+ ) {
+ await notifyUsersFollowers(userToReasonTexts)
+ notifyTaggedUsers(userToReasonTexts, recipients ?? [])
+ } else if (
+ sourceType === 'contract' &&
+ sourceUpdateType === 'closed' &&
+ sourceContract
+ ) {
+ await notifyContractCreator(userToReasonTexts, sourceContract, {
+ force: true,
+ })
+ } else if (
+ sourceType === 'liquidity' &&
+ sourceUpdateType === 'created' &&
+ sourceContract
+ ) {
+ await notifyContractCreator(userToReasonTexts, sourceContract)
+ } else if (
+ sourceType === 'bonus' &&
+ sourceUpdateType === 'created' &&
+ sourceContract
+ ) {
+ // Note: the daily bonus won't have a contract attached to it
+ await notifyContractCreatorOfUniqueBettorsBonus(
+ userToReasonTexts,
+ sourceContract.creatorId
+ )
}
- const userToReasonTexts = await getUsersToNotify()
+ await createUsersNotifications(userToReasonTexts)
+}
+
+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(
+ 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(
+ 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)
}
@@ -471,6 +595,32 @@ export const createReferralNotification = async (
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}`
export const createChallengeAcceptedNotification = async (
diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts
index 7156855e..35394e90 100644
--- a/functions/src/create-user.ts
+++ b/functions/src/create-user.ts
@@ -2,15 +2,8 @@ import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
-import {
- MANIFOLD_AVATAR_URL,
- MANIFOLD_USERNAME,
- PrivateUser,
- STARTING_BALANCE,
- SUS_STARTING_BALANCE,
- User,
-} from '../../common/user'
-import { getUser, getUserByUsername, getValues, isProd } from './utils'
+import { PrivateUser, User } from '../../common/user'
+import { getUser, getUserByUsername, getValues } from './utils'
import { randomString } from '../../common/util/random'
import {
cleanDisplayName,
@@ -25,10 +18,7 @@ import {
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
-import {
- DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
- HOUSE_LIQUIDITY_PROVIDER_ID,
-} from '../../common/antes'
+import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
const bodySchema = z.object({
deviceToken: z.string().optional(),
@@ -75,6 +65,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
createdTime: Date.now(),
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
+ nextLoanCached: 0,
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
@@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
.update({
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,
- })
- }
}
}
diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html
index fc067643..d00b227e 100644
--- a/functions/src/email-templates/interesting-markets.html
+++ b/functions/src/email-templates/interesting-markets.html
@@ -444,7 +444,7 @@
style="
color: inherit;
text-decoration: none;
- " target="_blank">click here to unsubscribe.
+ " target="_blank">click here to unsubscribe from future recommended markets.
diff --git a/functions/src/emails.ts b/functions/src/emails.ts
index f90366fa..e6e52090 100644
--- a/functions/src/emails.ts
+++ b/functions/src/emails.ts
@@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}`
- const creatorPayoutText =
- userId === creator.id
- ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
- : ''
+ // const creatorPayoutText =
+ // userId === creator.id
+ // ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
+ // : ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
@@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
question: contract.question,
outcome,
investment: `${Math.floor(investment)}`,
- payout: `${Math.floor(payout)}${creatorPayoutText}`,
+ payout: `${Math.floor(payout)}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl,
}
@@ -116,7 +116,9 @@ const toDisplayResolution = (
}
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
- const { resolutionValue } = contract
+ const { resolution, resolutionValue } = contract
+
+ if (resolution === 'CANCEL') return 'N/A'
return resolutionValue
? formatLargeNumber(resolutionValue)
diff --git a/functions/src/follow-market.ts b/functions/src/follow-market.ts
new file mode 100644
index 00000000..3fc05120
--- /dev/null
+++ b/functions/src/follow-market.ts
@@ -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()
+}
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 4d7cf42b..012ba241 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './update-metrics'
export * from './update-stats'
+export * from './update-loans'
export * from './backup-db'
export * from './market-close-notifications'
export * from './on-create-answer'
@@ -28,6 +29,8 @@ export * from './on-delete-group'
export * from './score-contracts'
export * from './weekly-markets-emails'
export * from './reset-betting-streaks'
+export * from './reset-weekly-emails-flag'
+export * from './on-update-contract-follow'
// v2
export * from './health'
diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts
index 6af5e699..611bf23b 100644
--- a/functions/src/on-create-answer.ts
+++ b/functions/src/on-create-answer.ts
@@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils'
-import { createNotification } from './create-notification'
+import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Answer } from '../../common/answer'
export const onCreateAnswer = functions.firestore
@@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator')
-
- await createNotification(
+ await createCommentOrAnswerOrUpdatedContractNotification(
answer.id,
'answer',
'created',
answerCreator,
eventId,
answer.text,
- { contract }
+ contract
)
})
diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts
index 45adade5..ff6cf9d9 100644
--- a/functions/src/on-create-bet.ts
+++ b/functions/src/on-create-bet.ts
@@ -17,7 +17,7 @@ import {
BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT,
-} from '../../common/numeric-constants'
+} from '../../common/economy'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts
index 9f19dfcc..8651bde0 100644
--- a/functions/src/on-create-comment-on-contract.ts
+++ b/functions/src/on-create-comment-on-contract.ts
@@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
-import { createNotification } from './create-notification'
+import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
+import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore()
@@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
const commentCreator = await getUser(comment.userId)
if (!commentCreator) throw new Error('Could not find comment creator')
+ await addUserToContractFollowers(contract.id, commentCreator.id)
+
await firestore
.collection('contracts')
.doc(contract.id)
@@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
- const recipients = uniq(
- compact([...parseMentions(comment.content), repliedUserId])
- )
-
- await createNotification(
+ await createCommentOrAnswerOrUpdatedContractNotification(
comment.id,
'comment',
'created',
commentCreator,
eventId,
richTextToString(comment.content),
- { contract, relatedSourceType, recipients }
+ contract,
+ {
+ relatedSourceType,
+ repliedUserId,
+ taggedUserIds: compact(parseMentions(comment.content)),
+ }
)
const recipientUserIds = uniq([
diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts
index 3785ecc9..d9826f6c 100644
--- a/functions/src/on-create-contract.ts
+++ b/functions/src/on-create-contract.ts
@@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
+import { addUserToContractFollowers } from './follow-market'
export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
@@ -18,6 +19,7 @@ export const onCreateContract = functions
const desc = contract.description as JSONContent
const mentioned = parseMentions(desc)
+ await addUserToContractFollowers(contract.id, contractCreator.id)
await createNotification(
contract.id,
diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts
index 6ec092a5..3a1e551f 100644
--- a/functions/src/on-create-liquidity-provision.ts
+++ b/functions/src/on-create-liquidity-provision.ts
@@ -1,7 +1,13 @@
import * as functions from 'firebase-functions'
-import { getContract, getUser } from './utils'
+import { getContract, getUser, log } from './utils'
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
.document('contracts/{contractId}/liquidity/{liquidityId}')
@@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
const { eventId } = context
// 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)
if (!contract)
@@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
+ await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification(
contract.id,
diff --git a/functions/src/on-update-contract-follow.ts b/functions/src/on-update-contract-follow.ts
new file mode 100644
index 00000000..f7d54fe8
--- /dev/null
+++ b/functions/src/on-update-contract-follow.ts
@@ -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),
+ })
+ })
diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts
index 2042f726..d7ecd56e 100644
--- a/functions/src/on-update-contract.ts
+++ b/functions/src/on-update-contract.ts
@@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
-import { createNotification } from './create-notification'
+import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract'
export const onUpdateContract = functions.firestore
@@ -29,40 +29,37 @@ export const onUpdateContract = functions.firestore
resolutionText = `${contract.resolutionValue}`
}
- await createNotification(
+ await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'resolved',
contractUpdater,
eventId,
resolutionText,
- { contract }
+ contract
)
} else if (
previousValue.closeTime !== contract.closeTime ||
- previousValue.description !== contract.description
+ previousValue.question !== contract.question
) {
let sourceText = ''
- if (previousValue.closeTime !== contract.closeTime && contract.closeTime)
+ if (
+ previousValue.closeTime !== contract.closeTime &&
+ contract.closeTime
+ ) {
sourceText = contract.closeTime.toString()
- else {
- const oldTrimmedDescription = previousValue.description.trim()
- const newTrimmedDescription = contract.description.trim()
- if (oldTrimmedDescription === '') sourceText = newTrimmedDescription
- else
- sourceText = newTrimmedDescription
- .split(oldTrimmedDescription)[1]
- .trim()
+ } else if (previousValue.question !== contract.question) {
+ sourceText = contract.question
}
- await createNotification(
+ await createCommentOrAnswerOrUpdatedContractNotification(
contract.id,
'contract',
'updated',
contractUpdater,
eventId,
sourceText,
- { contract }
+ contract
)
}
})
diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts
index a76132b5..b45809d0 100644
--- a/functions/src/on-update-user.ts
+++ b/functions/src/on-update-user.ts
@@ -1,13 +1,14 @@
import * as functions from 'firebase-functions'
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 { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
-import { LimitBet } from 'common/bet'
+import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
-import { Group } from 'common/group'
+import { Group } from '../../common/group'
+import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore()
export const onUpdateUser = functions.firestore
diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts
index 780b50d6..404fda50 100644
--- a/functions/src/place-bet.ts
+++ b/functions/src/place-bet.ts
@@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares'
import { log } from './utils'
+import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({
contractId: z.string(),
@@ -59,7 +60,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
- const loanAmount = 0
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
@@ -119,7 +119,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc)
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') {
const { outcome, value } = validate(numericSchema, req.body)
return getNumericBetsInfo(value, outcome, amount, contract)
@@ -168,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
return { betId: betDoc.id, makers, newBet }
})
+ await addUserToContractFollowers(contractId, auth.uid)
+
log('Main transaction finished.')
if (result.newBet.amount !== 0) {
diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts
index e1c3af8f..924f5c22 100644
--- a/functions/src/reset-betting-streaks.ts
+++ b/functions/src/reset-betting-streaks.ts
@@ -4,12 +4,12 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from '../../common/user'
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()
export const resetBettingStreaksForUsers = functions.pubsub
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
- .timeZone('utc')
+ .timeZone('Etc/UTC')
.onRun(async () => {
await resetBettingStreaksInternal()
})
diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flag.ts
new file mode 100644
index 00000000..5a71b65b
--- /dev/null
+++ b/functions/src/reset-weekly-emails-flag.ts
@@ -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,
+ })
+ })
+ )
+ })
diff --git a/functions/src/scripts/backfill-contract-followers.ts b/functions/src/scripts/backfill-contract-followers.ts
new file mode 100644
index 00000000..9b936654
--- /dev/null
+++ b/functions/src/scripts/backfill-contract-followers.ts
@@ -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(
+ firestore.collection('contracts').where('isResolved', '==', false)
+ )
+ let count = 0
+ for (const contract of contracts) {
+ const comments = await getValues(
+ 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)
+}
diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts
index 9b0c4096..acce446e 100644
--- a/functions/src/scripts/create-private-users.ts
+++ b/functions/src/scripts/create-private-users.ts
@@ -3,7 +3,8 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
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()
diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts
index 18df4536..22dc3f12 100644
--- a/functions/src/sell-bet.ts
+++ b/functions/src/sell-bet.ts
@@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
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()
transaction.update(userDoc, { balance: newBalance })
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.update(
contractDoc,
diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts
index ec08ab86..0e88a0b5 100644
--- a/functions/src/sell-shares.ts
+++ b/functions/src/sell-shares.ts
@@ -7,12 +7,13 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user'
import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
-import { getValues, log } from './utils'
+import { log } from './utils'
import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares'
+import { removeUserFromContractFollowers } from './follow-market'
const bodySchema = z.object({
contractId: z.string(),
@@ -28,12 +29,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
- const [[contractSnap, userSnap], userBets] = await Promise.all([
- transaction.getAll(contractDoc, userDoc),
- getValues(betsQ), // TODO: why is this not in the transaction??
- ])
+ const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
+ await Promise.all([
+ transaction.getAll(contractDoc, userDoc),
+ transaction.get(betsQ),
+ transaction.get(getUnfilledBetsQuery(contractDoc)),
+ ])
if (!contractSnap.exists) throw new APIError(400, 'Contract 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 user = userSnap.data() as User
@@ -45,7 +50,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
if (closeTime && Date.now() > closeTime)
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 sharesByOutcome = mapValues(betsByOutcome, (bets) =>
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.`)
const soldShares = Math.min(sharesToSell, maxShares)
-
- const unfilledBetsSnap = await transaction.get(
- getUnfilledBetsQuery(contractDoc)
- )
- const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
+ const saleFrac = soldShares / maxShares
+ let loanPaid = saleFrac * loanAmount
+ if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
- prevLoanAmount,
- unfilledBets
+ unfilledBets,
+ loanPaid
)
if (
@@ -104,7 +107,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.update(userDoc, {
- balance: FieldValue.increment(-newBet.amount),
+ balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
})
transaction.create(newBetDoc, {
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))
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
log('Share redemption transaction finished.')
diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts
index 4db91539..da7b507f 100644
--- a/functions/src/unsubscribe.ts
+++ b/functions/src/unsubscribe.ts
@@ -69,6 +69,10 @@ export const unsubscribe: EndpointDefinition = {
res.send(
`${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.`)
},
}
diff --git a/functions/src/update-loans.ts b/functions/src/update-loans.ts
new file mode 100644
index 00000000..770315fd
--- /dev/null
+++ b/functions/src/update-loans.ts
@@ -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(firestore.collection('users')),
+ getValues(
+ firestore.collection('contracts').where('isResolved', '==', false)
+ ),
+ getValues(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(
+ 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!')
+}
diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts
index cc9f8ebe..a2e72053 100644
--- a/functions/src/update-metrics.ts
+++ b/functions/src/update-metrics.ts
@@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
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 { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
@@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash'
+import { getLoanUpdates } from '../../common/loans'
const firestore = admin.firestore()
@@ -21,7 +22,9 @@ const computeInvestmentValue = (
if (bet.sale || bet.isSold) return 0
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 betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
- const userUpdates = users.map((user) => {
+
+ const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? []
@@ -93,32 +97,56 @@ export const updateMetricsCore = async () => {
newPortfolio,
didProfitChange
)
-
return {
- fieldUpdates: {
- doc: firestore.collection('users').doc(user.id),
- fields: {
- creatorVolumeCached: newCreatorVolume,
- ...(didProfitChange && {
- profitCached: newProfit,
- }),
- },
- },
-
- subcollectionUpdates: {
- doc: firestore
- .collection('users')
- .doc(user.id)
- .collection('portfolioHistory')
- .doc(),
- fields: {
- ...(didProfitChange && {
- ...newPortfolio,
- }),
- },
- },
+ 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 {
+ fieldUpdates: {
+ doc: firestore.collection('users').doc(user.id),
+ fields: {
+ creatorVolumeCached: newCreatorVolume,
+ ...(didProfitChange && {
+ profitCached: newProfit,
+ }),
+ nextLoanCached,
+ },
+ },
+
+ subcollectionUpdates: {
+ doc: firestore
+ .collection('users')
+ .doc(user.id)
+ .collection('portfolioHistory')
+ .doc(),
+ fields: {
+ ...(didProfitChange && {
+ ...newPortfolio,
+ }),
+ },
+ },
+ }
+ }
+ )
await writeAsync(
firestore,
userUpdates.map((u) => u.fieldUpdates)
@@ -234,6 +262,6 @@ const calculateNewProfit = (
}
export const updateMetrics = functions
- .runWith({ memory: '1GB', timeoutSeconds: 540 })
+ .runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)
diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts
index f99458ef..3f1b5d36 100644
--- a/functions/src/update-stats.ts
+++ b/functions/src/update-stats.ts
@@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
}
export const updateStats = functions
- .runWith({ memory: '1GB', timeoutSeconds: 540 })
+ .runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)
diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts
index 1e43b7dc..bf839d00 100644
--- a/functions/src/weekly-markets-emails.ts
+++ b/functions/src/weekly-markets-emails.ts
@@ -9,9 +9,9 @@ import { DAY_MS } from '../../common/util/time'
export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
- // every Monday at 12pm PT (UTC -07:00)
- .pubsub.schedule('0 19 * * 1')
- .timeZone('utc')
+ // every minute on Monday for an hour at 12pm PT (UTC -07:00)
+ .pubsub.schedule('* 19 * * 1')
+ .timeZone('Etc/UTC')
.onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers()
})
@@ -37,17 +37,33 @@ async function sendTrendingMarketsEmailsToAllUsers() {
const privateUsers = await getAllPrivateUsers()
// get all users that haven't unsubscribed from weekly emails
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())
.filter(
(contract) =>
!(
contract.question.toLowerCase().includes('trump') &&
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)
+ log(
+ `Found ${trendingContracts.length} trending contracts:\n`,
+ trendingContracts.map((c) => c.question).join('\n ')
+ )
+
for (const privateUser of privateUsersToSendEmailsTo) {
if (!privateUser.email) {
log(`No email for ${privateUser.username}`)
@@ -70,12 +86,17 @@ async function sendTrendingMarketsEmailsToAllUsers() {
if (!user) continue
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
+ await firestore.collection('private-users').doc(user.id).update({
+ weeklyTrendingEmailSent: true,
+ })
}
}
+const fiveMinutes = 5 * 60 * 1000
+const seed = Math.round(Date.now() / fiveMinutes).toString()
+const rng = createRNG(seed)
+
function chooseRandomSubset(contracts: Contract[], count: number) {
- const fiveMinutes = 5 * 60 * 1000
- const seed = Math.round(Date.now() / fiveMinutes).toString()
- shuffle(contracts, createRNG(seed))
+ shuffle(contracts, rng)
return contracts.slice(0, count)
}
diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx
index 7a839a7a..7ee27fb5 100644
--- a/web/components/NotificationSettings.tsx
+++ b/web/components/NotificationSettings.tsx
@@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
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() {
const user = useUser()
@@ -17,6 +19,7 @@ export function NotificationSettings() {
const [emailNotificationSettings, setEmailNotificationSettings] =
useState('all')
const [privateUser, setPrivateUser] = useState(null)
+ const [showModal, setShowModal] = useState(false)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
@@ -121,12 +124,20 @@ export function NotificationSettings() {
}
function NotificationSettingLine(props: {
- label: string
+ label: string | React.ReactNode
highlight: boolean
+ onClick?: () => void
}) {
- const { label, highlight } = props
+ const { label, highlight, onClick } = props
return (
-
+
{highlight ? : }
{label}
@@ -148,31 +159,45 @@ export function NotificationSettings() {
toggleClassName={'w-24'}
/>
-
-
- You will receive notifications for:
-
-
-
-
-
-
-
+
+
+ You will receive notifications for these general events:
+
+
+
+ You will receive new comment, answer, & resolution notifications on
+ questions:
+
+
+ That you watch - you
+ auto-watch questions if:
+
+ }
+ onClick={() => setShowModal(true)}
+ />
+
+ • You create it
+ • You bet, comment on, or answer it
+ • You add liquidity to it
+
+ • 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
+
+
+
Email Notifications
+
)
}
diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx
index cb071850..971a5496 100644
--- a/web/components/amount-input.tsx
+++ b/web/components/amount-input.tsx
@@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants'
+import { useWindowSize } from 'web/hooks/use-window-size'
export function AmountInput(props: {
amount: number | undefined
@@ -33,7 +34,8 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(amount)
onChange(isInvalid ? undefined : amount)
}
-
+ const { width } = useWindowSize()
+ const isMobile = (width ?? 0) < 768
return (
@@ -50,6 +52,7 @@ export function AmountInput(props: {
inputMode="numeric"
placeholder="0"
maxLength={6}
+ autoFocus={!isMobile}
value={amount ?? ''}
disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)}
diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx
index d7d62e7d..2aca1772 100644
--- a/web/components/bet-button.tsx
+++ b/web/components/bet-button.tsx
@@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares'
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 */
export default function BetButton(props: {
@@ -30,23 +32,27 @@ export default function BetButton(props: {
return (
<>
- setOpen(true)}
+ {
+ !user ? firebaseLogin() : setOpen(true)
+ }}
>
- Bet
-
+ {user ? 'Bet' : 'Sign up to Bet'}
+
-
- {hasYesShares
- ? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})`
- : hasNoShares
- ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
- : ''}
-
+ {user && (
+
+ {hasYesShares
+ ? `(${Math.floor(yesShares)} ${
+ isPseudoNumeric ? 'HIGHER' : 'YES'
+ })`
+ : hasNoShares
+ ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
+ : ''}
+
+ )}
diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx
index 7eda7198..64780f4b 100644
--- a/web/components/bet-inline.tsx
+++ b/web/components/bet-inline.tsx
@@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
export function BetInline(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
- setProbAfter: (probAfter: number) => void
+ setProbAfter: (probAfter: number | undefined) => void
onClose: () => void
}) {
const { contract, className, setProbAfter, onClose } = props
@@ -82,7 +82,7 @@ export function BetInline(props: {
Bet
)}
-
+ {
+ setProbAfter(undefined)
+ onClose()
+ }}
+ >
diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx
index 54aa961d..03bd3898 100644
--- a/web/components/bet-panel.tsx
+++ b/web/components/bet-panel.tsx
@@ -1,6 +1,6 @@
import clsx from 'clsx'
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 { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
@@ -9,7 +9,6 @@ import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import {
formatMoney,
- formatMoneyWithDecimals,
formatPercent,
formatWithCommas,
} from 'common/util/format'
@@ -18,7 +17,6 @@ import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input'
-import { InfoTooltip } from './info-tooltip'
import {
BinaryOutcomeLabel,
HigherLabel,
@@ -261,8 +259,6 @@ function BuyPanel(props: {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
- const totalFees = sum(Object.values(newBet.fees))
-
const format = getFormattedMappedValue(contract)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
@@ -346,9 +342,9 @@ function BuyPanel(props: {
>
)}
-
+ /> */}
@@ -665,9 +661,9 @@ function LimitOrderPanel(props: {
>
)}
-
+ /> */}
@@ -689,9 +685,9 @@ function LimitOrderPanel(props: {
>
)}
-
+ /> */}
diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx
index b1ac7704..6f91a6d4 100644
--- a/web/components/challenges/create-challenge-modal.tsx
+++ b/web/components/challenges/create-challenge-modal.tsx
@@ -21,7 +21,7 @@ import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
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 { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx
index 56bc965d..34e1ff0d 100644
--- a/web/components/contract-search.tsx
+++ b/web/components/contract-search.tsx
@@ -91,6 +91,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage?: boolean
useQuerySortUrlParams?: boolean
isWholePage?: boolean
+ maxItems?: number
+ noControls?: boolean
}) {
const {
user,
@@ -105,6 +107,8 @@ export function ContractSearch(props: {
useQuerySortLocalStorage,
useQuerySortUrlParams,
isWholePage,
+ maxItems,
+ noControls,
} = props
const [numPages, setNumPages] = useState(1)
@@ -158,6 +162,8 @@ export function ContractSearch(props: {
const contracts = pages
.flat()
.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) {
return
@@ -175,10 +181,11 @@ export function ContractSearch(props: {
useQuerySortUrlParams={useQuerySortUrlParams}
user={user}
onSearchParametersChanged={onSearchParametersChanged}
+ noControls={noControls}
/>
>
+ }
+
return (
+ Featured
+
+ )
+}
diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx
index 833b37eb..56407c4d 100644
--- a/web/components/contract/contract-details.tsx
+++ b/web/components/contract/contract-details.tsx
@@ -32,6 +32,8 @@ import { groupPath } from 'web/lib/firebase/groups'
import { insertContent } from '../editor/utils'
import clsx from 'clsx'
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'
@@ -72,6 +74,8 @@ export function MiscDetails(props: {
{'Resolved '}
{fromNow(resolutionTime || 0)}
+ ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
+
) : volume > 0 || !isNew ? (
{formatMoney(contract.volume)} bet
) : (
@@ -134,6 +138,7 @@ export function AbbrContractDetails(props: {
export function ContractDetails(props: {
contract: Contract
bets: Bet[]
+ user: User | null | undefined
isCreator?: boolean
disabled?: boolean
}) {
@@ -157,7 +162,7 @@ export function ContractDetails(props: {
)
return (
-
+
{disabled ? (
groupInfo
+ ) : !groupToDisplay && !user ? (
+
) : (
-
-
{resolvedDate && contract.resolutionTime ? (
<>
+
) : null}
- {!resolvedDate && closeTime && (
+ {!resolvedDate && closeTime && user && (
<>
+
)}
-
-
-
-
- {volumeLabel}
-
-
- {!disabled && }
+ {user && (
+ <>
+
+
+ {volumeLabel}
+
+ {!disabled && }
+ >
+ )}
)
}
diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx
index be24d0b5..7c35a071 100644
--- a/web/components/contract/contract-info-dialog.tsx
+++ b/web/components/contract/contract-info-dialog.tsx
@@ -7,12 +7,16 @@ import { Bet } from 'common/bet'
import { Contract } from 'common/contract'
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 { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Title } from '../title'
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 =
'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 [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 { createdTime, closeTime, resolutionTime, mechanism, outcomeType } =
+ const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
contract
const tradersCount = uniqBy(
@@ -105,10 +114,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
{formatMoney(contract.volume)}
-
+ {/*
Creator earnings
{formatMoney(contract.collectedFees.creatorFee)}
-
+ */}
Traders
@@ -121,6 +130,60 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
{contractPool(contract)}
+
+ {/* Show a path to Firebase if user is an admin, or we're on localhost */}
+ {(isAdmin || isDev) && (
+
+ [DEV] Firestore
+
+
+ Console link
+
+
+
+ )}
+ {isAdmin && (
+
+ Set featured
+
+ {
+ 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)
+ }}
+ >
+ false
+ true
+
+
+
+ )}
diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx
index 22175876..77af001e 100644
--- a/web/components/contract/contract-leaderboard.tsx
+++ b/web/components/contract/contract-leaderboard.tsx
@@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? (
-
+
+
+ {isBinary && (
+
+ )}
- {isBinary && (
-
- )}
+ {isPseudoNumeric && (
+
+ )}
- {isPseudoNumeric && (
-
- )}
-
- {outcomeType === 'NUMERIC' && (
-
- )}
+ {outcomeType === 'NUMERIC' && (
+
+ )}
+
{isBinary ? (
-
{tradingAllowed(contract) && (
-
+
+
+ {!user && (
+
+ (with play money!)
+
+ )}
+
)}
) : isPseudoNumeric ? (
- {tradingAllowed(contract) && }
-
- ) : isPseudoNumeric ? (
-
-
- {tradingAllowed(contract) && }
+ {tradingAllowed(contract) && (
+
+
+ {!user && (
+
+ (with play money!)
+
+ )}
+
+ )}
) : (
(outcomeType === 'FREE_RESPONSE' ||
@@ -107,9 +117,10 @@ export const ContractOverview = (props: {
contract={contract}
bets={bets}
isCreator={isCreator}
+ user={user}
/>
-
+
{(isBinary || isPseudoNumeric) && (
)}{' '}
diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx
index 98440ec8..693befbb 100644
--- a/web/components/contract/contract-prob-graph.tsx
+++ b/web/components/contract/contract-prob-graph.tsx
@@ -58,7 +58,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const { width } = useWindowSize()
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)
? times[0]
: hoursAgo.toDate()
diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx
index f7b7eeac..603173f6 100644
--- a/web/components/contract/contracts-grid.tsx
+++ b/web/components/contract/contracts-grid.tsx
@@ -86,10 +86,12 @@ export function ContractsGrid(props: {
/>
))}
-
+ {loadMore && (
+
+ )}
)
}
diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/follow-market-modal.tsx
new file mode 100644
index 00000000..fb62ce9f
--- /dev/null
+++ b/web/components/contract/follow-market-modal.tsx
@@ -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 (
+
+
+
+ {title ? title : 'Watching questions'}
+
+ • What is watching?
+
+ You can receive notifications on questions you're interested in by
+ clicking the
+
+ ️ button on a question.
+
+
+ • What types of notifications will I receive?
+
+
+ You'll receive in-app notifications for new comments, answers, and
+ updates to the question.
+
+
+
+
+ )
+}
diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx
index e1805364..2c74a5a4 100644
--- a/web/components/contract/share-modal.tsx
+++ b/web/components/contract/share-modal.tsx
@@ -14,9 +14,10 @@ import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
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 { formatMoney } from 'common/util/format'
+import { REFERRAL_AMOUNT } from 'common/economy'
export function ShareModal(props: {
contract: Contract
diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx
index 9011ff1b..1af52291 100644
--- a/web/components/contract/share-row.tsx
+++ b/web/components/contract/share-row.tsx
@@ -10,6 +10,7 @@ import { User } from 'common/user'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { ShareModal } from './share-modal'
import { withTracking } from 'web/lib/service/analytics'
+import { FollowMarketButton } from 'web/components/follow-market-button'
export function ShareRow(props: {
contract: Contract
@@ -25,7 +26,7 @@ export function ShareRow(props: {
const [isShareOpen, setShareOpen] = useState(false)
return (
-
+
)}
+
)
}
diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx
index 8536df71..07e519e1 100644
--- a/web/components/copy-contract-button.tsx
+++ b/web/components/copy-contract-button.tsx
@@ -1,9 +1,7 @@
import { DuplicateIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Contract } from 'common/contract'
-import { ENV_CONFIG } from 'common/envs/constants'
import { getMappedValue } from 'common/pseudo-numeric'
-import { contractPath } from 'web/lib/firebase/contracts'
import { trackCallback } from 'web/lib/service/analytics'
export function DuplicateContractButton(props: {
@@ -33,22 +31,29 @@ export function DuplicateContractButton(props: {
// Pass along the Uri to create a new 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 = {
q: contract.question,
- closeTime: contract.closeTime || 0,
- description:
- (contract.description ? `${contract.description}\n\n` : '') +
- `(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`,
+ closeTime,
+ description: descriptionString,
outcomeType: contract.outcomeType,
} as Record
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
params.min = contract.min
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)
}
+ // TODO: Support multiple choice markets?
+
if (contract.groupLinks && contract.groupLinks.length > 0) {
params.groupId = contract.groupLinks[0].groupId
}
diff --git a/web/components/editor.tsx b/web/components/editor.tsx
index f4166f27..6af58caa 100644
--- a/web/components/editor.tsx
+++ b/web/components/editor.tsx
@@ -6,6 +6,7 @@ import {
JSONContent,
Content,
Editor,
+ mergeAttributes,
} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
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: {
class: clsx('no-underline !text-indigo-700', linkClass),
},
diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx
new file mode 100644
index 00000000..45d26ce4
--- /dev/null
+++ b/web/components/follow-market-button.tsx
@@ -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 (
+ {
+ 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: ,
+ })
+ track('Unwatch Market', {
+ slug: contract.slug,
+ })
+ } else {
+ await followContract(contract.id, user.id)
+ toast("You'll now receive notifications from this market!", {
+ icon: ,
+ })
+ track('Watch Market', {
+ slug: contract.slug,
+ })
+ }
+ if (!user.hasSeenContractFollowModal) {
+ await updateUser(user.id, {
+ hasSeenContractFollowModal: true,
+ })
+ setOpen(true)
+ }
+ }}
+ >
+ {followers?.includes(user?.id ?? 'nope') ? (
+
+
+ Unwatch
+
+ ) : (
+
+
+ Watch
+
+ )}
+
+
+ )
+}
diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx
index a3cd7973..466b7a9b 100644
--- a/web/components/limit-bets.tsx
+++ b/web/components/limit-bets.tsx
@@ -163,13 +163,15 @@ export function OrderBookButton(props: {
const { limitBets, contract, className } = props
const [open, setOpen] = useState(false)
- const yesBets = sortBy(
- limitBets.filter((bet) => bet.outcome === 'YES'),
+ const sortedBets = sortBy(
+ limitBets,
(bet) => -1 * bet.limitProb,
(bet) => bet.createdTime
)
+
+ const yesBets = sortedBets.filter((bet) => bet.outcome === 'YES')
const noBets = sortBy(
- limitBets.filter((bet) => bet.outcome === 'NO'),
+ sortedBets.filter((bet) => bet.outcome === 'NO'),
(bet) => bet.limitProb,
(bet) => bet.createdTime
)
@@ -202,7 +204,7 @@ export function OrderBookButton(props: {
diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx
index 680b8946..5a81f566 100644
--- a/web/components/nav/nav-bar.tsx
+++ b/web/components/nav/nav-bar.tsx
@@ -9,7 +9,7 @@ import {
import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react'
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 { Avatar } from '../avatar'
import clsx from 'clsx'
@@ -17,8 +17,6 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics'
-import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
-import { PrivateUser } from 'common/user'
function getNavigation() {
return [
@@ -44,7 +42,6 @@ export function BottomNavBar() {
const currentPage = router.pathname
const user = useUser()
- const privateUser = usePrivateUser()
const isIframe = useIsIframe()
if (isIframe) {
@@ -85,11 +82,7 @@ export function BottomNavBar() {
onClick={() => setSidebarOpen(true)}
>
- {privateUser ? (
-
- ) : (
- 'More'
- )}
+ More
0 ? 'font-bold' : 'font-normal'
- }
- >
- More
-
- )
-}
-
function NavBarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx
index 6c4addc4..e16a502e 100644
--- a/web/components/nav/sidebar.tsx
+++ b/web/components/nav/sidebar.tsx
@@ -12,22 +12,20 @@ import {
import clsx from 'clsx'
import Link from 'next/link'
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 { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
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 { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
-import { Group, GROUP_CHAT_SLUG } from 'common/group'
+import { Group } from 'common/group'
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 { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
@@ -58,7 +56,14 @@ function getNavigation() {
function getMoreNavigation(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) {
- return [{ name: 'Leaderboards', href: '/leaderboards' }]
+ return [
+ { name: 'Leaderboards', href: '/leaderboards' },
+ {
+ name: 'Sign out',
+ href: '#',
+ onClick: logout,
+ },
+ ]
}
if (!user) {
@@ -88,7 +93,7 @@ function getMoreNavigation(user?: User | null) {
href: 'https://salemcenter.manifold.markets/',
},
{ 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',
href: '#',
@@ -102,16 +107,16 @@ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon },
{
- name: 'About',
- href: 'https://docs.manifold.markets/$how-to',
+ name: 'Help & About',
+ href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
]
const signedOutMobileNavigation = [
{
- name: 'About',
- href: 'https://docs.manifold.markets/$how-to',
+ name: 'Help & About',
+ href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon },
@@ -125,8 +130,8 @@ const signedInMobileNavigation = [
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
{
- name: 'About',
- href: 'https://docs.manifold.markets/$how-to',
+ name: 'Help & About',
+ href: 'https://help.manifold.markets/',
icon: BookOpenIcon,
},
]
@@ -221,8 +226,6 @@ export default function Sidebar(props: { className?: string }) {
const currentPage = router.pathname
const user = useUser()
- const privateUser = usePrivateUser()
- // usePing(user?.id)
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user
@@ -230,11 +233,9 @@ export default function Sidebar(props: { className?: string }) {
: signedInMobileNavigation
const memberItems = (
- useMemberGroups(
- user?.id,
- { withChatEnabled: true },
- { by: 'mostRecentChatActivityTime' }
- ) ?? []
+ useMemberGroups(user?.id, undefined, {
+ by: 'mostRecentContractAddedTime',
+ }) ?? []
).map((group: Group) => ({
name: group.name,
href: `${groupPath(group.slug)}`,
@@ -268,13 +269,7 @@ export default function Sidebar(props: { className?: string }) {
{memberItems.length > 0 && (
)}
- {privateUser && (
-
- )}
+
{/* Desktop navigation */}
@@ -289,46 +284,36 @@ export default function Sidebar(props: { className?: string }) {
{/* Spacer if there are any groups */}
{memberItems.length > 0 && }
- {privateUser && (
-
- )}
+
)
}
-function GroupsList(props: {
- currentPage: string
- memberItems: Item[]
- privateUser: PrivateUser
-}) {
- const { currentPage, memberItems, privateUser } = props
- const preferredNotifications = useUnseenPreferredNotifications(
- privateUser,
- {
- customHref: '/group/',
- },
- memberItems.length > 0 ? memberItems.length : undefined
- )
+function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
+ const { currentPage, memberItems } = props
const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState(null)
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
- const notifIsForThisItem = useMemo(
- () => (itemHref: string) =>
- preferredNotifications.some(
- (n) =>
- !n.isSeen &&
- (n.isSeenOnHref === itemHref ||
- n.isSeenOnHref?.replace('/chat', '') === itemHref)
- ),
- [preferredNotifications]
- )
+ // const preferredNotifications = useUnseenPreferredNotifications(
+ // privateUser,
+ // {
+ // customHref: '/group/',
+ // },
+ // memberItems.length > 0 ? memberItems.length : undefined
+ // )
+ // const notifIsForThisItem = useMemo(
+ // () => (itemHref: string) =>
+ // preferredNotifications.some(
+ // (n) =>
+ // !n.isSeen &&
+ // (n.isSeenOnHref === itemHref ||
+ // n.isSeenOnHref?.replace('/chat', '') === itemHref)
+ // ),
+ // [preferredNotifications]
+ // )
return (
<>
@@ -344,16 +329,12 @@ function GroupsList(props: {
>
{memberItems.map((item) => (
{item.name}
diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx
index 13880bd4..604873e9 100644
--- a/web/components/portfolio/portfolio-value-section.tsx
+++ b/web/components/portfolio/portfolio-value-section.tsx
@@ -14,7 +14,7 @@ export const PortfolioValueSection = memo(
}) {
const { disableSelector, userId } = props
- const [portfolioPeriod, setPortfolioPeriod] = useState('allTime')
+ const [portfolioPeriod, setPortfolioPeriod] = useState('weekly')
const [portfolioHistory, setUsersPortfolioHistory] = useState<
PortfolioMetrics[]
>([])
@@ -53,13 +53,15 @@ export const PortfolioValueSection = memo(
{!disableSelector && (
{
setPortfolioPeriod(e.target.value as Period)
}}
>
{allTimeLabel}
- 7 days
- 24 hours
+ Last 7d
+ {/* Note: 'daily' seems to be broken? */}
+ {/* Last 24h */}
)}
diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx
index eb90f6d9..694a0193 100644
--- a/web/components/profile/betting-streak-modal.tsx
+++ b/web/components/profile/betting-streak-modal.tsx
@@ -3,7 +3,7 @@ import { Col } from 'web/components/layout/col'
import {
BETTING_STREAK_BONUS_AMOUNT,
BETTING_STREAK_BONUS_MAX,
-} from 'common/numeric-constants'
+} from 'common/economy'
import { formatMoney } from 'common/util/format'
export function BettingStreakModal(props: {
@@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
🔥
- Daily betting streaks
+ Daily betting streaks
• What are they?
diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx
new file mode 100644
index 00000000..945fb6fe
--- /dev/null
+++ b/web/components/profile/loans-modal.tsx
@@ -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 (
+
+
+ 🏦
+ Daily loans on your bets
+
+ • What are daily loans?
+
+ Every day at midnight PT, get 1% of your total bet amount back as a
+ loan.
+
+
+ • Do I have to pay back a loan?
+
+
+ Yes, don't worry! You will automatically pay back loans when the
+ market resolves or you sell your bet.
+
+
+ • What is the purpose of loans?
+
+
+ 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.
+
+ • What is an example?
+
+ For example, if you bet M$1000 on "Will I become a millionare?" on
+ Monday, you will get M$10 back on Tuesday.
+
+
+ Previous loans count against your total bet amount. So on Wednesday,
+ you would get back 1% of M$990 = M$9.9.
+
+
+
+
+ )
+}
diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx
index 7bb9f2d4..fe062d06 100644
--- a/web/components/resolution-panel.tsx
+++ b/web/components/resolution-panel.tsx
@@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button'
import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { ProbabilitySelector } from './probability-selector'
-import { DPM_CREATOR_FEE } from 'common/fees'
import { getProbability } from 'common/calculate'
import { BinaryContract, resolution } from 'common/contract'
-import { formatMoney } from 'common/util/format'
export function ResolutionPanel(props: {
creator: User
@@ -20,10 +18,10 @@ export function ResolutionPanel(props: {
}) {
const { contract, className } = props
- const earnedFees =
- contract.mechanism === 'dpm-2'
- ? `${DPM_CREATOR_FEE * 100}% of trader profits`
- : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
+ // const earnedFees =
+ // contract.mechanism === 'dpm-2'
+ // ? `${DPM_CREATOR_FEE * 100}% of trader profits`
+ // : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
const [outcome, setOutcome] = useState()
@@ -86,16 +84,16 @@ export function ResolutionPanel(props: {
{outcome === 'YES' ? (
<>
Winnings will be paid out to YES bettors.
+ {/*
-
- You will earn {earnedFees}.
+ You will earn {earnedFees}. */}
>
) : outcome === 'NO' ? (
<>
Winnings will be paid out to NO bettors.
+ {/*
-
- You will earn {earnedFees}.
+ You will earn {earnedFees}. */}
>
) : outcome === 'CANCEL' ? (
<>All trades will be returned with no fees.>
@@ -106,7 +104,7 @@ export function ResolutionPanel(props: {
probabilityInt={Math.round(prob)}
setProbabilityInt={setProb}
/>
- You will earn {earnedFees}.
+ {/* You will earn {earnedFees}. */}
) : (
<>Resolving this market will immediately pay out traders.>
diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx
index 407983fc..56a041f1 100644
--- a/web/components/user-page.tsx
+++ b/web/components/user-page.tsx
@@ -29,6 +29,8 @@ import { formatMoney } from 'common/util/format'
import { ShareIconButton } from 'web/components/share-icon-button'
import { ENV_CONFIG } from 'common/envs/constants'
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: {
name: string
@@ -67,6 +69,7 @@ export function UserPage(props: { user: User }) {
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [showConfetti, setShowConfetti] = useState(false)
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
+ const [showLoansModal, setShowLoansModal] = useState(false)
useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes'
@@ -74,6 +77,9 @@ export function UserPage(props: { user: User }) {
setShowBettingStreakModal(showBettingStreak)
setShowConfetti(claimedMana || showBettingStreak)
+ const showLoansModel = router.query['show'] === 'loans'
+ setShowLoansModal(showLoansModel)
+
const query = { ...router.query }
if (query.claimedMana || query.show) {
delete query['claimed-mana']
@@ -106,6 +112,9 @@ export function UserPage(props: { user: User }) {
isOpen={showBettingStreakModal}
setOpen={setShowBettingStreakModal}
/>
+ {showLoansModal && (
+
+ )}
{/* Banner image up top, with an circle avatar overlaid */}
{!isCurrentUser &&
}
{isCurrentUser && (
-
+
{' '}
Edit
@@ -137,9 +146,14 @@ export function UserPage(props: { user: User }) {
{/* Profile details: name, username, bio, and link to twitter/discord */}
-
+
- {user.name}
+
+ {user.name}
+
@{user.username}
@@ -159,9 +173,20 @@ export function UserPage(props: { user: User }) {
className={'cursor-pointer items-center text-gray-500'}
onClick={() => setShowBettingStreakModal(true)}
>
- 🔥{user.currentBettingStreak ?? 0}
+ 🔥 {user.currentBettingStreak ?? 0}
streak
+ setShowLoansModal(true)}
+ >
+
+ 🏦 {formatMoney(user.nextLoanCached ?? 0)}
+
+ next loan
+
@@ -226,7 +251,7 @@ export function UserPage(props: { user: User }) {
)}
- {currentUser?.id === user.id && (
+ {currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
- Earn {formatMoney(500)} when you refer a friend!
+ Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
{' '}
You have
@@ -278,10 +303,7 @@ export function UserPage(props: { user: User }) {
>
- {currentUser &&
- ['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
- currentUser.username
- ) && }
+
),
diff --git a/web/hooks/use-admin.ts b/web/hooks/use-admin.ts
index 551c588b..aa566171 100644
--- a/web/hooks/use-admin.ts
+++ b/web/hooks/use-admin.ts
@@ -5,3 +5,7 @@ export const useAdmin = () => {
const privateUser = usePrivateUser()
return isAdmin(privateUser?.email || '')
}
+
+export const useDev = () => {
+ return process.env.NODE_ENV === 'development'
+}
diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts
index 2a8caaea..2b418658 100644
--- a/web/hooks/use-follows.ts
+++ b/web/hooks/use-follows.ts
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
+import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState()
@@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
return followerIds
}
+
+export const useContractFollows = (contractId: string) => {
+ const [followIds, setFollowIds] = useState()
+
+ useEffect(() => {
+ return listenForContractFollows(contractId, setFollowIds)
+ }, [contractId])
+
+ return followIds
+}
diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts
index 9df162bd..32500943 100644
--- a/web/hooks/use-notifications.ts
+++ b/web/hooks/use-notifications.ts
@@ -5,7 +5,7 @@ import {
getNotificationsQuery,
listenForNotifications,
} from 'web/lib/firebase/notifications'
-import { groupBy, map } from 'lodash'
+import { groupBy, map, partition } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
@@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString()
)
+ const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
+
Object.keys(notificationGroupsByDay).forEach((day) => {
const notificationsGroupedByDay = notificationGroupsByDay[day]
- const incomeNotifications = notificationsGroupedByDay.filter(
+ const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
+ notificationsGroupedByDay,
(notification) =>
- notification.sourceType === 'bonus' ||
- notification.sourceType === 'tip' ||
- notification.sourceType === 'betting_streak_bonus'
- )
- const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
- (notification) =>
- notification.sourceType !== 'bonus' &&
- notification.sourceType !== 'tip' &&
- notification.sourceType !== 'betting_streak_bonus'
+ incomeSourceTypes.includes(notification.sourceType ?? '')
)
if (incomeNotifications.length > 0) {
notificationGroups = notificationGroups.concat({
@@ -152,6 +147,7 @@ export function useUnseenPreferredNotifications(
const lessPriorityReasons = [
'on_contract_with_users_comment',
'on_contract_with_users_answer',
+ // Notifications not currently generated for users who've sold their shares
'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in',
diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts
index 1f83372e..6dc2ee3e 100644
--- a/web/lib/firebase/contracts.ts
+++ b/web/lib/firebase/contracts.ts
@@ -125,9 +125,10 @@ export async function listTaggedContractsCaseInsensitive(
export async function listAllContracts(
n: number,
- before?: string
+ before?: string,
+ sortDescBy = 'createdTime'
): Promise {
- let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
+ let q = query(contracts, orderBy(sortDescBy, 'desc'), limit(n))
if (before != null) {
const snap = await getDoc(doc(contracts, before))
q = query(q, startAfter(snap))
@@ -211,6 +212,29 @@ export function listenForContract(
return listenForValue(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) {
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
@@ -271,6 +295,26 @@ export async function getClosingSoonContracts() {
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(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) {
const contractDoc = doc(contracts, contract.id)
diff --git a/web/next-sitemap.js b/web/next-sitemap.js
deleted file mode 100644
index cd6c9c35..00000000
--- a/web/next-sitemap.js
+++ /dev/null
@@ -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
-}
diff --git a/web/package.json b/web/package.json
index a41591ed..db3fdf45 100644
--- a/web/package.json
+++ b/web/package.json
@@ -15,7 +15,6 @@
"start": "next start",
"lint": "next lint",
"format": "npx prettier --write .",
- "postbuild": "next-sitemap",
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
},
diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx
index c86f9c55..8250bde9 100644
--- a/web/pages/[username]/[contractSlug].tsx
+++ b/web/pages/[username]/[contractSlug].tsx
@@ -1,17 +1,17 @@
-import React, { useEffect, useMemo, useState } from 'react'
+import React, { useEffect, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline'
-import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractOverview } from 'web/components/contract/contract-overview'
import { BetPanel } from 'web/components/bet-panel'
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 { Spacer } from 'web/components/layout/spacer'
import {
Contract,
getContractFromSlug,
+ getRandTopCreatorContracts,
tradingAllowed,
} from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO'
@@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel'
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 { FullscreenConfetti } from 'web/components/fullscreen-confetti'
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 { AlertBox } from 'web/components/alert-box'
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 { User } from 'common/user'
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 {
+ 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 async function getStaticPropz(props: {
@@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id
- const [bets, comments] = await Promise.all([
+ const [bets, comments, recommendedContracts] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
+ contract
+ ? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
+ : [],
])
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.
bets: bets.slice(0, 5000),
comments: comments.slice(0, 1000),
+ recommendedContracts,
},
revalidate: 60, // regenerate after a minute
@@ -81,6 +84,7 @@ export default function ContractPage(props: {
bets: Bet[]
comments: ContractComment[]
slug: string
+ recommendedContracts: Contract[]
backToHome?: () => void
}) {
props = usePropz(props, getStaticPropz) ?? {
@@ -88,6 +92,7 @@ export default function ContractPage(props: {
username: '',
comments: [],
bets: [],
+ recommendedContracts: [],
slug: '',
}
@@ -149,7 +154,7 @@ export function ContractPageContent(
user?: User | null
}
) {
- const { backToHome, comments, user } = props
+ const { backToHome, comments, user, recommendedContracts } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
@@ -263,128 +268,13 @@ export function ContractPageContent(
comments={comments}
/>
+
+ {recommendedContracts.length > 0 && (
+
+
+
+
+ )}
)
}
-
-function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
- const { contract, bets } = props
- const [users, setUsers] = useState()
-
- 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 ? (
- 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 = {}
- 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 (
-
- {topCommentId && profitById[topCommentId] > 0 && (
- <>
-
-
-
-
-
- {commentsById[topCommentId].userName} made{' '}
- {formatMoney(profitById[topCommentId] || 0)}!
-
-
- >
- )}
-
- {/* If they're the same, only show the comment; otherwise show both */}
- {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
- <>
-
-
-
-
-
- {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
-
- >
- )}
-
- )
-}
diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx
index 81f23ba9..209b38a3 100644
--- a/web/pages/admin.tsx
+++ b/web/pages/admin.tsx
@@ -10,6 +10,7 @@ import { mapKeys } from 'lodash'
import { useAdmin } from 'web/hooks/use-admin'
import { contractPath } from 'web/lib/firebase/contracts'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
+import { firestoreConsolePath } from 'common/envs/constants'
export const getServerSideProps = redirectIfLoggedOut('/')
@@ -198,7 +199,7 @@ function ContractsTable() {
html(`${cell} `),
+ href="${firestoreConsolePath(cell as string)}">${cell}`),
},
]}
search={true}
diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts
index f0d9c443..968b770e 100644
--- a/web/pages/api/v0/_types.ts
+++ b/web/pages/api/v0/_types.ts
@@ -22,8 +22,6 @@ export type LiteMarket = {
// Market attributes. All times are in milliseconds since epoch
closeTime?: number
question: string
- description: string | JSONContent
- textDescription: string // string version of description
tags: string[]
url: string
outcomeType: string
@@ -54,6 +52,8 @@ export type FullMarket = LiteMarket & {
bets: Bet[]
comments: Comment[]
answers?: ApiAnswer[]
+ description: string | JSONContent
+ textDescription: string // string version of description
}
export type ApiError = {
@@ -81,7 +81,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
creatorAvatarUrl,
closeTime,
question,
- description,
tags,
slug,
pool,
@@ -118,11 +117,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
? Math.min(resolutionTime, closeTime)
: closeTime,
question,
- description,
- textDescription:
- typeof description === 'string'
- ? description
- : richTextToString(description),
tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`,
pool,
@@ -158,11 +152,18 @@ export function toFullMarket(
)
: undefined
+ const { description } = contract
+
return {
...liteMarket,
answers,
comments,
bets,
+ description,
+ textDescription:
+ typeof description === 'string'
+ ? description
+ : richTextToString(description),
}
}
diff --git a/web/pages/api/v0/markets.ts b/web/pages/api/v0/markets.ts
index 56ecc594..78c54772 100644
--- a/web/pages/api/v0/markets.ts
+++ b/web/pages/api/v0/markets.ts
@@ -10,7 +10,7 @@ const queryParams = z
.object({
limit: z
.number()
- .default(1000)
+ .default(500)
.or(z.string().regex(/\d+/).transform(Number))
.refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'),
before: z.string().optional(),
diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx
index e9014bfb..0bc6f0f8 100644
--- a/web/pages/charity/index.tsx
+++ b/web/pages/charity/index.tsx
@@ -39,8 +39,8 @@ export async function getStaticProps() {
])
const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
- const mostRecentDonor = await getUser(txns[0].fromId)
- const mostRecentCharity = txns[0].toId
+ const mostRecentDonor = txns[0] ? await getUser(txns[0].fromId) : null
+ const mostRecentCharity = txns[0]?.toId ?? ''
return {
props: {
@@ -94,8 +94,8 @@ export default function Charity(props: {
matches: { [charityId: string]: number }
txns: Txn[]
numDonors: number
- mostRecentDonor: User
- mostRecentCharity: string
+ mostRecentDonor?: User | null
+ mostRecentCharity?: string
}) {
const {
totalRaised,
@@ -159,8 +159,8 @@ export default function Charity(props: {
},
{
name: 'Most recent donor',
- stat: mostRecentDonor.name ?? 'Nobody',
- url: `/${mostRecentDonor.username}`,
+ stat: mostRecentDonor?.name ?? 'Nobody',
+ url: `/${mostRecentDonor?.username}`,
},
{
name: 'Most recent donation',
diff --git a/web/pages/create.tsx b/web/pages/create.tsx
index d7422ff1..0c142d67 100644
--- a/web/pages/create.tsx
+++ b/web/pages/create.tsx
@@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts'
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 { Page } from 'web/components/page'
import { Row } from 'web/components/layout/row'
@@ -158,6 +158,8 @@ export function NewContract(props: {
: undefined
const balance = creator.balance || 0
+ const deservesFreeMarket =
+ (creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
const min = minString ? parseFloat(minString) : undefined
const max = maxString ? parseFloat(maxString) : undefined
@@ -177,7 +179,7 @@ export function NewContract(props: {
question.length > 0 &&
ante !== undefined &&
ante !== null &&
- ante <= balance &&
+ (ante <= balance || deservesFreeMarket) &&
// closeTime must be in the future
closeTime &&
closeTime > Date.now() &&
@@ -207,6 +209,7 @@ export function NewContract(props: {
max: MAX_DESCRIPTION_LENGTH,
placeholder: descriptionPlaceholder,
disabled: isSubmitting,
+ defaultValue: JSON.parse(params?.description ?? '{}'),
})
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.`}
/>
+ {!deservesFreeMarket ? (
+
+ {formatMoney(ante)}
+
+ ) : (
+
+
+ FREE{' '}
+
+ (You have{' '}
+ {FREE_MARKETS_PER_USER_MAX -
+ (creator?.freeMarketsCreated ?? 0)}{' '}
+ free markets left)
+
+
+
+ )}
-
- {formatMoney(ante)}
-
-
- {ante > balance && (
+ {ante > balance && !deservesFreeMarket && (
Insufficient balance
diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx
new file mode 100644
index 00000000..380f4286
--- /dev/null
+++ b/web/pages/experimental/home.tsx
@@ -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 (
+
+
+
+
+
+ {memberGroups.map((group) => (
+
+ ))}
+
+ {
+ router.push('/create')
+ track('mobile create button')
+ }}
+ >
+
+
+
+ )
+}
+
+function SearchSection(props: {
+ label: string
+ user: User | null
+ sort: Sort
+}) {
+ const { label, user, sort } = props
+
+ const router = useRouter()
+
+ return (
+
+
+
+
+
router.push(`/home?s=${sort}`)}
+ >
+ See more
+
+
+ )
+}
+
+function GroupSection(props: { group: Group; user: User | null }) {
+ const { group, user } = props
+
+ const router = useRouter()
+
+ return (
+
+
+
+
+
router.push(`/group/${group.slug}`)}
+ >
+ See more
+
+
+ )
+}
+
+export default Home
diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx
index 20b1a8ce..6ce3e7c3 100644
--- a/web/pages/group/[...slugs]/index.tsx
+++ b/web/pages/group/[...slugs]/index.tsx
@@ -33,12 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
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 { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline'
-import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
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 { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
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 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 group = useGroup(props.group?.id) ?? props.group
- const tips = useTipTxns({ groupId: group?.id })
-
- const messages = useCommentsOnGroup(group?.id) ?? props.messages
const user = useUser()
@@ -201,21 +195,12 @@ export default function GroupPage(props: {
/>
)
- const chatTab = (
-
- )
-
const tabs = [
{
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
},
- {
- title: 'Chat',
- content: chatTab,
- href: groupPath(group.slug, 'chat'),
- },
{
title: 'Leaderboards',
content: leaderboard,
diff --git a/web/pages/home.tsx b/web/pages/home.tsx
index e61d5c32..3aa791ab 100644
--- a/web/pages/home.tsx
+++ b/web/pages/home.tsx
@@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
backToHome={() => {
history.back()
}}
+ recommendedContracts={[]}
/>
)}
>
diff --git a/web/pages/links.tsx b/web/pages/links.tsx
index 351abefb..6f57dc14 100644
--- a/web/pages/links.tsx
+++ b/web/pages/links.tsx
@@ -25,8 +25,8 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination'
import { Manalink } from 'common/manalink'
-import { REFERRAL_AMOUNT } from 'common/user'
import { SiteLink } from 'web/components/site-link'
+import { REFERRAL_AMOUNT } from 'common/economy'
const LINKS_PER_PAGE = 24
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx
index 9541ee5b..0fe3b179 100644
--- a/web/pages/notifications.tsx
+++ b/web/pages/notifications.tsx
@@ -34,7 +34,7 @@ import { groupPath } from 'web/lib/firebase/groups'
import {
BETTING_STREAK_BONUS_AMOUNT,
UNIQUE_BETTOR_BONUS_AMOUNT,
-} from 'common/numeric-constants'
+} from 'common/economy'
import { groupBy, sum, uniq } from 'lodash'
import { track } from '@amplitude/analytics-browser'
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 { NotificationSettings } from 'web/components/NotificationSettings'
import { SEO } from 'web/components/SEO'
+import { useUser } from 'web/hooks/use-user'
export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers'
@@ -165,7 +166,7 @@ function NotificationsList(props: {
if (!paginatedGroupedNotifications || !allGroupedNotifications) return
return (
-
+
{paginatedGroupedNotifications.length === 0 && (
You don't have any notifications. Try changing your settings to see
@@ -271,9 +272,17 @@ function IncomeNotificationGroupItem(props: {
}
return newNotifications
}
-
- const combinedNotifs =
- combineNotificationsByAddingNumericSourceTexts(notifications)
+ const combinedNotifs = combineNotificationsByAddingNumericSourceTexts(
+ notifications.filter((n) => n.sourceType !== 'betting_streak_bonus')
+ )
+ // 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 (
{
setNotificationsAsSeen([notification])
}, [notification])
@@ -388,20 +399,30 @@ function IncomeNotificationItem(props: {
reasonText = !simple ? `tipped you on` : `in tips on`
} else if (sourceType === 'betting_streak_bonus') {
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 =
sourceType === 'betting_streak_bonus' &&
- (sourceText
- ? `🔥 ${
- parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
- } day Betting Streak`
- : 'Betting Streak')
+ (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
return (
<>
{reasonText}
- {sourceType === 'betting_streak_bonus' ? (
+ {sourceType === 'loan' ? (
+ simple ? (
+
🏦 Loan
+ ) : (
+
+ 🏦 Loan
+
+ )
+ ) : sourceType === 'betting_streak_bonus' ? (
simple ? (
{bettingStreakText}
) : (
@@ -445,6 +466,7 @@ function IncomeNotificationItem(props: {
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak`
+ if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',
diff --git a/web/pages/referrals.tsx b/web/pages/referrals.tsx
index c30418cf..2e330980 100644
--- a/web/pages/referrals.tsx
+++ b/web/pages/referrals.tsx
@@ -5,11 +5,11 @@ import { useUser } from 'web/hooks/use-user'
import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
-import { REFERRAL_AMOUNT } from 'common/user'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { InfoBox } from 'web/components/info-box'
import { QRCode } from 'web/components/qr-code'
+import { REFERRAL_AMOUNT } from 'common/economy'
export const getServerSideProps = redirectIfLoggedOut('/')
diff --git a/web/pages/server-sitemap.xml.tsx b/web/pages/server-sitemap.xml.tsx
index 246bb9ee..15cb734c 100644
--- a/web/pages/server-sitemap.xml.tsx
+++ b/web/pages/server-sitemap.xml.tsx
@@ -1,24 +1,21 @@
-import { sortBy } from 'lodash'
import { GetServerSideProps } from 'next'
import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
-import { DOMAIN } from 'common/envs/constants'
-import { LiteMarket } from './api/v0/_types'
+import { listAllContracts } from 'web/lib/firebase/contracts'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
- // Fetching data from https://manifold.markets/api
- const response = await fetch(`https://${DOMAIN}/api/v0/markets`)
+ const contracts = await listAllContracts(1000, undefined, 'popularityScore')
- const liteMarkets = (await response.json()) as LiteMarket[]
- const sortedMarkets = sortBy(liteMarkets, (m) => -m.volume24Hours)
+ const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1))
- const fields = sortedMarkets.map((market) => ({
- // See https://www.sitemaps.org/protocol.html
- loc: market.url,
- changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
- priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1,
- // TODO: Add `lastmod` aka last modified time
- })) as ISitemapField[]
+ const fields = contracts
+ .sort((x) => x.popularityScore ?? 0)
+ .map((market) => ({
+ loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`,
+ changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
+ priority: score(market.popularityScore ?? 0),
+ lastmod: market.lastUpdatedTime,
+ })) as ISitemapField[]
return await getServerSideSitemap(ctx, fields)
}
diff --git a/web/public/sitemap-0.xml b/web/public/sitemap-0.xml
deleted file mode 100644
index d0750f46..00000000
--- a/web/public/sitemap-0.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-https://manifold.markets hourly 1.0
-https://manifold.markets/home hourly 0.2
-https://manifold.markets/leaderboards daily 0.2
-
diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml
index 050639f2..c52d0c0e 100644
--- a/web/public/sitemap.xml
+++ b/web/public/sitemap.xml
@@ -1,4 +1,10 @@
-
-https://manifold.markets/sitemap-0.xml
-
\ No newline at end of file
+
+https://manifold.markets hourly 1.0
+https://manifold.markets/home hourly 0.2
+https://manifold.markets/leaderboards daily 0.2
+https://manifold.markets/add-funds daily 0.2
+https://manifold.markets/challenges daily 0.2
+https://manifold.markets/charity daily 0.7
+https://manifold.markets/groups daily 0.2
+