From d18dd5b8fbbb4cbefd508141557e59650dfb72b1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 21 Aug 2022 15:58:49 -0500 Subject: [PATCH 01/70] Fix a case of limit order sorting --- web/components/limit-bets.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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: { From aa3647e0f316aada5ccd74f2e39884e8d70922ca Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Sun, 21 Aug 2022 17:10:58 -0700 Subject: [PATCH 02/70] Handle case when no charity txns --- web/pages/charity/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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', From 258b2a318fa626df536ca31ce178ec6ea6af4919 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Sun, 21 Aug 2022 21:02:56 -0700 Subject: [PATCH 03/70] Default to showing weekly bet graph --- web/components/portfolio/portfolio-value-section.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 && ( )} From 3158740ea3195bcf2146a4e5eb9c2d65179444d4 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Sun, 21 Aug 2022 22:13:42 -0700 Subject: [PATCH 04/70] Minor tweaks for custom instances --- web/components/nav/sidebar.tsx | 9 ++++++++- web/components/user-page.tsx | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 6c4addc4..056ab78a 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -58,7 +58,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) { diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 407983fc..2aa837e9 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -29,6 +29,7 @@ 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/user' export function UserLink(props: { name: string @@ -226,7 +227,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 From 8b7cd20b6fe0a8b09e53285b35abfbae7f0e33d3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 22 Aug 2022 00:22:49 -0500 Subject: [PATCH 05/70] Loans2: Bets return you some capital every week (#588) * Remove some old loan code * Almost complete implementation of updateLoans cloud function * Merge fixes * Use invested instead of sale value, check if eligible, perform payouts * Run monday 12am * Implement loan income notification * Fix imports * Loan update fixes / debug * Handle NaN and negative loan calcs * Working loan notification * Loan modal! * Move loans calculation to /common * Better layout * Pay back loan on sell shares * Pay back fraction of loan on redeem * Sell bet loan: negate buy bet's loan * Modal tweaks * Compute and store nextLoanCached for all users * lint * Update loans with newest portfolio * Filter loans to only unresolved contracts * Tweak spacing * Increase memory --- common/bet.ts | 2 - common/loans.ts | 138 ++++++++++++++++++ common/new-bet.ts | 20 +-- common/notification.ts | 2 + common/redeem.ts | 3 +- common/sell-bet.ts | 8 +- common/user.ts | 1 + functions/src/create-answer.ts | 4 +- functions/src/create-notification.ts | 26 ++++ functions/src/create-user.ts | 1 + functions/src/index.ts | 1 + functions/src/place-bet.ts | 3 +- functions/src/sell-bet.ts | 3 +- functions/src/sell-shares.ts | 30 ++-- functions/src/update-loans.ts | 88 +++++++++++ functions/src/update-metrics.ts | 82 +++++++---- .../profile/betting-streak-modal.tsx | 2 +- web/components/profile/loans-modal.tsx | 47 ++++++ web/components/user-page.tsx | 32 +++- web/hooks/use-notifications.ts | 17 +-- web/pages/notifications.tsx | 13 +- 21 files changed, 436 insertions(+), 87 deletions(-) create mode 100644 common/loans.ts create mode 100644 functions/src/update-loans.ts create mode 100644 web/components/profile/loans-modal.tsx 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/loans.ts b/common/loans.ts new file mode 100644 index 00000000..64742b3e --- /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_WEEKLY_RATE = 0.05 + +const calculateNewLoan = (investedValue: number, loanTotal: number) => { + const netValue = investedValue - loanTotal + return netValue * LOAN_WEEKLY_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 (isNaN(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 (isNaN(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..0a69f89d 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,4 @@ export type notification_reason_types = | 'user_joined_from_your_group_invite' | 'challenge_accepted' | 'betting_streak_incremented' + | 'loan_income' 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..4b21fe2d 100644 --- a/common/user.ts +++ b/common/user.ts @@ -32,6 +32,7 @@ export type User = { allTime: number } + nextLoanCached: number followerCountCached: number followedCategories?: string[] 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-notification.ts b/functions/src/create-notification.ts index 90250e73..3fb1f9c3 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -471,6 +471,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..f42fb5c3 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -75,6 +75,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, diff --git a/functions/src/index.ts b/functions/src/index.ts index 4d7cf42b..b0ad50fa 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' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 780b50d6..44a96210 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -59,7 +59,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 +118,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) 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..d9f99de3 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -7,7 +7,7 @@ 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' @@ -28,12 +28,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 +49,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 +81,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 +106,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, diff --git a/functions/src/update-loans.ts b/functions/src/update-loans.ts new file mode 100644 index 00000000..fd89b643 --- /dev/null +++ b/functions/src/update-loans.ts @@ -0,0 +1,88 @@ +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 Monday. + .pubsub.schedule('0 0 * * 1') + .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.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/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index eb90f6d9..732292f1 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -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..c8d30b4e --- /dev/null +++ b/web/components/profile/loans-modal.tsx @@ -0,0 +1,47 @@ +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 ( + + + 🏦 + Loans on your bets + + • What are loans? + + Every Monday, get 5% 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$100 on "Will I become a millionare?" on + Sunday, you will get M$5 back on Monday. + + + Previous loans count against your total bet amount. So, the next + week, you would get back 5% of M$95 = M$4.75. + + + + + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 2aa837e9..dc68898f 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -29,6 +29,7 @@ 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 { LoansModal } from './profile/loans-modal' import { REFERRAL_AMOUNT } from 'common/user' export function UserLink(props: { @@ -68,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' @@ -75,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'] @@ -107,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
@@ -138,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} @@ -160,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 + diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 9df162bd..ecc4ce2a 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({ diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9541ee5b..7ec5e1ea 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -388,6 +388,8 @@ 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` } const bettingStreakText = @@ -401,7 +403,15 @@ function IncomeNotificationItem(props: { return ( <> {reasonText} - {sourceType === 'betting_streak_bonus' ? ( + {sourceType === 'loan' ? ( + simple ? ( + Loan + ) : ( + + Loan + + ) + ) : sourceType === 'betting_streak_bonus' ? ( simple ? ( {bettingStreakText} ) : ( @@ -445,6 +455,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 ?? '', From 88bf678ce32a2097bb7532f993d5d2dca62065a7 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Sun, 21 Aug 2022 22:37:26 -0700 Subject: [PATCH 06/70] Allow custom environments to override any economic aspect (#787) * Extract monetary constants to a single file economy.ts * Add missing import * Allow environments to override any econ variable * Update imports * Update more imports * Fix import --- common/antes.ts | 3 --- common/economy.ts | 16 +++++++++++++++ common/envs/prod.ts | 20 +++++++++++++++---- common/numeric-constants.ts | 4 ---- common/user.ts | 7 ------- functions/src/create-market.ts | 2 +- functions/src/create-user.ts | 3 +-- functions/src/on-create-bet.ts | 2 +- functions/src/on-update-user.ts | 3 ++- functions/src/reset-betting-streaks.ts | 2 +- functions/src/scripts/create-private-users.ts | 3 ++- .../challenges/create-challenge-modal.tsx | 2 +- web/components/contract/share-modal.tsx | 3 ++- .../profile/betting-streak-modal.tsx | 2 +- web/components/user-page.tsx | 2 +- web/pages/create.tsx | 2 +- web/pages/group/[...slugs]/index.tsx | 2 +- web/pages/links.tsx | 2 +- web/pages/notifications.tsx | 2 +- web/pages/referrals.tsx | 2 +- 20 files changed, 50 insertions(+), 34 deletions(-) create mode 100644 common/economy.ts 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/economy.ts b/common/economy.ts new file mode 100644 index 00000000..cd40f87c --- /dev/null +++ b/common/economy.ts @@ -0,0 +1,16 @@ +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 ?? 5 +export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100 +export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 5bd12095..033d050f 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -19,10 +19,22 @@ 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 } type FirebaseConfig = { 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/user.ts b/common/user.ts index 4b21fe2d..dee1413f 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 @@ -46,11 +44,6 @@ export type User = { currentBettingStreak?: 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 diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 5b0d1daf..c3780a1f 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -18,8 +18,8 @@ import { randomString } from '../../common/util/random' import { chargeUser, getContract } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' +import { FIXED_ANTE } from 'common/economy' import { - FIXED_ANTE, getCpmmInitialLiquidity, getFreeAnswerAnte, getMultipleChoiceAntes, diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index f42fb5c3..54e37d62 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -6,8 +6,6 @@ import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, PrivateUser, - STARTING_BALANCE, - SUS_STARTING_BALANCE, User, } from '../../common/user' import { getUser, getUserByUsername, getValues, isProd } from './utils' @@ -29,6 +27,7 @@ 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(), 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-update-user.ts b/functions/src/on-update-user.ts index a76132b5..3dc09a1b 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -1,6 +1,6 @@ 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' @@ -8,6 +8,7 @@ import { Contract } from '../../common/contract' import { LimitBet } from 'common/bet' import { QuerySnapshot } from 'firebase-admin/firestore' 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/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index e1c3af8f..c781aba2 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -4,7 +4,7 @@ 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 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/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/share-modal.tsx b/web/components/contract/share-modal.tsx index e1805364..2c74a5a4 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -14,9 +14,10 @@ import { Button } from '../button' import { copyToClipboard } from 'web/lib/util/copy' import { track } from 'web/lib/service/analytics' import { ENV_CONFIG } from 'common/envs/constants' -import { REFERRAL_AMOUNT, User } from 'common/user' +import { User } from 'common/user' import { SiteLink } from '../site-link' import { formatMoney } from 'common/util/format' +import { REFERRAL_AMOUNT } from 'common/economy' export function ShareModal(props: { contract: Contract diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 732292f1..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: { diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index dc68898f..92404f89 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -29,8 +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' -import { REFERRAL_AMOUNT } from 'common/user' export function UserLink(props: { name: string diff --git a/web/pages/create.tsx b/web/pages/create.tsx index d7422ff1..52f2a373 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 } from 'common/economy' import { InfoTooltip } from 'web/components/info-tooltip' import { Page } from 'web/components/page' import { Row } from 'web/components/layout/row' diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 20b1a8ce..8926d0ab 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -34,7 +34,6 @@ 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' @@ -48,6 +47,7 @@ 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[] } }) { 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 7ec5e1ea..ccfcf1b3 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' 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('/') From b7790a96785c4afa31bb1e61e77cf3f3fdb9db0e Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Sun, 21 Aug 2022 22:53:02 -0700 Subject: [PATCH 07/70] Show Referrals count for each user --- web/components/user-page.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 92404f89..56a041f1 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -303,10 +303,7 @@ export function UserPage(props: { user: User }) { > - {currentUser && - ['ian', 'Austin', 'SG', 'JamesGrugett'].includes( - currentUser.username - ) && } + ), From 571d3e71b52a2d565296aa2120252610bf3dcd03 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 06:31:30 -0600 Subject: [PATCH 08/70] Only show most recent streak notif, relative econ imports, pubsub emulator --- functions/package.json | 2 +- functions/src/create-market.ts | 2 +- functions/src/create-user.ts | 2 +- functions/src/on-update-user.ts | 6 +++--- web/pages/notifications.tsx | 14 +++++++++++--- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/functions/package.json b/functions/package.json index d6278c25..63ef9b5d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -14,7 +14,7 @@ "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", + "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-market.ts b/functions/src/create-market.ts index c3780a1f..ae120c43 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -18,7 +18,7 @@ import { randomString } from '../../common/util/random' import { chargeUser, getContract } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' -import { FIXED_ANTE } from 'common/economy' +import { FIXED_ANTE } from '../../common/economy' import { getCpmmInitialLiquidity, getFreeAnswerAnte, diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 54e37d62..216a7eb4 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -27,7 +27,7 @@ import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' -import { SUS_STARTING_BALANCE, STARTING_BALANCE } from 'common/economy' +import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' const bodySchema = z.object({ deviceToken: z.string().optional(), diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index 3dc09a1b..b45809d0 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,10 +5,10 @@ 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 { REFERRAL_AMOUNT } from 'common/economy' +import { Group } from '../../common/group' +import { REFERRAL_AMOUNT } from '../../common/economy' const firestore = admin.firestore() export const onUpdateUser = functions.firestore diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index ccfcf1b3..971201e8 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -271,9 +271,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 (
Date: Mon, 22 Aug 2022 10:20:22 -0500 Subject: [PATCH 09/70] Check loans calc for isFinite --- common/loans.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/loans.ts b/common/loans.ts index 64742b3e..46c491b5 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -101,7 +101,7 @@ const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => { const oldestBet = minBy(bets, (bet) => bet.createdTime) const newLoan = calculateNewLoan(invested, loanAmount) - if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined + if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan @@ -125,7 +125,7 @@ const getFreeResponseContractLoanUpdate = ( const newLoan = calculateNewLoan(bet.amount, loanAmount) const loanTotal = loanAmount + newLoan - if (isNaN(newLoan) || newLoan <= 0) return undefined + if (!isFinite(newLoan) || newLoan <= 0) return undefined return { userId: bet.userId, From 8ea9a79760ea3f24a7357b656c8877540d635300 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 10:31:23 -0500 Subject: [PATCH 10/70] loan emoji --- web/pages/notifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 971201e8..116c367d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -413,10 +413,10 @@ function IncomeNotificationItem(props: { {reasonText} {sourceType === 'loan' ? ( simple ? ( - Loan + 🏦 Loan ) : ( - Loan + 🏦 Loan ) ) : sourceType === 'betting_streak_bonus' ? ( From 40a22b31f30699cc9193d409217e95c66b32fd6c Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 11:52:05 -0500 Subject: [PATCH 11/70] fix sitemap --- web/pages/server-sitemap.xml.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/web/pages/server-sitemap.xml.tsx b/web/pages/server-sitemap.xml.tsx index 246bb9ee..a6673dd4 100644 --- a/web/pages/server-sitemap.xml.tsx +++ b/web/pages/server-sitemap.xml.tsx @@ -1,23 +1,18 @@ -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) - 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, + const fields = contracts.map((market) => ({ + loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`, changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily', - priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1, - // TODO: Add `lastmod` aka last modified time + priority: score(market.popularityScore ?? 0), + lastmod: market.lastUpdatedTime, })) as ISitemapField[] return await getServerSideSitemap(ctx, fields) From 7a0d64e72ff310d16124655dba84d8e3ffae409e Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 12:02:08 -0500 Subject: [PATCH 12/70] host sitemap manually (delete nextjs sitemap) --- web/next-sitemap.js | 15 --------------- web/public/sitemap-0.xml | 6 ------ web/public/sitemap.xml | 12 +++++++++--- 3 files changed, 9 insertions(+), 24 deletions(-) delete mode 100644 web/next-sitemap.js delete mode 100644 web/public/sitemap-0.xml 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/public/sitemap-0.xml b/web/public/sitemap-0.xml deleted file mode 100644 index d0750f46..00000000 --- a/web/public/sitemap-0.xml +++ /dev/null @@ -1,6 +0,0 @@ - - -https://manifold.marketshourly1.0 -https://manifold.markets/homehourly0.2 -https://manifold.markets/leaderboardsdaily0.2 - diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml index 050639f2..c52d0c0e 100644 --- a/web/public/sitemap.xml +++ b/web/public/sitemap.xml @@ -1,4 +1,10 @@ - -https://manifold.markets/sitemap-0.xml - \ No newline at end of file + +https://manifold.marketshourly1.0 +https://manifold.markets/homehourly0.2 +https://manifold.markets/leaderboardsdaily0.2 +https://manifold.markets/add-fundsdaily0.2 +https://manifold.markets/challengesdaily0.2 +https://manifold.markets/charitydaily0.7 +https://manifold.markets/groupsdaily0.2 + From 009c85b61a08c97fda131d958812cba3d9b1ba07 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 12:07:05 -0500 Subject: [PATCH 13/70] listAllContracts: order by popularity score --- web/lib/firebase/contracts.ts | 2 +- web/pages/server-sitemap.xml.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 1f83372e..453ec697 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -127,7 +127,7 @@ export async function listAllContracts( n: number, before?: string ): Promise { - let q = query(contracts, orderBy('createdTime', 'desc'), limit(n)) + let q = query(contracts, orderBy('popularityScore', 'desc'), limit(n)) if (before != null) { const snap = await getDoc(doc(contracts, before)) q = query(q, startAfter(snap)) diff --git a/web/pages/server-sitemap.xml.tsx b/web/pages/server-sitemap.xml.tsx index a6673dd4..0027c4dc 100644 --- a/web/pages/server-sitemap.xml.tsx +++ b/web/pages/server-sitemap.xml.tsx @@ -8,12 +8,14 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1)) - const fields = contracts.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[] + 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) } From 2530171721bcf40bf006e0505b92b6cbfa5920e3 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 12:09:16 -0500 Subject: [PATCH 14/70] don't run next-sitemap post build --- web/package.json | 1 - 1 file changed, 1 deletion(-) 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" }, From 0cd61eb214b4e62d1cf2b52e53ec79e5289d48b2 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 10:48:21 -0700 Subject: [PATCH 15/70] DX: Link to Firestore console from "..." --- common/envs/constants.ts | 4 ++++ .../contract/contract-info-dialog.tsx | 19 ++++++++++++++++++- web/hooks/use-admin.ts | 4 ++++ web/pages/admin.tsx | 3 ++- 4 files changed, 28 insertions(+), 2 deletions(-) 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/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index be24d0b5..63c9ac72 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -13,6 +13,9 @@ import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Title } from '../title' import { InfoTooltip } from '../info-tooltip' +import { useAdmin, useDev } from 'web/hooks/use-admin' +import { SiteLink } from '../site-link' +import { firestoreConsolePath } from 'common/envs/constants' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -21,10 +24,12 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const [open, setOpen] = useState(false) + const isDev = useDev() + const isAdmin = useAdmin() const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z') - const { createdTime, closeTime, resolutionTime, mechanism, outcomeType } = + const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = contract const tradersCount = uniqBy( @@ -121,6 +126,18 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { {contractPool(contract)} + + {/* Show a path to Firebase if user is an admin, or we're on localhost */} + {(isAdmin || isDev) && ( + + [DEV] Firestore + + + Console link + + + + )} 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/pages/admin.tsx b/web/pages/admin.tsx index 81f23ba9..209b38a3 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -10,6 +10,7 @@ import { mapKeys } from 'lodash' import { useAdmin } from 'web/hooks/use-admin' import { contractPath } from 'web/lib/firebase/contracts' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { firestoreConsolePath } from 'common/envs/constants' export const getServerSideProps = redirectIfLoggedOut('/') @@ -198,7 +199,7 @@ function ContractsTable() { html(`${cell}`), + href="${firestoreConsolePath(cell as string)}">${cell}`), }, ]} search={true} From 7736f1e3c190d71fa7054a0b79ad737256473527 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 10:49:54 -0700 Subject: [PATCH 16/70] Make duplicating better: description, closetime, logscale Known issue: some markets like https://manifold.markets/FFSX/rojo-ronald-jones don't duplicate because too much stuff in JSON...? --- web/components/copy-contract-button.tsx | 17 ++++++++++++----- web/pages/create.tsx | 1 + 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx index 8536df71..cb23776c 100644 --- a/web/components/copy-contract-button.tsx +++ b/web/components/copy-contract-button.tsx @@ -33,22 +33,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/pages/create.tsx b/web/pages/create.tsx index 52f2a373..2ec86bb7 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -207,6 +207,7 @@ export function NewContract(props: { max: MAX_DESCRIPTION_LENGTH, placeholder: descriptionPlaceholder, disabled: isSubmitting, + defaultValue: JSON.parse(params?.description ?? '{}'), }) const isEditorFilled = editor != null && !editor.isEmpty From 650aa68bcd8c596382621a3983a119a3cb2bcb00 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 11:31:33 -0700 Subject: [PATCH 17/70] Fix imports --- web/components/copy-contract-button.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx index cb23776c..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: { From 3313b55853c83be7746f9b8d011fc0cf885afc91 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 14:23:43 -0500 Subject: [PATCH 18/70] listAllContracts: sort by createdTime by default --- web/lib/firebase/contracts.ts | 5 +++-- web/pages/server-sitemap.xml.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 453ec697..9fe1e59c 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('popularityScore', '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)) diff --git a/web/pages/server-sitemap.xml.tsx b/web/pages/server-sitemap.xml.tsx index 0027c4dc..15cb734c 100644 --- a/web/pages/server-sitemap.xml.tsx +++ b/web/pages/server-sitemap.xml.tsx @@ -4,7 +4,7 @@ import { getServerSideSitemap, ISitemapField } from 'next-sitemap' import { listAllContracts } from 'web/lib/firebase/contracts' export const getServerSideProps: GetServerSideProps = async (ctx) => { - const contracts = await listAllContracts(1000, undefined) + const contracts = await listAllContracts(1000, undefined, 'popularityScore') const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1)) From 571cf80e134450f2ce1ffa55a083484b62b20563 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 14:42:23 -0500 Subject: [PATCH 19/70] markets api: only load 500 markets by default --- web/pages/api/v0/markets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(), From b9a667b1262f17d4ab8fcde5196318123b1e0adb Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 14:59:11 -0600 Subject: [PATCH 20/70] Add logs to weekly emails --- functions/src/weekly-markets-emails.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 1e43b7dc..881ba7ba 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -39,6 +39,11 @@ async function sendTrendingMarketsEmailsToAllUsers() { const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return !user.unsubscribedFromWeeklyTrendingEmails }) + log( + 'Sending weekly trending emails to', + privateUsersToSendEmailsTo.length, + 'users' + ) const trendingContracts = (await getTrendingContracts()) .filter( (contract) => @@ -48,6 +53,10 @@ async function sendTrendingMarketsEmailsToAllUsers() { ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS ) .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}`) From ec4d0f6b4ac641d250a8538f8d25d8ba89d38fc8 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 15:26:54 -0700 Subject: [PATCH 21/70] Fix notification for updated questions (#782) * Fix update notification for question, description * Don't notify on updated description --- functions/src/on-update-contract.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 2042f726..28523eae 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -40,19 +40,16 @@ export const onUpdateContract = functions.firestore ) } 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( From e1775681aa47b67601abda567b19024529c190d8 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 16:36:39 -0600 Subject: [PATCH 22/70] Add weekly email sent flag, filter out manifold grouped markets --- common/user.ts | 1 + functions/src/index.ts | 1 + functions/src/reset-betting-streaks.ts | 2 +- functions/src/reset-weekly-emails-flag.ts | 24 +++++++++++++++++++++++ functions/src/weekly-markets-emails.ts | 20 ++++++++++++++----- 5 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 functions/src/reset-weekly-emails-flag.ts diff --git a/common/user.ts b/common/user.ts index dee1413f..9927a3d3 100644 --- a/common/user.ts +++ b/common/user.ts @@ -54,6 +54,7 @@ export type PrivateUser = { unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean unsubscribedFromWeeklyTrendingEmails?: boolean + weeklyTrendingEmailSent?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/functions/src/index.ts b/functions/src/index.ts index b0ad50fa..26a1ddf6 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -29,6 +29,7 @@ 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' // v2 export * from './health' diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index c781aba2..924f5c22 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -9,7 +9,7 @@ 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..fc6b396a --- /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 1 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/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 881ba7ba..c7331dae 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('* 18 * * 1') + .timeZone('Etc/UTC') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() }) @@ -37,7 +37,10 @@ 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', @@ -50,13 +53,17 @@ async function sendTrendingMarketsEmailsToAllUsers() { !( 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}`) @@ -79,6 +86,9 @@ async function sendTrendingMarketsEmailsToAllUsers() { if (!user) continue await sendInterestingMarketsEmail(user, privateUser, contractsToSend) + await firestore.collection('private-users').doc(user.id).update({ + weeklyTrendingEmailSent: true, + }) } } From 6929076740179a10bccede717de1c5edd133e110 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 16:43:08 -0600 Subject: [PATCH 23/70] Be more specific about unsubscribe --- functions/src/email-templates/interesting-markets.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.

From 3bea9836620ad54cdf994155a20ba3e251612d76 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 16:56:28 -0600 Subject: [PATCH 24/70] Be more explicit after unsubscribing from weekly trending --- functions/src/reset-weekly-emails-flag.ts | 2 +- functions/src/unsubscribe.ts | 4 ++++ functions/src/weekly-markets-emails.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flag.ts index fc6b396a..5a71b65b 100644 --- a/functions/src/reset-weekly-emails-flag.ts +++ b/functions/src/reset-weekly-emails-flag.ts @@ -4,7 +4,7 @@ import { getAllPrivateUsers } from './utils' export const resetWeeklyEmailsFlag = functions .runWith({ secrets: ['MAILGUN_KEY'] }) - // every Monday at 1 am PT (UTC -07:00) ( 12 hours before the emails will be sent) + // 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 () => { 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/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index c7331dae..a20e40a2 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -10,7 +10,7 @@ import { DAY_MS } from '../../common/util/time' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) // every minute on Monday for an hour at 12pm PT (UTC -07:00) - .pubsub.schedule('* 18 * * 1') + .pubsub.schedule('* 19 * * 1') .timeZone('Etc/UTC') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() From 552f9add700554a480d58469524245f4ce426d4c Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 17:23:59 -0700 Subject: [PATCH 25/70] Reduce min time on contract graph to 1h Allows more resolution on real-time markets, where a lot of trading happens within minutes --- web/components/contract/contract-prob-graph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 98440ec8..693befbb 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -58,7 +58,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const { width } = useWindowSize() const numXTickValues = !width || width < 800 ? 2 : 5 - const hoursAgo = latestTime.subtract(5, 'hours') + const hoursAgo = latestTime.subtract(1, 'hours') const startDate = dayjs(times[0]).isBefore(hoursAgo) ? times[0] : hoursAgo.toDate() From 20fd286756c2b957850c8ab84394bf0e4116452e Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 17:45:23 -0700 Subject: [PATCH 26/70] Fix link classes duplicating on paste (#788) --- web/components/editor.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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), }, From baa27a3c856bfad7edf4f8674f7ac940e7393d80 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 17:50:59 -0700 Subject: [PATCH 27/70] Make Sinclair an admin --- firestore.rules | 1 + 1 file changed, 1 insertion(+) diff --git a/firestore.rules b/firestore.rules index c0d17dac..b28ac6a5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -10,6 +10,7 @@ service cloud.firestore { 'akrolsmir@gmail.com', 'jahooma@gmail.com', 'taowell@gmail.com', + 'abc.sinclair@gmail.com', 'manticmarkets@gmail.com' ] } From b476a7e3f82ef0d248c3e480ea77aee5e0c00025 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 18:18:51 -0700 Subject: [PATCH 28/70] Take descriptions out of LiteMarket (#789) --- docs/docs/api.md | 5 ++--- web/pages/api/v0/_types.ts | 17 +++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) 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/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), } } From 1c73d2192587356e6c2010faee1fe8178daba3a1 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Tue, 23 Aug 2022 00:27:07 -0500 Subject: [PATCH 29/70] weeklyMarketsEmails: send different markets to different users --- functions/src/weekly-markets-emails.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index a20e40a2..bf839d00 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -92,9 +92,11 @@ async function sendTrendingMarketsEmailsToAllUsers() { } } +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) } From bea94d58c579f0fd29702bf77bc03a836f8ee2b0 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 23 Aug 2022 07:55:26 -0600 Subject: [PATCH 30/70] Add extra text-sm --- web/pages/notifications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 116c367d..94ad6680 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -165,7 +165,7 @@ function NotificationsList(props: { if (!paginatedGroupedNotifications || !allGroupedNotifications) return
return ( -
+
{paginatedGroupedNotifications.length === 0 && (
You don't have any notifications. Try changing your settings to see From 7da4eb8fe919bf0bb8cd138db33d1f89a1f5b77f Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Tue, 23 Aug 2022 14:31:52 -0700 Subject: [PATCH 31/70] Fix bet modal probability sticking (#793) * Fix button group styles * Reset prob strike-out when bet modal closed --- web/components/bet-inline.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index 7eda7198..64780f4b 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format' export function BetInline(props: { contract: CPMMBinaryContract | PseudoNumericContract className?: string - setProbAfter: (probAfter: number) => void + setProbAfter: (probAfter: number | undefined) => void onClose: () => void }) { const { contract, className, setProbAfter, onClose } = props @@ -82,7 +82,7 @@ export function BetInline(props: {
Bet
)} - From 78780a92199d233004cfec489bdc122e0a4f9a7c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 23 Aug 2022 19:25:57 -0500 Subject: [PATCH 32/70] Dedup contract leaderboards code from contract slug (merge error?) --- .../contract/contract-leaderboard.tsx | 2 +- web/pages/[username]/[contractSlug].tsx | 140 +----------------- 2 files changed, 8 insertions(+), 134 deletions(-) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 22175876..77af001e 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -49,7 +49,7 @@ export function ContractLeaderboard(props: { return users && users.length > 0 ? ( ) } - -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> - ) -} From f50b4775a1e563f18a15fa441101ffb5fdc710b3 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 10:49:53 -0600 Subject: [PATCH 33/70] Allow to follow/unfollow markets, backfill as well (#794) * Allow to follow/unfollow markets, backfill as well * remove yarn script edit * add decrement comment * Lint * Decrement follow count on unfollow * Follow/unfollow button logic * Unfollow/follow => heart * Add user to followers in place-bet and sell-shares * Add tracking * Show contract follow modal for first time following * Increment follower count as well * Remove add follow from bet trigger * restore on-create-bet * Add pubsub to dev.sh, show heart on FR, remove from answer trigger --- common/contract.ts | 1 + common/notification.ts | 1 + common/user.ts | 1 + dev.sh | 2 +- firestore.rules | 7 +- functions/package.json | 2 +- functions/src/create-notification.ts | 440 +++++++++++------- functions/src/follow-market.ts | 36 ++ functions/src/index.ts | 1 + functions/src/on-create-answer.ts | 7 +- .../src/on-create-comment-on-contract.ts | 18 +- functions/src/on-create-contract.ts | 2 + .../src/on-create-liquidity-provision.ts | 2 + functions/src/on-update-contract-follow.ts | 45 ++ functions/src/on-update-contract.ts | 10 +- functions/src/place-bet.ts | 3 + .../scripts/backfill-contract-followers.ts | 75 +++ functions/src/sell-shares.ts | 6 +- web/components/NotificationSettings.tsx | 82 ++-- web/components/contract/contract-overview.tsx | 69 +-- .../contract/follow-market-modal.tsx | 33 ++ web/components/follow-market-button.tsx | 76 +++ web/hooks/use-follows.ts | 11 + web/hooks/use-notifications.ts | 1 + web/lib/firebase/contracts.ts | 23 + 25 files changed, 719 insertions(+), 235 deletions(-) create mode 100644 functions/src/follow-market.ts create mode 100644 functions/src/on-update-contract-follow.ts create mode 100644 functions/src/scripts/backfill-contract-followers.ts create mode 100644 web/components/contract/follow-market-modal.tsx create mode 100644 web/components/follow-market-button.tsx diff --git a/common/contract.ts b/common/contract.ts index 2a8f897a..343bc750 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -57,6 +57,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { uniqueBettorIds?: string[] uniqueBettorCount?: number popularityScore?: number + followerCount?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/notification.ts b/common/notification.ts index 0a69f89d..f10bd3f6 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -70,3 +70,4 @@ export type notification_reason_types = | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' + | 'you_follow_contract' diff --git a/common/user.ts b/common/user.ts index 9927a3d3..b278300c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -42,6 +42,7 @@ export type User = { shouldShowWelcome?: boolean lastBetTime?: number currentBettingStreak?: number + hasSeenContractFollowModal?: boolean } export type PrivateUser = { 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/firestore.rules b/firestore.rules index b28ac6a5..0e5a759b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -23,7 +23,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() @@ -44,6 +44,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 63ef9b5d..c8f295fc 100644 --- a/functions/package.json +++ b/functions/package.json @@ -13,7 +13,7 @@ "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", "dev": "nodemon src/serve.ts", - "firestore": "firebase emulators:start --only firestore --import=./firestore_export", + "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,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", diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 3fb1f9c3..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<Answer>( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('answers') - ) - const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) - } - - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const comments = await getValues<Comment>( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('comments') - ) - const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) - } - - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const betsSnap = await firestore - .collection(`contracts/${sourceContract.id}/bets`) - .get() - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - // filter bets for only users that have an amount invested still - const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter( - (userId) => { - return ( - getContractBetMetrics( - sourceContract, - bets.filter((bet) => bet.userId === userId) - ).invested > 0 - ) - } - ) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) - } - const notifyUserAddedToGroup = ( 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<Answer>( + firestore + .collection('contracts') + .doc(sourceContract.id) + .collection('answers') + ) + const recipientUserIds = uniq(answers.map((answer) => answer.userId)) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_answer', + } + }) + } + + const notifyOtherCommentersOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const comments = await getValues<Comment>( + firestore + .collection('contracts') + .doc(sourceContract.id) + .collection('comments') + ) + const recipientUserIds = uniq(comments.map((comment) => comment.userId)) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_comment', + } + }) + } + + const notifyBettorsOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const betsSnap = await firestore + .collection(`contracts/${sourceContract.id}/bets`) + .get() + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + // filter bets for only users that have an amount invested still + const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter( + (userId) => { + return ( + getContractBetMetrics( + sourceContract, + bets.filter((bet) => bet.userId === userId) + ).invested > 0 + ) + } + ) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_shares_in', + } + }) + } + + const notifyRepliedUser = ( + userToReasonTexts: user_to_reason_texts, + relatedUserId: string, + relatedSourceType: notification_source_types + ) => { + if ( + shouldGetNotification(relatedUserId, userToReasonTexts) && + stillFollowingContract(relatedUserId) + ) { + if (relatedSourceType === 'comment') { + userToReasonTexts[relatedUserId] = { + reason: 'reply_to_users_comment', + } + } else if (relatedSourceType === 'answer') { + userToReasonTexts[relatedUserId] = { + reason: 'reply_to_users_answer', + } + } + } + } + + const notifyTaggedUsers = ( + userToReasonTexts: user_to_reason_texts, + userIds: (string | undefined)[] + ) => { + userIds.forEach((id) => { + console.log('tagged user: ', id) + // Allowing non-following users to get tagged + if (id && shouldGetNotification(id, userToReasonTexts)) + userToReasonTexts[id] = { + reason: 'tagged_user', + } + }) + } + + const notifyLiquidityProviders = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const liquidityProviders = await firestore + .collection(`contracts/${sourceContract.id}/liquidity`) + .get() + const liquidityProvidersIds = uniq( + liquidityProviders.docs.map((doc) => doc.data().userId) + ) + liquidityProvidersIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) { + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_shares_in', + } + } + }) + } + const userToReasonTexts: user_to_reason_texts = {} + + if (sourceType === 'comment') { + if (repliedUserId && relatedSourceType) + notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) + if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) + } + await notifyContractCreator(userToReasonTexts) + await notifyOtherAnswerersOnContract(userToReasonTexts) + await notifyLiquidityProviders(userToReasonTexts) + await notifyBettorsOnContract(userToReasonTexts) + await notifyOtherCommentersOnContract(userToReasonTexts) + // if they weren't added previously, add them now + await notifyContractFollowers(userToReasonTexts) + await createUsersNotifications(userToReasonTexts) } 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 26a1ddf6..012ba241 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -30,6 +30,7 @@ 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-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..56a01bbb 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -2,6 +2,7 @@ import * as functions from 'firebase-functions' import { getContract, getUser } from './utils' import { createNotification } from './create-notification' import { LiquidityProvision } from 'common/liquidity-provision' +import { addUserToContractFollowers } from './follow-market' export const onCreateLiquidityProvision = functions.firestore .document('contracts/{contractId}/liquidity/{liquidityId}') @@ -18,6 +19,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 28523eae..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,14 +29,14 @@ 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 || @@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore sourceText = contract.question } - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( contract.id, 'contract', 'updated', contractUpdater, eventId, sourceText, - { contract } + contract ) } }) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 44a96210..237019a4 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 'functions/src/follow-market' const bodySchema = z.object({ contractId: z.string(), @@ -167,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/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<Contract>( + firestore.collection('contracts').where('isResolved', '==', false) + ) + let count = 0 + for (const contract of contracts) { + const comments = await getValues<Comment>( + firestore.collection('contracts').doc(contract.id).collection('comments') + ) + const commenterIds = uniq(comments.map((comment) => comment.userId)) + const betsSnap = await firestore + .collection(`contracts/${contract.id}/bets`) + .get() + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + // filter bets for only users that have an amount invested still + const bettorIds = uniq(bets.map((bet) => bet.userId)) + const liquidityProviders = await firestore + .collection(`contracts/${contract.id}/liquidity`) + .get() + const liquidityProvidersIds = uniq( + liquidityProviders.docs.map((doc) => doc.data().userId) + // exclude free market liquidity provider + ).filter( + (id) => + id !== HOUSE_LIQUIDITY_PROVIDER_ID || + id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID + ) + const followerIds = uniq([ + ...commenterIds, + ...bettorIds, + ...liquidityProvidersIds, + contract.creatorId, + ]) + for (const followerId of followerIds) { + await firestore + .collection(`contracts/${contract.id}/follows`) + .doc(followerId) + .set({ id: followerId, createdTime: Date.now() }) + } + // Perhaps handled by the trigger? + // const followerCount = followerIds.length + // await firestore + // .collection(`contracts`) + // .doc(contract.id) + // .update({ followerCount: followerCount }) + count += 1 + if (count % 100 === 0) { + console.log(`${count} contracts processed`) + } + } +} + +if (require.main === module) { + backfillContractFollowers() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index d9f99de3..0e669f39 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -13,6 +13,7 @@ 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 'functions/src/follow-market' const bodySchema = z.object({ contractId: z.string(), @@ -123,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/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx index 7a839a7a..6d8aa25f 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<notification_subscribe_types>('all') const [privateUser, setPrivateUser] = useState<PrivateUser | null>(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 ( - <Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}> + <Row + className={clsx( + 'my-1 gap-1 text-gray-300', + highlight && '!text-black', + onClick ? 'cursor-pointer' : '' + )} + onClick={onClick} + > {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />} {label} </Row> @@ -148,31 +159,45 @@ export function NotificationSettings() { toggleClassName={'w-24'} /> <div className={'mt-4 text-sm'}> - <div> - <div className={''}> - You will receive notifications for: - <NotificationSettingLine - label={"Resolution of questions you've interacted with"} - highlight={notificationSettings !== 'none'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={'Activity on your own questions, comments, & answers'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Activity on questions you're betting on"} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Income & referral bonuses you've received"} - /> - <NotificationSettingLine - label={"Activity on questions you've ever bet or commented on"} - highlight={notificationSettings === 'all'} - /> - </div> - </div> + <Col className={''}> + <Row className={'my-1'}> + You will receive notifications for these general events: + </Row> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Income & referral bonuses you've received"} + /> + <Row className={'my-1'}> + You will receive new comment, answer, & resolution notifications on + questions: + </Row> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={ + <span> + That <span className={'font-bold'}>you follow </span>- you + auto-follow questions if: + </span> + } + onClick={() => setShowModal(true)} + /> + <Col + className={clsx( + 'mb-2 ml-8', + 'gap-1 text-gray-300', + notificationSettings !== 'none' && '!text-black' + )} + > + <Row>• You create it</Row> + <Row>• You bet, comment on, or answer it</Row> + <Row>• You add liquidity to it</Row> + <Row> + • If you select 'Less' and you've commented on or answered a + question, you'll only receive notification on direct replies to + your comments or answers + </Row> + </Col> + </Col> </div> <div className={'mt-4'}>Email Notifications</div> <ChoicesToggleGroup @@ -205,6 +230,7 @@ export function NotificationSettings() { /> </div> </div> + <FollowMarketModal setOpen={setShowModal} open={showModal} /> </div> ) } diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index bba30776..2aa2d6df 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -22,6 +22,7 @@ import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' import { ShareRow } from './share-row' +import { FollowMarketButton } from 'web/components/follow-market-button' export const ContractOverview = (props: { contract: Contract @@ -44,47 +45,57 @@ export const ContractOverview = (props: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && + !resolution && ( + <div className={'xl:hidden'}> + <FollowMarketButton contract={contract} user={user} /> + </div> + )} + <Row className={'hidden gap-3 xl:flex'}> + <FollowMarketButton contract={contract} user={user} /> - {isBinary && ( - <BinaryResolutionOrChance - className="hidden items-end xl:flex" - contract={contract} - large - /> - )} + {isBinary && ( + <BinaryResolutionOrChance + className="items-end" + contract={contract} + large + /> + )} - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation - contract={contract} - className="hidden items-end xl:flex" - /> - )} + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation + contract={contract} + className="items-end" + /> + )} - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation - contract={contract} - className="hidden items-end xl:flex" - /> - )} + {outcomeType === 'NUMERIC' && ( + <NumericResolutionOrExpectation + contract={contract} + className="items-end" + /> + )} + </Row> </Row> {isBinary ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - - {tradingAllowed(contract) && ( - <BetButton contract={contract as CPMMBinaryContract} /> - )} + <Row className={'items-start gap-2'}> + <FollowMarketButton contract={contract} user={user} /> + {tradingAllowed(contract) && ( + <BetButton contract={contract as CPMMBinaryContract} /> + )} + </Row> </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} - </Row> - ) : isPseudoNumeric ? ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} + <Row className={'items-start gap-2'}> + <FollowMarketButton contract={contract} user={user} /> + {tradingAllowed(contract) && <BetButton contract={contract} />} + </Row> </Row> ) : ( (outcomeType === 'FREE_RESPONSE' || diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/follow-market-modal.tsx new file mode 100644 index 00000000..3dfb7ff4 --- /dev/null +++ b/web/components/contract/follow-market-modal.tsx @@ -0,0 +1,33 @@ +import { Col } from 'web/components/layout/col' +import { Modal } from 'web/components/layout/modal' +import React from 'react' + +export const FollowMarketModal = (props: { + open: boolean + setOpen: (b: boolean) => void + title?: string +}) => { + const { open, setOpen, title } = props + return ( + <Modal open={open} setOpen={setOpen}> + <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> + <span className={'text-8xl'}>❤️</span> + <span className="text-xl">{title ? title : 'Following questions'}</span> + <Col className={'gap-2'}> + <span className={'text-indigo-700'}>• What is following?</span> + <span className={'ml-2'}> + You can receive notifications on questions you're interested in by + clicking the ❤️ button on a question. + </span> + <span className={'text-indigo-700'}> + • What types of notifications will I receive? + </span> + <span className={'ml-2'}> + You'll receive in-app notifications for new comments, answers, and + updates to the question. + </span> + </Col> + </Col> + </Modal> + ) +} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx new file mode 100644 index 00000000..0a8ff4b4 --- /dev/null +++ b/web/components/follow-market-button.tsx @@ -0,0 +1,76 @@ +import { Button } from 'web/components/button' +import { + Contract, + followContract, + unFollowContract, +} from 'web/lib/firebase/contracts' +import toast from 'react-hot-toast' +import { CheckIcon, HeartIcon } 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' + +export const FollowMarketButton = (props: { + contract: Contract + user: User | undefined | null +}) => { + const { contract, user } = props + const followers = useContractFollows(contract.id) + const [open, setOpen] = useState(false) + + return ( + <Button + size={'lg'} + color={'gray-white'} + onClick={async () => { + if (!user) return firebaseLogin() + if (followers?.includes(user.id)) { + await unFollowContract(contract.id, user.id) + toast('Notifications from this market are now silenced.', { + icon: <CheckIcon className={'text-primary h-5 w-5'} />, + }) + track('Unfollow Market', { + slug: contract.slug, + }) + } else { + await followContract(contract.id, user.id) + toast('You are now following this market!', { + icon: <CheckIcon className={'text-primary h-5 w-5'} />, + }) + track('Follow Market', { + slug: contract.slug, + }) + } + if (!user.hasSeenContractFollowModal) { + await updateUser(user.id, { + hasSeenContractFollowModal: true, + }) + setOpen(true) + } + }} + > + {followers?.includes(user?.id ?? 'nope') ? ( + <HeartIcon + className={clsx('h-6 w-6 fill-red-600 stroke-red-600 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + ) : ( + <HeartIcon + className={clsx('h-6 w-6 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + )} + <FollowMarketModal + open={open} + setOpen={setOpen} + title={`You ${ + followers?.includes(user?.id ?? 'nope') ? 'followed' : 'unfollowed' + } a question!`} + /> + </Button> + ) +} 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<string[] | undefined>() @@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => { return followerIds } + +export const useContractFollows = (contractId: string) => { + const [followIds, setFollowIds] = useState<string[] | undefined>() + + useEffect(() => { + return listenForContractFollows(contractId, setFollowIds) + }, [contractId]) + + return followIds +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index ecc4ce2a..32500943 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -147,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 9fe1e59c..fc205b6a 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -212,6 +212,29 @@ export function listenForContract( return listenForValue<Contract>(contractRef, setContract) } +export function listenForContractFollows( + contractId: string, + setFollowIds: (followIds: string[]) => void +) { + const follows = collection(contracts, contractId, 'follows') + return listenForValues<{ id: string }>(follows, (docs) => + setFollowIds(docs.map(({ id }) => id)) + ) +} + +export async function followContract(contractId: string, userId: string) { + const followDoc = doc(collection(contracts, contractId, 'follows'), userId) + return await setDoc(followDoc, { + id: userId, + createdTime: Date.now(), + }) +} + +export async function unFollowContract(contractId: string, userId: string) { + const followDoc = doc(collection(contracts, contractId, 'follows'), userId) + await deleteDoc(followDoc) +} + function chooseRandomSubset(contracts: Contract[], count: number) { const fiveMinutes = 5 * 60 * 1000 const seed = Math.round(Date.now() / fiveMinutes).toString() From 480371cf9f67ffd7eac05690937793608b258e2b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 10:50:55 -0600 Subject: [PATCH 34/70] Fix import --- functions/src/place-bet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 237019a4..404fda50 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -22,7 +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 'functions/src/follow-market' +import { addUserToContractFollowers } from './follow-market' const bodySchema = z.object({ contractId: z.string(), From 5dcaae7af6eecb8794261804f9b88cb8b2e7042c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 10:51:21 -0600 Subject: [PATCH 35/70] Fix import --- functions/src/sell-shares.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 0e669f39..0e88a0b5 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -13,7 +13,7 @@ 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 'functions/src/follow-market' +import { removeUserFromContractFollowers } from './follow-market' const bodySchema = z.object({ contractId: z.string(), From a5812a5a7334b34ec1713fb3ff0cb888d1426936 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:15:38 -0600 Subject: [PATCH 36/70] Remove group chat display --- functions/src/create-user.ts | 19 ----------- web/components/nav/sidebar.tsx | 49 +++++++++++++--------------- web/pages/group/[...slugs]/index.tsx | 15 --------- 3 files changed, 22 insertions(+), 61 deletions(-) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 216a7eb4..fe8b7d77 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -144,24 +144,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/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 056ab78a..e982cb0e 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,15 +18,14 @@ 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' @@ -313,29 +312,29 @@ function GroupsList(props: { memberItems: Item[] privateUser: PrivateUser }) { - const { currentPage, memberItems, privateUser } = props - const preferredNotifications = useUnseenPreferredNotifications( - privateUser, - { - customHref: '/group/', - }, - memberItems.length > 0 ? memberItems.length : undefined - ) + const { currentPage, memberItems } = props const { height } = useWindowSize() const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(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 ( <> @@ -351,16 +350,12 @@ function GroupsList(props: { > {memberItems.map((item) => ( <a - href={ - item.href + - (notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '') - } + href={item.href} key={item.name} onClick={trackCallback('sidebar: ' + item.name)} className={clsx( 'cursor-pointer truncate', - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', - notifIsForThisItem(item.href) && 'font-bold' + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900' )} > {item.name} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 8926d0ab..6ce3e7c3 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -33,11 +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 { 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' @@ -46,7 +44,6 @@ 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) @@ -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, From 432ee387ec6fabee3d7860b7427c778805df5a99 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:23:07 -0600 Subject: [PATCH 37/70] Show all groups on sidebar --- web/components/nav/sidebar.tsx | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index e982cb0e..5a5976f2 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -227,8 +227,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 @@ -236,11 +234,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)}`, @@ -274,13 +270,7 @@ export default function Sidebar(props: { className?: string }) { {memberItems.length > 0 && ( <hr className="!my-4 mr-2 border-gray-300" /> )} - {privateUser && ( - <GroupsList - currentPage={router.asPath} - memberItems={memberItems} - privateUser={privateUser} - /> - )} + <GroupsList currentPage={router.asPath} memberItems={memberItems} /> </div> {/* Desktop navigation */} @@ -295,23 +285,13 @@ export default function Sidebar(props: { className?: string }) { {/* Spacer if there are any groups */} {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />} - {privateUser && ( - <GroupsList - currentPage={router.asPath} - memberItems={memberItems} - privateUser={privateUser} - /> - )} + <GroupsList currentPage={router.asPath} memberItems={memberItems} /> </div> </nav> ) } -function GroupsList(props: { - currentPage: string - memberItems: Item[] - privateUser: PrivateUser -}) { +function GroupsList(props: { currentPage: string; memberItems: Item[] }) { const { currentPage, memberItems } = props const { height } = useWindowSize() From c72bf506c3ab187ebbe6f008ffbf3d18f99f62ee Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:53:29 -0600 Subject: [PATCH 38/70] Heart button on xl style --- web/components/contract/contract-overview.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 2aa2d6df..d2938a2e 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -53,7 +53,9 @@ export const ContractOverview = (props: { </div> )} <Row className={'hidden gap-3 xl:flex'}> - <FollowMarketButton contract={contract} user={user} /> + <div className={'mt-2'}> + <FollowMarketButton contract={contract} user={user} /> + </div> {isBinary && ( <BinaryResolutionOrChance From 3eb1b66e9a3fc1eb61d00fa649a5643d08b0e32a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:58:32 -0600 Subject: [PATCH 39/70] Lint --- functions/src/create-user.ts | 13 ++----------- web/components/nav/sidebar.tsx | 3 +-- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index fe8b7d77..35394e90 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -2,13 +2,8 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { uniq } from 'lodash' -import { - MANIFOLD_AVATAR_URL, - MANIFOLD_USERNAME, - PrivateUser, - 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, @@ -23,10 +18,6 @@ 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({ diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 5a5976f2..17fa3bde 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -12,7 +12,7 @@ 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' @@ -26,7 +26,6 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' -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' From 1c323c5a7fac604d842d9b8dbb37f1ac69954eca Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 12:59:21 -0500 Subject: [PATCH 40/70] Simple recommended contracts based on contract creator --- web/lib/firebase/contracts.ts | 20 ++++++++++++++++++++ web/pages/[username]/[contractSlug].tsx | 20 ++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index fc205b6a..6dc2ee3e 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -295,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<Contract>(creatorContractsQuery) + const open = data + .filter((c) => c.closeTime && c.closeTime > Date.now()) + .filter((c) => !excluding.includes(c.id)) + + return chooseRandomSubset(open, count) +} + export async function getRecentBetsAndComments(contract: Contract) { const contractDoc = doc(contracts, contract.id) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index e62c457e..282df488 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -11,6 +11,7 @@ import { Spacer } from 'web/components/layout/spacer' import { Contract, getContractFromSlug, + getRandTopCreatorContracts, tradingAllowed, } from 'web/lib/firebase/contracts' import { SEO } from 'web/components/SEO' @@ -39,6 +40,8 @@ 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: { @@ -48,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 { @@ -61,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 @@ -77,6 +84,7 @@ export default function ContractPage(props: { bets: Bet[] comments: ContractComment[] slug: string + recommendedContracts: Contract[] backToHome?: () => void }) { props = usePropz(props, getStaticPropz) ?? { @@ -84,6 +92,7 @@ export default function ContractPage(props: { username: '', comments: [], bets: [], + recommendedContracts: [], slug: '', } @@ -145,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 @@ -258,6 +267,13 @@ export function ContractPageContent( tips={tips} comments={comments} /> + + {recommendedContracts?.length > 0 && ( + <Col className="mx-2 gap-2 sm:mx-0"> + <Subtitle text="Recommended" /> + <ContractsGrid contracts={recommendedContracts} /> + </Col> + )} </Col> </Page> ) From d6d1e8e86fd7b82e1a75b27aa6cf3e8621c80b77 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 13:29:35 -0500 Subject: [PATCH 41/70] Fix types --- web/pages/home.tsx | 1 + 1 file changed, 1 insertion(+) 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={[]} /> )} </> From d390b39e0a722417f23ca71f4fe3264ba6fc5b4d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 15:29:48 -0500 Subject: [PATCH 42/70] eliminate fees --- common/fees.ts | 6 +++--- functions/src/emails.ts | 10 +++++----- web/components/bet-panel.tsx | 14 ++++++------- .../contract/contract-info-dialog.tsx | 4 ++-- web/components/resolution-panel.tsx | 20 +++++++++---------- 5 files changed, 25 insertions(+), 29 deletions(-) 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/functions/src/emails.ts b/functions/src/emails.ts index f90366fa..047e6828 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, } diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 54aa961d..a74442d0 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -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, @@ -346,9 +344,9 @@ function BuyPanel(props: { </> )} </div> - <InfoTooltip + {/* <InfoTooltip text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`} - /> + /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> @@ -665,9 +663,9 @@ function LimitOrderPanel(props: { </> )} </div> - <InfoTooltip + {/* <InfoTooltip text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`} - /> + /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> @@ -689,9 +687,9 @@ function LimitOrderPanel(props: { </> )} </div> - <InfoTooltip + {/* <InfoTooltip text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`} - /> + /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 63c9ac72..29746c65 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -110,10 +110,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <td>{formatMoney(contract.volume)}</td> </tr> - <tr> + {/* <tr> <td>Creator earnings</td> <td>{formatMoney(contract.collectedFees.creatorFee)}</td> - </tr> + </tr> */} <tr> <td>Traders</td> 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<resolution | undefined>() @@ -86,16 +84,16 @@ export function ResolutionPanel(props: { {outcome === 'YES' ? ( <> Winnings will be paid out to YES bettors. + {/* <br /> <br /> - <br /> - You will earn {earnedFees}. + You will earn {earnedFees}. */} </> ) : outcome === 'NO' ? ( <> Winnings will be paid out to NO bettors. + {/* <br /> <br /> - <br /> - 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}. */} </Col> ) : ( <>Resolving this market will immediately pay out traders.</> From de74b2987a2255fb9410039932040e3e37b644ea Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 15:34:00 -0500 Subject: [PATCH 43/70] eslint --- web/components/bet-panel.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index a74442d0..8e7f4999 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -259,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) From d5ac560f0c3f3c90f3cbdd6c178bab74ff940def Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 15:36:57 -0500 Subject: [PATCH 44/70] eslint --- web/components/bet-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 8e7f4999..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' From 5365fa6175b7a94f28642ba811a8bbd8796650c4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 15:09:28 -0600 Subject: [PATCH 45/70] =?UTF-8?q?=F0=9F=92=94=F0=9F=92=94=F0=9F=92=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/contract/contract-overview.tsx | 26 +++-------------- web/components/contract/share-row.tsx | 2 ++ web/components/follow-market-button.tsx | 29 ++++++++++++------- 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index d2938a2e..797921bf 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -22,7 +22,6 @@ import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' import { ShareRow } from './share-row' -import { FollowMarketButton } from 'web/components/follow-market-button' export const ContractOverview = (props: { contract: Contract @@ -45,18 +44,7 @@ export const ContractOverview = (props: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> - {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && - !resolution && ( - <div className={'xl:hidden'}> - <FollowMarketButton contract={contract} user={user} /> - </div> - )} <Row className={'hidden gap-3 xl:flex'}> - <div className={'mt-2'}> - <FollowMarketButton contract={contract} user={user} /> - </div> - {isBinary && ( <BinaryResolutionOrChance className="items-end" @@ -84,20 +72,14 @@ export const ContractOverview = (props: { {isBinary ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - <Row className={'items-start gap-2'}> - <FollowMarketButton contract={contract} user={user} /> - {tradingAllowed(contract) && ( - <BetButton contract={contract as CPMMBinaryContract} /> - )} - </Row> + {tradingAllowed(contract) && ( + <BetButton contract={contract as CPMMBinaryContract} /> + )} </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - <Row className={'items-start gap-2'}> - <FollowMarketButton contract={contract} user={user} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} - </Row> + {tradingAllowed(contract) && <BetButton contract={contract} />} </Row> ) : ( (outcomeType === 'FREE_RESPONSE' || diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 9011ff1b..0aa0c6b0 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -10,6 +10,7 @@ import { User } from 'common/user' import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' import { withTracking } from 'web/lib/service/analytics' +import { FollowMarketButton } from 'web/components/follow-market-button' export function ShareRow(props: { contract: Contract @@ -62,6 +63,7 @@ export function ShareRow(props: { /> </Button> )} + <FollowMarketButton contract={contract} user={user} /> </Row> ) } diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 0a8ff4b4..026902f3 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -5,7 +5,7 @@ import { unFollowContract, } from 'web/lib/firebase/contracts' import toast from 'react-hot-toast' -import { CheckIcon, HeartIcon } from '@heroicons/react/outline' +import { CheckIcon, EyeIcon, EyeOffIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { User } from 'common/user' import { useContractFollows } from 'web/hooks/use-follows' @@ -13,6 +13,7 @@ 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 @@ -30,7 +31,7 @@ export const FollowMarketButton = (props: { if (!user) return firebaseLogin() if (followers?.includes(user.id)) { await unFollowContract(contract.id, user.id) - toast('Notifications from this market are now silenced.', { + toast("You'll no longer receive notifications from this market", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) track('Unfollow Market', { @@ -38,7 +39,7 @@ export const FollowMarketButton = (props: { }) } else { await followContract(contract.id, user.id) - toast('You are now following this market!', { + toast("You'll now receive notifications from this market!", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) track('Follow Market', { @@ -54,15 +55,21 @@ export const FollowMarketButton = (props: { }} > {followers?.includes(user?.id ?? 'nope') ? ( - <HeartIcon - className={clsx('h-6 w-6 fill-red-600 stroke-red-600 xl:h-7 xl:w-7')} - aria-hidden="true" - /> + <Row className={'gap-2'}> + <EyeOffIcon + className={clsx('h-6 w-6 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + Unfollow + </Row> ) : ( - <HeartIcon - className={clsx('h-6 w-6 xl:h-7 xl:w-7')} - aria-hidden="true" - /> + <Row className={'gap-2'}> + <EyeIcon + className={clsx('h-6 w-6 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + Follow + </Row> )} <FollowMarketModal open={open} From d553aae71eae510747a11e18dcc7510dcc76f1bf Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 15:11:38 -0600 Subject: [PATCH 46/70] Shrink icon --- web/components/follow-market-button.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 026902f3..44f4d4be 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -56,19 +56,13 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Row className={'gap-2'}> - <EyeOffIcon - className={clsx('h-6 w-6 xl:h-7 xl:w-7')} - aria-hidden="true" - /> - Unfollow + <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Unwatch </Row> ) : ( <Row className={'gap-2'}> - <EyeIcon - className={clsx('h-6 w-6 xl:h-7 xl:w-7')} - aria-hidden="true" - /> - Follow + <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Watch </Row> )} <FollowMarketModal From 52a89d07836954b56bd92c4a4c6583b14258d968 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 15:42:09 -0600 Subject: [PATCH 47/70] Remove bolded More from navbar --- web/components/nav/nav-bar.tsx | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) 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)} > <MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" /> - {privateUser ? ( - <MoreMenuWithGroupNotifications privateUser={privateUser} /> - ) : ( - 'More' - )} + More </div> <MobileSidebar @@ -100,22 +93,6 @@ export function BottomNavBar() { ) } -function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) { - const { privateUser } = props - const preferredNotifications = useUnseenPreferredNotifications(privateUser, { - customHref: '/group/', - }) - return ( - <span - className={ - preferredNotifications.length > 0 ? 'font-bold' : 'font-normal' - } - > - More - </span> - ) -} - function NavBarItem(props: { item: Item; currentPage: string }) { const { item, currentPage } = props const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) From 74a0479cbdcb00c2e0408e5acace8275e73362d5 Mon Sep 17 00:00:00 2001 From: SirSaltyy <104849031+SirSaltyy@users.noreply.github.com> Date: Thu, 25 Aug 2022 06:51:33 +0900 Subject: [PATCH 48/70] Change about button (#796) About button name change and now directs to "Help and About Center" super.so --- web/components/nav/sidebar.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 17fa3bde..c3df3579 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -93,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: '#', @@ -107,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 }, @@ -130,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, }, ] From 5bf135760e88d29da39cdbd5e0931febad988394 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 17:23:26 -0500 Subject: [PATCH 49/70] fix sidebar tracking --- web/components/nav/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index c3df3579..e16a502e 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -331,7 +331,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { <a href={item.href} key={item.name} - onClick={trackCallback('sidebar: ' + item.name)} + onClick={trackCallback('click sidebar group', { name: item.name })} className={clsx( 'cursor-pointer truncate', 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900' From b6e636cbc04431b4e3723e4d40b61e600460d20f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 16:41:46 -0600 Subject: [PATCH 50/70] Small ux tweaks for signed out market page --- web/components/amount-input.tsx | 5 +- web/components/bet-button.tsx | 36 +++++----- web/components/contract/contract-details.tsx | 67 ++++++++++++++----- web/components/contract/contract-overview.tsx | 10 ++- web/components/contract/share-row.tsx | 36 +++++----- 5 files changed, 105 insertions(+), 49 deletions(-) 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 ( <Col className={className}> <label className="input-group mb-4"> @@ -50,6 +52,7 @@ export function AmountInput(props: { inputMode="numeric" placeholder="0" maxLength={6} + autoFocus={!isMobile} value={amount ?? ''} disabled={disabled} onChange={(e) => onAmountChange(e.target.value)} diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index d7d62e7d..2aca1772 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' +import { Button } from 'web/components/button' +import { firebaseLogin } from 'web/lib/firebase/users' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -30,23 +32,27 @@ export default function BetButton(props: { return ( <> <Col className={clsx('items-center', className)}> - <button - className={clsx( - 'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24', - btnClassName - )} - onClick={() => setOpen(true)} + <Button + size={'lg'} + className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} + onClick={() => { + !user ? firebaseLogin() : setOpen(true) + }} > - Bet - </button> + {user ? 'Bet' : 'Sign up to Bet'} + </Button> - <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> - {hasYesShares - ? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})` - : hasNoShares - ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` - : ''} - </div> + {user && ( + <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> + {hasYesShares + ? `(${Math.floor(yesShares)} ${ + isPseudoNumeric ? 'HIGHER' : 'YES' + })` + : hasNoShares + ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` + : ''} + </div> + )} </Col> <Modal open={open} setOpen={setOpen}> diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 833b37eb..781cea59 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,7 +1,9 @@ import { ClockIcon, DatabaseIcon, + LinkIcon, PencilIcon, + ShareIcon, TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' @@ -9,7 +11,11 @@ import { import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' -import { Contract, updateContract } from 'web/lib/firebase/contracts' +import { + Contract, + contractPath, + updateContract, +} from 'web/lib/firebase/contracts' import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -32,6 +38,11 @@ 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 { copyToClipboard } from 'web/lib/util/copy' +import toast from 'react-hot-toast' +import { track } from 'web/lib/service/analytics' +import { ENV_CONFIG } from 'common/envs/constants' export type ShowTime = 'resolve-date' | 'close-date' @@ -134,6 +145,7 @@ export function AbbrContractDetails(props: { export function ContractDetails(props: { contract: Contract bets: Bet[] + user: User | null | undefined isCreator?: boolean disabled?: boolean }) { @@ -146,7 +158,11 @@ export function ContractDetails(props: { groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null const user = useUser() const [open, setOpen] = useState(false) - + const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ + user?.username && contract.creatorUsername !== user?.username + ? '?referrer=' + user?.username + : '' + }` const groupInfo = ( <Row> <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> @@ -157,7 +173,7 @@ export function ContractDetails(props: { ) return ( - <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> + <Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2"> <Row className="items-center gap-2"> <Avatar username={creatorUsername} @@ -179,6 +195,8 @@ export function ContractDetails(props: { <Row> {disabled ? ( groupInfo + ) : !groupToDisplay && !user ? ( + <div /> ) : ( <Button size={'xs'} @@ -203,13 +221,30 @@ export function ContractDetails(props: { /> </Col> </Modal> - + {!user && ( + <Row className={'items-center justify-end'}> + <Button + size="xs" + color="gray-white" + className={'flex'} + onClick={() => { + copyToClipboard(shareUrl) + toast('Link copied!', { + icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, + }) + track('copy share link') + }} + > + <ShareIcon className={clsx('mr-2 h-5 w-5')} aria-hidden="true" /> + Share + </Button> + </Row> + )} {(!!closeTime || !!resolvedDate) && ( <Row className="items-center gap-1"> - <ClockIcon className="h-5 w-5" /> - {resolvedDate && contract.resolutionTime ? ( <> + <ClockIcon className="h-5 w-5" /> <DateTimeTooltip text="Market resolved:" time={dayjs(contract.resolutionTime)} @@ -219,8 +254,9 @@ export function ContractDetails(props: { </> ) : null} - {!resolvedDate && closeTime && ( + {!resolvedDate && closeTime && user && ( <> + <ClockIcon className="h-5 w-5" /> <EditableCloseDate closeTime={closeTime} contract={contract} @@ -230,14 +266,15 @@ export function ContractDetails(props: { )} </Row> )} - - <Row className="items-center gap-1"> - <DatabaseIcon className="h-5 w-5" /> - - <div className="whitespace-nowrap">{volumeLabel}</div> - </Row> - - {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} + {user && ( + <> + <Row className="items-center gap-1"> + <DatabaseIcon className="h-5 w-5" /> + <div className="whitespace-nowrap">{volumeLabel}</div> + </Row> + {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} + </> + )} </Row> ) } diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 797921bf..4676f796 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -73,7 +73,14 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> {tradingAllowed(contract) && ( - <BetButton contract={contract as CPMMBinaryContract} /> + <Col> + <BetButton contract={contract as CPMMBinaryContract} /> + {!user && ( + <div className="text-sm text-gray-500"> + (Don't worry, it's play money!) + </div> + )} + </Col> )} </Row> ) : isPseudoNumeric ? ( @@ -102,6 +109,7 @@ export const ContractOverview = (props: { contract={contract} bets={bets} isCreator={isCreator} + user={user} /> </Col> <Spacer h={4} /> diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 0aa0c6b0..b4c3b4f3 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -27,23 +27,25 @@ export function ShareRow(props: { return ( <Row className="mt-2"> - <Button - size="lg" - color="gray-white" - className={'flex'} - onClick={() => { - setShareOpen(true) - }} - > - <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> - Share - <ShareModal - isOpen={isShareOpen} - setOpen={setShareOpen} - contract={contract} - user={user} - /> - </Button> + {user && ( + <Button + size="lg" + color="gray-white" + className={'flex'} + onClick={() => { + setShareOpen(true) + }} + > + <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> + Share + <ShareModal + isOpen={isShareOpen} + setOpen={setShareOpen} + contract={contract} + user={user} + /> + </Button> + )} {showChallenge && ( <Button From 8d1cebf4db84a1df9947dae8578c03a5ab763771 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:07:22 -0600 Subject: [PATCH 51/70] Move share button back down, small spacing tweaks --- web/components/contract/contract-details.tsx | 38 ++----------------- web/components/contract/contract-overview.tsx | 5 +-- web/components/contract/share-row.tsx | 36 +++++++++--------- web/pages/embed/[username]/[contractSlug].tsx | 1 + 4 files changed, 23 insertions(+), 57 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 781cea59..7b6a6277 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,9 +1,7 @@ import { ClockIcon, DatabaseIcon, - LinkIcon, PencilIcon, - ShareIcon, TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' @@ -11,11 +9,7 @@ import { import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' -import { - Contract, - contractPath, - updateContract, -} from 'web/lib/firebase/contracts' +import { Contract, updateContract } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -39,10 +33,6 @@ import { insertContent } from '../editor/utils' import clsx from 'clsx' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' -import { copyToClipboard } from 'web/lib/util/copy' -import toast from 'react-hot-toast' -import { track } from 'web/lib/service/analytics' -import { ENV_CONFIG } from 'common/envs/constants' export type ShowTime = 'resolve-date' | 'close-date' @@ -158,11 +148,7 @@ export function ContractDetails(props: { groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null const user = useUser() const [open, setOpen] = useState(false) - const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ - user?.username && contract.creatorUsername !== user?.username - ? '?referrer=' + user?.username - : '' - }` + const groupInfo = ( <Row> <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> @@ -221,25 +207,7 @@ export function ContractDetails(props: { /> </Col> </Modal> - {!user && ( - <Row className={'items-center justify-end'}> - <Button - size="xs" - color="gray-white" - className={'flex'} - onClick={() => { - copyToClipboard(shareUrl) - toast('Link copied!', { - icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, - }) - track('copy share link') - }} - > - <ShareIcon className={clsx('mr-2 h-5 w-5')} aria-hidden="true" /> - Share - </Button> - </Row> - )} + {(!!closeTime || !!resolvedDate) && ( <Row className="items-center gap-1"> {resolvedDate && contract.resolutionTime ? ( diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 4676f796..cebde4d6 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -3,7 +3,6 @@ import clsx from 'clsx' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' -import { Spacer } from '../layout/spacer' import { ContractProbGraph } from './contract-prob-graph' import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' @@ -76,7 +75,7 @@ export const ContractOverview = (props: { <Col> <BetButton contract={contract as CPMMBinaryContract} /> {!user && ( - <div className="text-sm text-gray-500"> + <div className="mt-1 text-sm text-gray-500"> (Don't worry, it's play money!) </div> )} @@ -112,7 +111,7 @@ export const ContractOverview = (props: { user={user} /> </Col> - <Spacer h={4} /> + <div className={'my-1 md:my-2'}></div> {(isBinary || isPseudoNumeric) && ( <ContractProbGraph contract={contract} bets={bets} /> )}{' '} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index b4c3b4f3..0aa0c6b0 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -27,25 +27,23 @@ export function ShareRow(props: { return ( <Row className="mt-2"> - {user && ( - <Button - size="lg" - color="gray-white" - className={'flex'} - onClick={() => { - setShareOpen(true) - }} - > - <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> - Share - <ShareModal - isOpen={isShareOpen} - setOpen={setShareOpen} - contract={contract} - user={user} - /> - </Button> - )} + <Button + size="lg" + color="gray-white" + className={'flex'} + onClick={() => { + setShareOpen(true) + }} + > + <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> + Share + <ShareModal + isOpen={isShareOpen} + setOpen={setShareOpen} + contract={contract} + user={user} + /> + </Button> {showChallenge && ( <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 /> From 7a22c7d76ab9aee258830f91334b4673942d2251 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:09:07 -0600 Subject: [PATCH 52/70] Gap adjustment --- web/components/contract/contract-overview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index cebde4d6..6103fee7 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -38,7 +38,7 @@ export const ContractOverview = (props: { return ( <Col className={clsx('mb-6', className)}> - <Col className="gap-4 px-2"> + <Col className="gap-3 px-2 sm:gap-4"> <Row className="justify-between gap-4"> <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> From 7a38d67c5b1fa2fce1e2db8c18c4f8bd47f67f0d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:11:48 -0600 Subject: [PATCH 53/70] Reduce share row top margin on mobile --- web/components/contract/share-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 0aa0c6b0..1af52291 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -26,7 +26,7 @@ export function ShareRow(props: { const [isShareOpen, setShareOpen] = useState(false) return ( - <Row className="mt-2"> + <Row className="mt-0.5 sm:mt-2"> <Button size="lg" color="gray-white" From a8da5719fe5759504f5ca53f00d76c0c7bf2edc8 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 18:30:29 -0500 Subject: [PATCH 54/70] Create experimental home page --- web/components/contract-search.tsx | 17 ++- web/components/contract/contracts-grid.tsx | 10 +- web/pages/experimental/home.tsx | 118 +++++++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 web/pages/experimental/home.tsx 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 <ContractSearchFirestore additionalFilter={additionalFilter} /> @@ -175,10 +181,11 @@ export function ContractSearch(props: { useQuerySortUrlParams={useQuerySortUrlParams} user={user} onSearchParametersChanged={onSearchParametersChanged} + noControls={noControls} /> <ContractsGrid - contracts={pages.length === 0 ? undefined : contracts} - loadMore={performQuery} + contracts={renderedContracts} + loadMore={noControls ? undefined : performQuery} showTime={showTime} onContractClick={onContractClick} highlightOptions={highlightOptions} @@ -198,6 +205,7 @@ function ContractSearchControls(props: { useQuerySortLocalStorage?: boolean useQuerySortUrlParams?: boolean user?: User | null + noControls?: boolean }) { const { className, @@ -209,6 +217,7 @@ function ContractSearchControls(props: { useQuerySortLocalStorage, useQuerySortUrlParams, user, + noControls, } = props const savedSort = useQuerySortLocalStorage ? getSavedSort() : null @@ -329,6 +338,10 @@ function ContractSearchControls(props: { }) }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) + if (noControls) { + return <></> + } + return ( <Col className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)} diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index f7b7eeac..603173f6 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -86,10 +86,12 @@ export function ContractsGrid(props: { /> ))} </Masonry> - <VisibilityObserver - onVisibilityUpdated={onVisibilityUpdated} - className="relative -top-96 h-1" - /> + {loadMore && ( + <VisibilityObserver + onVisibilityUpdated={onVisibilityUpdated} + className="relative -top-96 h-1" + /> + )} </Col> ) } 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 From bca34dad6081e02cc6e1c1ffe74f8b22a125c5ea Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:31:35 -0600 Subject: [PATCH 55/70] Set max betting bonus to M --- common/economy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/economy.ts b/common/economy.ts index cd40f87c..16f1eb77 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -12,5 +12,5 @@ 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 ?? 5 -export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100 +export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 From 535e50eeac6d354d56b3b2ec7592812062ee6649 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:40:03 -0600 Subject: [PATCH 56/70] Betting streak bonus per day:10, max:50 --- common/economy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/economy.ts b/common/economy.ts index 16f1eb77..8db4a7b9 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -11,6 +11,6 @@ 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 ?? 5 -export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25 + 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 From 25eca718463250a55d4862053627d11bcd23a78e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 21:16:38 -0500 Subject: [PATCH 57/70] Convert heart to eye and follow to watch --- web/components/NotificationSettings.tsx | 4 ++-- web/components/contract/follow-market-modal.tsx | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx index 6d8aa25f..7ee27fb5 100644 --- a/web/components/NotificationSettings.tsx +++ b/web/components/NotificationSettings.tsx @@ -175,8 +175,8 @@ export function NotificationSettings() { highlight={notificationSettings !== 'none'} label={ <span> - That <span className={'font-bold'}>you follow </span>- you - auto-follow questions if: + That <span className={'font-bold'}>you watch </span>- you + auto-watch questions if: </span> } onClick={() => setShowModal(true)} diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/follow-market-modal.tsx index 3dfb7ff4..fb62ce9f 100644 --- a/web/components/contract/follow-market-modal.tsx +++ b/web/components/contract/follow-market-modal.tsx @@ -1,6 +1,8 @@ import { Col } from 'web/components/layout/col' import { Modal } from 'web/components/layout/modal' +import { EyeIcon } from '@heroicons/react/outline' import React from 'react' +import clsx from 'clsx' export const FollowMarketModal = (props: { open: boolean @@ -11,13 +13,18 @@ export const FollowMarketModal = (props: { return ( <Modal open={open} setOpen={setOpen}> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> - <span className={'text-8xl'}>❤️</span> - <span className="text-xl">{title ? title : 'Following questions'}</span> + <EyeIcon className={clsx('h-20 w-20')} aria-hidden="true" /> + <span className="text-xl">{title ? title : 'Watching questions'}</span> <Col className={'gap-2'}> - <span className={'text-indigo-700'}>• What is following?</span> + <span className={'text-indigo-700'}>• What is watching?</span> <span className={'ml-2'}> You can receive notifications on questions you're interested in by - clicking the ❤️ button on a question. + clicking the + <EyeIcon + className={clsx('ml-1 inline h-6 w-6 align-top')} + aria-hidden="true" + /> + ️ button on a question. </span> <span className={'text-indigo-700'}> • What types of notifications will I receive? From 0caa5e24e8605b6f7750851d87ed143a91242a5a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 21:23:12 -0500 Subject: [PATCH 58/70] Some other follow to watch changes --- web/components/follow-market-button.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 44f4d4be..45d26ce4 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -34,7 +34,7 @@ export const FollowMarketButton = (props: { toast("You'll no longer receive notifications from this market", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) - track('Unfollow Market', { + track('Unwatch Market', { slug: contract.slug, }) } else { @@ -42,7 +42,7 @@ export const FollowMarketButton = (props: { toast("You'll now receive notifications from this market!", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) - track('Follow Market', { + track('Watch Market', { slug: contract.slug, }) } @@ -69,7 +69,7 @@ export const FollowMarketButton = (props: { open={open} setOpen={setOpen} title={`You ${ - followers?.includes(user?.id ?? 'nope') ? 'followed' : 'unfollowed' + followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched' } a question!`} /> </Button> From cffd5dcd317d0d196eda52327d16205d3d6dadd2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 22:03:07 -0500 Subject: [PATCH 59/70] Weekly => daily loans --- common/loans.ts | 4 ++-- functions/src/update-loans.ts | 14 +++++++++----- web/components/profile/loans-modal.tsx | 15 ++++++++------- web/pages/notifications.tsx | 2 +- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/common/loans.ts b/common/loans.ts index 46c491b5..cb956c09 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -10,11 +10,11 @@ import { import { PortfolioMetrics, User } from './user' import { filterDefined } from './util/array' -const LOAN_WEEKLY_RATE = 0.05 +const LOAN_DAILY_RATE = 0.01 const calculateNewLoan = (investedValue: number, loanTotal: number) => { const netValue = investedValue - loanTotal - return netValue * LOAN_WEEKLY_RATE + return netValue * LOAN_DAILY_RATE } export const getLoanUpdates = ( diff --git a/functions/src/update-loans.ts b/functions/src/update-loans.ts index fd89b643..770315fd 100644 --- a/functions/src/update-loans.ts +++ b/functions/src/update-loans.ts @@ -12,8 +12,8 @@ const firestore = admin.firestore() export const updateLoans = functions .runWith({ memory: '2GB', timeoutSeconds: 540 }) - // Run every Monday. - .pubsub.schedule('0 0 * * 1') + // Run every day at midnight. + .pubsub.schedule('0 0 * * *') .timeZone('America/Los_Angeles') .onRun(updateLoansCore) @@ -79,9 +79,13 @@ async function updateLoansCore() { const today = new Date().toDateString().replace(' ', '-') const key = `loan-notifications-${today}` await Promise.all( - userPayouts.map(({ user, payout }) => - createLoanIncomeNotification(user, key, payout) - ) + 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/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index c8d30b4e..945fb6fe 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -11,11 +11,12 @@ export function LoansModal(props: { <Modal open={isOpen} setOpen={setOpen}> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <span className={'text-8xl'}>🏦</span> - <span className="text-xl">Loans on your bets</span> + <span className="text-xl">Daily loans on your bets</span> <Col className={'gap-2'}> - <span className={'text-indigo-700'}>• What are loans?</span> + <span className={'text-indigo-700'}>• What are daily loans?</span> <span className={'ml-2'}> - Every Monday, get 5% of your total bet amount back as a loan. + Every day at midnight PT, get 1% of your total bet amount back as a + loan. </span> <span className={'text-indigo-700'}> • Do I have to pay back a loan? @@ -33,12 +34,12 @@ export function LoansModal(props: { </span> <span className={'text-indigo-700'}>• What is an example?</span> <span className={'ml-2'}> - For example, if you bet M$100 on "Will I become a millionare?" on - Sunday, you will get M$5 back on Monday. + For example, if you bet M$1000 on "Will I become a millionare?" on + Monday, you will get M$10 back on Tuesday. </span> <span className={'ml-2'}> - Previous loans count against your total bet amount. So, the next - week, you would get back 5% of M$95 = M$4.75. + Previous loans count against your total bet amount. So on Wednesday, + you would get back 1% of M$990 = M$9.9. </span> </Col> </Col> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 94ad6680..9b717af9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -397,7 +397,7 @@ function IncomeNotificationItem(props: { } else if (sourceType === 'betting_streak_bonus') { reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { - reasonText = `of your invested bets returned as` + reasonText = `of your invested bets returned as a` } const bettingStreakText = From 51380febd4def2adc5f8476f86909067b61775ff Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 22:50:52 -0500 Subject: [PATCH 60/70] Increase memory for updateStats function --- functions/src/update-stats.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 93739e79905daba5f2414198741019fc87ed3eaa Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 23:40:27 -0500 Subject: [PATCH 61/70] Fix betting streak number --- web/pages/notifications.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9b717af9..73ab5c4d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -31,10 +31,7 @@ import { import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' -import { - BETTING_STREAK_BONUS_AMOUNT, - UNIQUE_BETTOR_BONUS_AMOUNT, -} from 'common/economy' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/economy' import { groupBy, sum, uniq } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' @@ -44,6 +41,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' @@ -378,6 +376,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]) @@ -403,9 +403,7 @@ function IncomeNotificationItem(props: { const bettingStreakText = sourceType === 'betting_streak_bonus' && (sourceText - ? `🔥 ${ - parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT - } day Betting Streak` + ? `🔥 ${user?.currentBettingStreak ?? 0} day Betting Streak` : 'Betting Streak') return ( From 18f2550e4dc4ea96506a922134b758e31a935b29 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 23:44:23 -0500 Subject: [PATCH 62/70] resolution email: show n/a for canceled numeric --- functions/src/emails.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 047e6828..e6e52090 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -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) From 465e219bfc5672969c5c6f0e66bb96f1b0744d0f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 05:10:24 -0600 Subject: [PATCH 63/70] Show old streak for old streak notifs --- web/pages/notifications.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 73ab5c4d..75ad2ab9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -31,7 +31,10 @@ import { import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' -import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/economy' +import { + BETTING_STREAK_BONUS_AMOUNT, + UNIQUE_BETTOR_BONUS_AMOUNT, +} from 'common/economy' import { groupBy, sum, uniq } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' @@ -42,6 +45,7 @@ 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' +import { DAY_MS } from 'common/lib/util/time' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -400,11 +404,13 @@ function IncomeNotificationItem(props: { 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 - ? `🔥 ${user?.currentBettingStreak ?? 0} day Betting Streak` - : 'Betting Streak') + (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak') return ( <> From 6e3b8fdd4d7a1dce4d144157ef00d1fc5c0c9fd5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 05:10:38 -0600 Subject: [PATCH 64/70] Show old streak for old streak notifs --- web/pages/notifications.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 75ad2ab9..0fe3b179 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -45,7 +45,6 @@ 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' -import { DAY_MS } from 'common/lib/util/time' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' From b9f0da9d3bc7a86897934916bc3740c9558da3d2 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 05:51:56 -0600 Subject: [PATCH 65/70] Give all users 5 free markets --- common/economy.ts | 1 + common/envs/prod.ts | 1 + common/user.ts | 1 + functions/src/create-market.ts | 27 ++++++++++++----- .../src/on-create-liquidity-provision.ts | 18 ++++++++++-- web/pages/create.tsx | 29 ++++++++++++++----- 6 files changed, 60 insertions(+), 17 deletions(-) diff --git a/common/economy.ts b/common/economy.ts index 8db4a7b9..c1449d4f 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -14,3 +14,4 @@ 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/prod.ts b/common/envs/prod.ts index 033d050f..33cf03c1 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -35,6 +35,7 @@ export type Economy = { BETTING_STREAK_BONUS_AMOUNT?: number BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_RESET_HOUR?: number + FREE_MARKETS_PER_USER_MAX?: number } type FirebaseConfig = { diff --git a/common/user.ts b/common/user.ts index b278300c..48a3d59c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -43,6 +43,7 @@ export type User = { lastBetTime?: number currentBettingStreak?: number hasSeenContractFollowModal?: boolean + freeMarketsCreated?: number } export type PrivateUser = { diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index ae120c43..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 } from '../../common/economy' +import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { + 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<JSONContent> = 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/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 56a01bbb..3a1e551f 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -1,8 +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}') @@ -11,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) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 2ec86bb7..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/economy' +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() && @@ -461,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 From dc89d5d4d037b40c7750c2e8cf55459425f1992b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 07:05:26 -0600 Subject: [PATCH 66/70] Feature markets on trending --- common/contract.ts | 1 + common/envs/prod.ts | 1 + firestore.rules | 3 +- .../contract/FeaturedContractBadge.tsx | 9 ++++ web/components/contract/contract-details.tsx | 3 ++ .../contract/contract-info-dialog.tsx | 48 ++++++++++++++++++- 6 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 web/components/contract/FeaturedContractBadge.tsx diff --git a/common/contract.ts b/common/contract.ts index 343bc750..2b330201 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -58,6 +58,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { uniqueBettorCount?: number popularityScore?: number followerCount?: number + featuredOnHomeRank?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 33cf03c1..5d9ac00e 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -71,6 +71,7 @@ export const PROD_CONFIG: EnvConfig = { 'taowell@gmail.com', // Stephen 'abc.sinclair@gmail.com', // Sinclair 'manticmarkets@gmail.com', // Manifold + 'iansphilips@gmail.com', // Ian ], visibility: 'PUBLIC', diff --git a/firestore.rules b/firestore.rules index 0e5a759b..4cd718d3 100644 --- a/firestore.rules +++ b/firestore.rules @@ -11,7 +11,8 @@ service cloud.firestore { 'jahooma@gmail.com', 'taowell@gmail.com', 'abc.sinclair@gmail.com', - 'manticmarkets@gmail.com' + 'manticmarkets@gmail.com', + 'iansphilips@gmail.com' ] } diff --git a/web/components/contract/FeaturedContractBadge.tsx b/web/components/contract/FeaturedContractBadge.tsx new file mode 100644 index 00000000..5ef34f4a --- /dev/null +++ b/web/components/contract/FeaturedContractBadge.tsx @@ -0,0 +1,9 @@ +import { SparklesIcon } from '@heroicons/react/solid' + +export function FeaturedContractBadge() { + return ( + <span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800"> + <SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured + </span> + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7b6a6277..56407c4d 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -33,6 +33,7 @@ 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' @@ -73,6 +74,8 @@ export function MiscDetails(props: { {'Resolved '} {fromNow(resolutionTime || 0)} </Row> + ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( + <FeaturedContractBadge /> ) : volume > 0 || !isNew ? ( <Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row> ) : ( diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 29746c65..7c35a071 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,7 +7,7 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { contractPool } from 'web/lib/firebase/contracts' +import { contractPool, updateContract } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' @@ -16,6 +16,7 @@ import { InfoTooltip } from '../info-tooltip' import { useAdmin, useDev } from 'web/hooks/use-admin' import { SiteLink } from '../site-link' import { firestoreConsolePath } from 'common/envs/constants' +import { deleteField } from 'firebase/firestore' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -24,6 +25,9 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const [open, setOpen] = useState(false) + const [featured, setFeatured] = useState( + (contract?.featuredOnHomeRank ?? 0) > 0 + ) const isDev = useDev() const isAdmin = useAdmin() @@ -138,6 +142,48 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { </td> </tr> )} + {isAdmin && ( + <tr> + <td>Set featured</td> + <td> + <select + className="select select-bordered" + value={featured ? 'true' : 'false'} + onChange={(e) => { + const newVal = e.target.value === 'true' + if ( + newVal && + (contract.featuredOnHomeRank === 0 || + !contract?.featuredOnHomeRank) + ) + updateContract(id, { + featuredOnHomeRank: 1, + }) + .then(() => { + setFeatured(true) + }) + .catch(console.error) + else if ( + !newVal && + (contract?.featuredOnHomeRank ?? 0) > 0 + ) + updateContract(id, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + featuredOnHomeRank: deleteField(), + }) + .then(() => { + setFeatured(false) + }) + .catch(console.error) + }} + > + <option value="false">false</option> + <option value="true">true</option> + </select> + </td> + </tr> + )} </tbody> </table> From 90e1fdb586fc8102f3cbc2799f906abcb2a75486 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 07:54:50 -0600 Subject: [PATCH 67/70] Add david to admins --- common/envs/prod.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 5d9ac00e..2b1ee70e 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -72,6 +72,7 @@ export const PROD_CONFIG: EnvConfig = { 'abc.sinclair@gmail.com', // Sinclair 'manticmarkets@gmail.com', // Manifold 'iansphilips@gmail.com', // Ian + 'd4vidchee@gmail.com', // D4vid ], visibility: 'PUBLIC', From b785d4b047436cde6dc658685a917c192e00d98d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 10:02:22 -0600 Subject: [PATCH 68/70] With play money --- web/components/contract/contract-overview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 6103fee7..ac6b20f9 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -76,7 +76,7 @@ export const ContractOverview = (props: { <BetButton contract={contract as CPMMBinaryContract} /> {!user && ( <div className="mt-1 text-sm text-gray-500"> - (Don't worry, it's play money!) + (with play money!) </div> )} </Col> From 97b648a51e3d7b3adf89f5952914b303a375b53b Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 25 Aug 2022 12:59:26 -0500 Subject: [PATCH 69/70] Move recommended markets below market white bg onto gray bg --- web/pages/[username]/[contractSlug].tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 282df488..8250bde9 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -267,14 +267,14 @@ export function ContractPageContent( tips={tips} comments={comments} /> - - {recommendedContracts?.length > 0 && ( - <Col className="mx-2 gap-2 sm:mx-0"> - <Subtitle text="Recommended" /> - <ContractsGrid contracts={recommendedContracts} /> - </Col> - )} </Col> + + {recommendedContracts.length > 0 && ( + <Col className="gap-2 px-2 sm:px-0"> + <Subtitle text="Recommended" /> + <ContractsGrid contracts={recommendedContracts} /> + </Col> + )} </Page> ) } From 91bb4dfab2cdb6d2e2fd1a9081ac2e7bba630ec5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 12:06:42 -0600 Subject: [PATCH 70/70] With play money on numeric & center text --- web/components/contract/contract-overview.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index ac6b20f9..23485179 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -75,7 +75,7 @@ export const ContractOverview = (props: { <Col> <BetButton contract={contract as CPMMBinaryContract} /> {!user && ( - <div className="mt-1 text-sm text-gray-500"> + <div className="mt-1 text-center text-sm text-gray-500"> (with play money!) </div> )} @@ -85,7 +85,16 @@ export const ContractOverview = (props: { ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} + {tradingAllowed(contract) && ( + <Col> + <BetButton contract={contract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + )} </Row> ) : ( (outcomeType === 'FREE_RESPONSE' ||