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 (
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 ( + + ) +} 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 ? ( +
) : ( )} + ) } 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 ( + + ) +} 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)} >
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 && ( )} 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 && ( - <> - - <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> - <FeedComment - contract={contract} - comment={commentsById[topCommentId]} - tips={tips[topCommentId]} - betsBySameUser={[betsById[topCommentId]]} - smallAvatar={false} - /> - </div> - <div className="mt-2 text-sm text-gray-500"> - {commentsById[topCommentId].userName} made{' '} - {formatMoney(profitById[topCommentId] || 0)}! - </div> - <Spacer h={16} /> - </> - )} - - {/* If they're the same, only show the comment; otherwise show both */} - {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( - <> - <Title text="💸 Smartest money" className="!mt-0" /> - <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> - <FeedBet - contract={contract} - bet={betsById[topBetId]} - hideOutcome={false} - smallAvatar={false} - /> - </div> - <div className="mt-2 text-sm text-gray-500"> - {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! - </div> - </> - )} - </div> - ) -} 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(`<a class="hover:underline hover:decoration-indigo-400 hover:decoration-2" target="_blank" - href="https://console.firebase.google.com/project/mantic-markets/firestore/data/~2Fcontracts~2F${cell}">${cell}</a>`), + href="${firestoreConsolePath(cell as string)}">${cell}</a>`), }, ]} search={true} 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.`} /> </label> + {!deservesFreeMarket ? ( + <div className="label-text text-neutral pl-1"> + {formatMoney(ante)} + </div> + ) : ( + <div> + <div className="label-text text-primary pl-1"> + FREE{' '} + <span className="label-text pl-1 text-gray-500"> + (You have{' '} + {FREE_MARKETS_PER_USER_MAX - + (creator?.freeMarketsCreated ?? 0)}{' '} + free markets left) + </span> + </div> + </div> + )} - <div className="label-text text-neutral pl-1"> - {formatMoney(ante)} - </div> - - {ante > balance && ( + {ante > balance && !deservesFreeMarket && ( <div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide"> <span className="mr-2 text-red-500">Insufficient balance</span> <button diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 83d83871..7ec8daeb 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { contract={contract} bets={bets} isCreator={false} + user={null} disabled /> 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 ( + <Page> + <Col className="mx-auto mb-8 w-full"> + <SearchSection label="Trending" sort="score" user={user} /> + <SearchSection label="Newest" sort="newest" user={user} /> + <SearchSection label="Closing soon" sort="close-date" user={user} /> + {memberGroups.map((group) => ( + <GroupSection key={group.id} group={group} user={user} /> + ))} + </Col> + <button + type="button" + className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" + onClick={() => { + router.push('/create') + track('mobile create button') + }} + > + <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> + </button> + </Page> + ) +} + +function SearchSection(props: { + label: string + user: User | null + sort: Sort +}) { + const { label, user, sort } = props + + const router = useRouter() + + return ( + <Col> + <Title className="mx-2 !text-gray-800 sm:mx-0" text={label} /> + <Spacer h={2} /> + <ContractSearch user={user} defaultSort={sort} maxItems={4} noControls /> + <Button + className="self-end" + color="blue" + size="sm" + onClick={() => router.push(`/home?s=${sort}`)} + > + See more + </Button> + </Col> + ) +} + +function GroupSection(props: { group: Group; user: User | null }) { + const { group, user } = props + + const router = useRouter() + + return ( + <Col className=""> + <Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} /> + <Spacer h={2} /> + <ContractSearch + user={user} + defaultSort={'score'} + additionalFilter={{ groupSlug: group.slug }} + maxItems={4} + noControls + /> + <Button + className="mr-2 self-end" + color="blue" + size="sm" + onClick={() => router.push(`/group/${group.slug}`)} + > + See more + </Button> + </Col> + ) +} + +export default Home 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 = ( - <GroupChat messages={messages} group={group} user={user} tips={tips} /> - ) - 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 <div /> return ( - <div className={'min-h-[100vh]'}> + <div className={'min-h-[100vh] text-sm'}> {paginatedGroupedNotifications.length === 0 && ( <div className={'mt-2'}> 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 ( <div @@ -370,6 +379,8 @@ function IncomeNotificationItem(props: { const [highlighted] = useState(!notification.isSeen) const { width } = useWindowSize() const isMobile = (width && width < 768) || false + const user = useUser() + useEffect(() => { 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 ? ( + <span className={'ml-1 font-bold'}>🏦 Loan</span> + ) : ( + <SiteLink className={'ml-1 font-bold'} href={'/loans'}> + 🏦 Loan + </SiteLink> + ) + ) : sourceType === 'betting_streak_bonus' ? ( simple ? ( <span className={'ml-1 font-bold'}>{bettingStreakText}</span> ) : ( @@ -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 @@ -<?xml version="1.0" encoding="UTF-8"?> -<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"> -<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url> -<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url> -<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url> -</urlset> 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 @@ <?xml version="1.0" encoding="UTF-8"?> -<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> -<sitemap><loc>https://manifold.markets/sitemap-0.xml</loc></sitemap> -</sitemapindex> \ No newline at end of file +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"> +<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url> +<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url> +<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url> +<url><loc>https://manifold.markets/add-funds</loc><changefreq>daily</changefreq><priority>0.2</priority></url> +<url><loc>https://manifold.markets/challenges</loc><changefreq>daily</changefreq><priority>0.2</priority></url> +<url><loc>https://manifold.markets/charity</loc><changefreq>daily</changefreq><priority>0.7</priority></url> +<url><loc>https://manifold.markets/groups</loc><changefreq>daily</changefreq><priority>0.2</priority></url> +</urlset>