From 2ac7caaf245787b57c9a2b4b9a490baad5d29b57 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Tue, 7 Jun 2022 08:37:23 -0700 Subject: [PATCH 001/217] Rename /analytics to /stats to work around adblockers (#437) * Rename /analytics to /stats to work around adblockers * Fix prettier lint --- web/next.config.js | 5 +++++ web/pages/embed/analytics.tsx | 2 +- web/pages/{analytics.tsx => stats.tsx} | 0 3 files changed, 6 insertions(+), 1 deletion(-) rename web/pages/{analytics.tsx => stats.tsx} (100%) diff --git a/web/next.config.js b/web/next.config.js index 917d72b3..80d4ac96 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -34,6 +34,11 @@ module.exports = { destination: API_DOCS_URL, permanent: false, }, + { + source: '/analytics', + destination: '/stats', + permanent: true, + }, ] }, } diff --git a/web/pages/embed/analytics.tsx b/web/pages/embed/analytics.tsx index 564c4abc..be850fdf 100644 --- a/web/pages/embed/analytics.tsx +++ b/web/pages/embed/analytics.tsx @@ -5,7 +5,7 @@ import Analytics, { CustomAnalytics, FirebaseAnalytics, getStaticPropz, -} from '../analytics' +} from '../stats' export const getStaticProps = fromPropz(getStaticPropz) diff --git a/web/pages/analytics.tsx b/web/pages/stats.tsx similarity index 100% rename from web/pages/analytics.tsx rename to web/pages/stats.tsx From 0f0390cb6ab0a55ce8b55946e9be4e4182c5ce58 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Tue, 7 Jun 2022 13:31:08 -0700 Subject: [PATCH 002/217] Fix secret access for some email sending functions (#449) --- functions/src/market-close-emails.ts | 5 +++-- functions/src/stripe.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/functions/src/market-close-emails.ts b/functions/src/market-close-emails.ts index 382751f6..17700613 100644 --- a/functions/src/market-close-emails.ts +++ b/functions/src/market-close-emails.ts @@ -5,8 +5,9 @@ import { Contract } from '../../common/contract' import { getPrivateUser, getUserByUsername } from './utils' import { sendMarketCloseEmail } from './emails' -export const marketCloseEmails = functions.pubsub - .schedule('every 1 hours') +export const marketCloseEmails = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('every 1 hours') .onRun(async () => { await sendMarketCloseEmails() }) diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index a5d1482f..f9da593f 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -90,7 +90,7 @@ export const createCheckoutSession = functions export const stripeWebhook = functions .runWith({ minInstances: 1, - secrets: ['STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'], + secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'], }) .https.onRequest(async (req, res) => { const stripe = initStripe() From 60e830974eb8ccbeccf6aab2fd17e5662711eb0f Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Tue, 7 Jun 2022 13:54:58 -0700 Subject: [PATCH 003/217] Migrate `sellShares` cloud function to v2 `sellshares` (#440) * Migrate `sellShares` to v2 `sellshares` * Point client at new v2 sellshares function * Clean up `getCpmmSellBetInfo` --- common/envs/dev.ts | 1 + common/envs/prod.ts | 3 +- common/envs/theoremone.ts | 1 + common/sell-bet.ts | 15 +-- functions/src/sell-shares.ts | 156 +++++++++++--------------- web/components/bet-panel.tsx | 34 +++--- web/components/contract/quick-bet.tsx | 2 +- web/lib/firebase/api-call.ts | 4 + web/lib/firebase/fn-call.ts | 5 - 9 files changed, 98 insertions(+), 123 deletions(-) diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 6be7b1b2..2c110861 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -14,6 +14,7 @@ export const DEV_CONFIG: EnvConfig = { }, functionEndpoints: { placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app', + sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app', createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app', }, } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 909afec1..6538aa42 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -1,4 +1,4 @@ -export type V2CloudFunction = 'placebet' | 'createmarket' +export type V2CloudFunction = 'placebet' | 'sellshares' | 'createmarket' export type EnvConfig = { domain: string @@ -42,6 +42,7 @@ export const PROD_CONFIG: EnvConfig = { }, functionEndpoints: { placebet: 'https://placebet-nggbo3neva-uc.a.run.app', + sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', }, adminEmails: [ diff --git a/common/envs/theoremone.ts b/common/envs/theoremone.ts index 19c64eef..a2ddba51 100644 --- a/common/envs/theoremone.ts +++ b/common/envs/theoremone.ts @@ -15,6 +15,7 @@ export const THEOREMONE_CONFIG: EnvConfig = { // TODO: fill in real endpoints for T1 functionEndpoints: { placebet: 'https://placebet-nggbo3neva-uc.a.run.app', + sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', }, adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'], diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 233e478f..3070399b 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -9,6 +9,8 @@ import { CPMMContract, DPMContract } from './contract' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' import { User } from './user' +export type CandidateBet = Omit + export const getSellBetInfo = ( user: User, bet: Bet, @@ -84,12 +86,10 @@ export const getSellBetInfo = ( } export const getCpmmSellBetInfo = ( - user: User, shares: number, outcome: 'YES' | 'NO', contract: CPMMContract, - prevLoanAmount: number, - newBetId: string + prevLoanAmount: number ) => { const { pool, p } = contract @@ -100,8 +100,6 @@ export const getCpmmSellBetInfo = ( ) const loanPaid = Math.min(prevLoanAmount, saleValue) - const netAmount = saleValue - loanPaid - const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, p) @@ -115,9 +113,7 @@ export const getCpmmSellBetInfo = ( fees.creatorFee ) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount: -saleValue, shares: -shares, @@ -129,13 +125,10 @@ export const getCpmmSellBetInfo = ( fees, } - const newBalance = user.balance + netAmount - return { newBet, newPool, newP, - newBalance, fees, } } diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index e6bd66ab..9a2240a5 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,114 +1,90 @@ import { partition, sumBy } from 'lodash' import * as admin from 'firebase-admin' -import * as functions from 'firebase-functions' +import { z } from 'zod' -import { BinaryContract } from '../../common/contract' +import { APIError, newEndpoint, validate } from './api' +import { Contract } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { getValues } from './utils' import { Bet } from '../../common/bet' -export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - contractId: string - shares: number - outcome: 'YES' | 'NO' - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), + shares: z.number(), + outcome: z.enum(['YES', 'NO']), +}) - const { contractId, shares, outcome } = data +export const sellshares = newEndpoint(['POST'], async (req, [bettor, _]) => { + const { contractId, shares, outcome } = validate(bodySchema, req.body) - // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${bettor.id}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as User - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as BinaryContract - const { closeTime, mechanism, collectedFees, volume } = contract + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as Contract + const { closeTime, mechanism, collectedFees, volume } = contract - if (mechanism !== 'cpmm-1') - return { - status: 'error', - message: 'Sell shares only works with mechanism cpmm-1', - } + if (mechanism !== 'cpmm-1') + throw new APIError(400, 'You can only sell shares on CPMM-1 contracts.') + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed.') - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const userBets = await getValues( + contractDoc.collection('bets').where('userId', '==', bettor.id) + ) - const userBets = await getValues( - contractDoc.collection('bets').where('userId', '==', userId) - ) + const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) - const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) + const [yesBets, noBets] = partition( + userBets ?? [], + (bet) => bet.outcome === 'YES' + ) + const [yesShares, noShares] = [ + sumBy(yesBets, (bet) => bet.shares), + sumBy(noBets, (bet) => bet.shares), + ] - const [yesBets, noBets] = partition( - userBets ?? [], - (bet) => bet.outcome === 'YES' - ) - const [yesShares, noShares] = [ - sumBy(yesBets, (bet) => bet.shares), - sumBy(noBets, (bet) => bet.shares), - ] + const maxShares = outcome === 'YES' ? yesShares : noShares + if (shares > maxShares + 0.000000000001) + throw new APIError(400, `You can only sell up to ${maxShares} shares.`) - const maxShares = outcome === 'YES' ? yesShares : noShares - if (shares > maxShares + 0.000000000001) { - return { - status: 'error', - message: `You can only sell ${maxShares} shares`, - } - } + const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( + shares, + outcome, + contract, + prevLoanAmount + ) - const newBetDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() + if (!isFinite(newP)) { + throw new APIError(500, 'Trade rejected due to overflow error.') + } - const { newBet, newPool, newP, newBalance, fees } = getCpmmSellBetInfo( - user, - shares, - outcome, - contract, - prevLoanAmount, - newBetDoc.id - ) + const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() + const newBalance = user.balance - newBet.amount + (newBet.loanAmount ?? 0) + const userId = user.id - if (!isFinite(newP)) { - return { - status: 'error', - message: 'Trade rejected due to overflow error.', - } - } + transaction.update(userDoc, { balance: newBalance }) + transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet }) + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + collectedFees: addObjects(fees, collectedFees), + volume: volume + Math.abs(newBet.amount), + }) + ) - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) - } - - transaction.update(userDoc, { balance: newBalance }) - transaction.create(newBetDoc, newBet) - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - collectedFees: addObjects(fees, collectedFees), - volume: volume + Math.abs(newBet.amount), - }) - ) - - return { status: 'success' } - }) - } -) + return { status: 'success' } + }) +}) const firestore = admin.firestore() diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 129356a1..109cddc2 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -17,7 +17,7 @@ import { Title } from './title' import { User } from 'web/lib/firebase/users' import { Bet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api-call' -import { sellShares } from 'web/lib/firebase/fn-call' +import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' import { BinaryOutcomeLabel } from './outcome-label' @@ -398,23 +398,27 @@ export function SellPanel(props: { // Sell all shares if remaining shares would be < 1 const sellAmount = amount === Math.floor(shares) ? shares : amount - const result = await sellShares({ + await sellShares({ shares: sellAmount, outcome: sharesOutcome, contractId: contract.id, - }).then((r) => r.data) - - console.log('Sold shares. Result:', result) - - if (result?.status === 'success') { - setIsSubmitting(false) - setWasSubmitted(true) - setAmount(undefined) - if (onSellSuccess) onSellSuccess() - } else { - setError(result?.message || 'Error selling') - setIsSubmitting(false) - } + }) + .then((r) => { + console.log('Sold shares. Result:', r) + setIsSubmitting(false) + setWasSubmitted(true) + setAmount(undefined) + if (onSellSuccess) onSellSuccess() + }) + .catch((e) => { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error selling') + } + setIsSubmitting(false) + }) } const initialProb = getProbability(contract) diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index c5633c0a..58b151e1 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -22,7 +22,7 @@ import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon' import { Col } from '../layout/col' import { OUTCOME_TO_COLOR } from '../outcome-label' import { useSaveShares } from '../use-save-shares' -import { sellShares } from 'web/lib/firebase/fn-call' +import { sellShares } from 'web/lib/firebase/api-call' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' const BET_SIZE = 10 diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 0e833d1c..898ca3e7 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -50,3 +50,7 @@ export function createMarket(params: any) { export function placeBet(params: any) { return call(getFunctionUrl('placebet'), 'POST', params) } + +export function sellShares(params: any) { + return call(getFunctionUrl('sellshares'), 'POST', params) +} diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 3713c4e5..1cb259b1 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -22,11 +22,6 @@ export const transact = cloudFunction< export const sellBet = cloudFunction('sellBet') -export const sellShares = cloudFunction< - { contractId: string; shares: number; outcome: 'YES' | 'NO' }, - { status: 'error' | 'success'; message?: string } ->('sellShares') - export const createAnswer = cloudFunction< { contractId: string; text: string; amount: number }, { From 244bbc51b2e04cd83f435e4d7a646413c9ce1401 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Tue, 7 Jun 2022 14:08:56 -0700 Subject: [PATCH 004/217] Migrate `sellBet` cloud function to v2 `sellbet` (#438) * Migrate sellBet to v2 * Kill sellBet warmup requests * Point client at new v2 sellbet function * Clean up `getSellBetInfo` * Fix up functions index.ts --- common/envs/dev.ts | 1 + common/envs/prod.ts | 7 +- common/envs/theoremone.ts | 1 + common/sell-bet.ts | 17 +-- functions/src/index.ts | 4 +- functions/src/scripts/migrate-to-dpm-2.ts | 10 +- functions/src/sell-bet.ts | 125 +++++++++------------- web/components/bets-list.tsx | 18 +--- web/lib/firebase/api-call.ts | 4 + web/lib/firebase/fn-call.ts | 2 - 10 files changed, 77 insertions(+), 112 deletions(-) diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 2c110861..151de075 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -15,6 +15,7 @@ export const DEV_CONFIG: EnvConfig = { functionEndpoints: { placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app', sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app', + sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app', createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app', }, } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 6538aa42..4352af64 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -1,4 +1,8 @@ -export type V2CloudFunction = 'placebet' | 'sellshares' | 'createmarket' +export type V2CloudFunction = + | 'placebet' + | 'sellbet' + | 'sellshares' + | 'createmarket' export type EnvConfig = { domain: string @@ -43,6 +47,7 @@ export const PROD_CONFIG: EnvConfig = { functionEndpoints: { placebet: 'https://placebet-nggbo3neva-uc.a.run.app', sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', + sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', }, adminEmails: [ diff --git a/common/envs/theoremone.ts b/common/envs/theoremone.ts index a2ddba51..54ced3f4 100644 --- a/common/envs/theoremone.ts +++ b/common/envs/theoremone.ts @@ -16,6 +16,7 @@ export const THEOREMONE_CONFIG: EnvConfig = { functionEndpoints: { placebet: 'https://placebet-nggbo3neva-uc.a.run.app', sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', + sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', }, adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'], diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 3070399b..2e75e4e7 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -7,18 +7,12 @@ import { import { calculateCpmmSale, getCpmmProbability } from './calculate-cpmm' import { CPMMContract, DPMContract } from './contract' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' -import { User } from './user' export type CandidateBet = Omit -export const getSellBetInfo = ( - user: User, - bet: Bet, - contract: DPMContract, - newBetId: string -) => { +export const getSellBetInfo = (bet: Bet, contract: DPMContract) => { const { pool, totalShares, totalBets } = contract - const { id: betId, amount, shares, outcome, loanAmount } = bet + const { id: betId, amount, shares, outcome } = bet const adjShareValue = calculateDpmShareValue(contract, bet) @@ -56,9 +50,7 @@ export const getSellBetInfo = ( creatorFee ) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount: -adjShareValue, shares: -shares, @@ -73,14 +65,11 @@ export const getSellBetInfo = ( fees, } - const newBalance = user.balance + saleAmount - (loanAmount ?? 0) - return { newBet, newPool, newTotalShares, newTotalBets, - newBalance, fees, } } diff --git a/functions/src/index.ts b/functions/src/index.ts index 043b860f..f3099fec 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -7,8 +7,6 @@ admin.initializeApp() export * from './transact' export * from './resolve-market' export * from './stripe' -export * from './sell-bet' -export * from './sell-shares' export * from './create-user' export * from './create-fold' export * from './create-answer' @@ -33,4 +31,6 @@ export * from './on-follow-user' // v2 export * from './health' export * from './place-bet' +export * from './sell-bet' +export * from './sell-shares' export * from './create-contract' diff --git a/functions/src/scripts/migrate-to-dpm-2.ts b/functions/src/scripts/migrate-to-dpm-2.ts index 98e2f9fc..d62862e0 100644 --- a/functions/src/scripts/migrate-to-dpm-2.ts +++ b/functions/src/scripts/migrate-to-dpm-2.ts @@ -11,7 +11,6 @@ import { getDpmProbability, } from '../../../common/calculate-dpm' import { getSellBetInfo } from '../../../common/sell-bet' -import { User } from '../../../common/user' type DocRef = admin.firestore.DocumentReference @@ -105,8 +104,6 @@ async function recalculateContract( const soldBet = bets.find((b) => b.id === bet.sale?.betId) if (!soldBet) throw new Error('invalid sold bet' + bet.sale.betId) - const fakeUser = { id: soldBet.userId, balance: 0 } as User - const fakeContract: Contract = { ...contract, totalBets, @@ -116,11 +113,14 @@ async function recalculateContract( } const { newBet, newPool, newTotalShares, newTotalBets } = - getSellBetInfo(fakeUser, soldBet, fakeContract, bet.id) + getSellBetInfo(soldBet, fakeContract) + const betDoc = betsRef.doc(bet.id) + const userId = soldBet.userId newBet.createdTime = bet.createdTime console.log('sale bet', newBet) - if (isCommit) transaction.update(betsRef.doc(bet.id), newBet) + if (isCommit) + transaction.update(betDoc, { id: bet.id, userId, ...newBet }) pool = newPool totalShares = newTotalShares diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 93fcb6a2..4bad754d 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -1,92 +1,73 @@ import * as admin from 'firebase-admin' -import * as functions from 'firebase-functions' +import { z } from 'zod' +import { APIError, newEndpoint, validate } from './api' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' -export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - contractId: string - betId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), + betId: z.string(), +}) - const { contractId, betId } = data +export const sellbet = newEndpoint(['POST'], async (req, [bettor, _]) => { + const { contractId, betId } = validate(bodySchema, req.body) - // run as transaction to prevent race conditions - return await firestore.runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // run as transaction to prevent race conditions + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${bettor.id}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as User - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - const { closeTime, mechanism, collectedFees, volume } = contract + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as Contract - if (mechanism !== 'dpm-2') - return { - status: 'error', - message: 'Sell shares only works with mechanism dpm-2', - } + const { closeTime, mechanism, collectedFees, volume } = contract + if (mechanism !== 'dpm-2') + throw new APIError(400, 'You can only sell bets on DPM-2 contracts.') + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed.') - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) + const betSnap = await transaction.get(betDoc) + if (!betSnap.exists) throw new APIError(400, 'Bet not found.') + const bet = betSnap.data() as Bet - const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) - const betSnap = await transaction.get(betDoc) - if (!betSnap.exists) return { status: 'error', message: 'Invalid bet' } - const bet = betSnap.data() as Bet + if (bettor.id !== bet.userId) + throw new APIError(400, 'The specified bet does not belong to you.') + if (bet.isSold) + throw new APIError(400, 'The specified bet is already sold.') - if (userId !== bet.userId) - return { status: 'error', message: 'Not authorized' } - if (bet.isSold) return { status: 'error', message: 'Bet already sold' } + const { newBet, newPool, newTotalShares, newTotalBets, fees } = + getSellBetInfo(bet, contract) - const newBetDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + const saleAmount = newBet.sale!.amount + const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0) + const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() - const { - newBet, - newPool, - newTotalShares, - newTotalBets, - newBalance, - fees, - } = getSellBetInfo(user, bet, contract, newBetDoc.id) + transaction.update(userDoc, { balance: newBalance }) + transaction.update(betDoc, { isSold: true }) + transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet }) + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + collectedFees: addObjects(fees, collectedFees), + volume: volume + Math.abs(newBet.amount), + }) + ) - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) - } - transaction.update(userDoc, { balance: newBalance }) - - transaction.update(betDoc, { isSold: true }) - transaction.create(newBetDoc, newBet) - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - totalShares: newTotalShares, - totalBets: newTotalBets, - collectedFees: addObjects(fees, collectedFees), - volume: volume + Math.abs(newBet.amount), - }) - ) - - return { status: 'success' } - }) - } -) + return {} + }) +}) const firestore = admin.firestore() diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 506da013..a4e90c35 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,13 +1,5 @@ import Link from 'next/link' -import { - uniq, - groupBy, - mapValues, - sortBy, - partition, - sumBy, - throttle, -} from 'lodash' +import { uniq, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' import { useEffect, useState } from 'react' import clsx from 'clsx' @@ -30,7 +22,7 @@ import { } from 'web/lib/firebase/contracts' import { Row } from './layout/row' import { UserLink } from './user-page' -import { sellBet } from 'web/lib/firebase/fn-call' +import { sellBet } from 'web/lib/firebase/api-call' import { ConfirmationButton } from './confirmation-button' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' import { filterDefined } from 'common/util/array' @@ -647,13 +639,7 @@ function BetRow(props: { ) } -const warmUpSellBet = throttle(() => sellBet({}).catch(() => {}), 5000 /* ms */) - function SellButton(props: { contract: Contract; bet: Bet }) { - useEffect(() => { - warmUpSellBet() - }, []) - const { contract, bet } = props const { outcome, shares, loanAmount } = bet diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 898ca3e7..4df1fccf 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -54,3 +54,7 @@ export function placeBet(params: any) { export function sellShares(params: any) { return call(getFunctionUrl('sellshares'), 'POST', params) } + +export function sellBet(params: any) { + return call(getFunctionUrl('sellbet'), 'POST', params) +} diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 1cb259b1..198e59f8 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -20,8 +20,6 @@ export const transact = cloudFunction< { status: 'error' | 'success'; message?: string; txn?: Txn } >('transact') -export const sellBet = cloudFunction('sellBet') - export const createAnswer = cloudFunction< { contractId: string; text: string; amount: number }, { From 82b189aa48241b95dd746075b18b7a6b2cc592d2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 7 Jun 2022 17:03:22 -0500 Subject: [PATCH 005/217] Don't show numeric bet panel if market closed --- web/pages/[username]/[contractSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 008a97aa..bf3571ff 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -193,7 +193,7 @@ export function ContractPageContent(props: Parameters[0]) { )} - {isNumeric && ( + {isNumeric && allowTrade && ( )} From 43be03617a76da1177b1bae508cbed586644a7c4 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Tue, 7 Jun 2022 15:39:05 -0700 Subject: [PATCH 006/217] Documentation for new write API endpoints (#430) --- docs/docs/api.md | 108 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index da692afc..61aca5d2 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -4,15 +4,40 @@ Our API is still in alpha — things may change or break at any time! -::: - -Manifold currently supports a basic, read-only API for getting information about our markets. - If you have questions, come chat with us on [Discord](https://discord.com/invite/eHQBNBqXuh). We’d love to hear about what you build! -## List out all markets +::: -### `/v0/markets` +## General notes + +Some APIs are not associated with any particular user. Other APIs require authentication. + +APIs that require authentication accept an `Authorization` header in one of two formats: + +- `Authorization: Key {key}`. A Manifold API key associated with a user + account. Each account may have zero or one API keys. To generate an API key + for your account, visit your user profile, click "edit", and click the + "refresh" button next to the API key field at the bottom. You can click it + again any time to invalidate your existing key and generate a new one. + +- `Authorization: Bearer {jwt}`. A signed JWT from Firebase asserting your + identity. This is what our web client uses. It will probably be annoying for + you to generate and we will not document it further here. + +API requests that accept parameters should have a body with a JSON object with +one property per parameter. + +API responses should always either have a body with a JSON result object (if +the response was a 200) or with a JSON object representing an error (if the +response was a 4xx or 5xx.) + +## Endpoints + +### `GET /v0/markets` + +Lists all markets. + +Requires no authorization. - Example request ``` @@ -72,9 +97,11 @@ If you have questions, come chat with us on [Discord](https://discord.com/invite } ``` -## Get information about one market +### `GET /v0/market/[marketId]` -### `/v0/market/[marketId]` +Gets information about a single market by ID. + +Requires no authorization. - Example request @@ -347,9 +374,11 @@ If you have questions, come chat with us on [Discord](https://discord.com/invite } ``` -### `/v0/slug/[marketSlug]` +### `GET /v0/slug/[marketSlug]` -This is a convenience endpoint for getting info about a market from it slug (everything after the last slash in a market’s URL). +Gets information about a single market by slug (the portion of the URL path after the username). + +Requires no authorization. - Example request ``` @@ -357,13 +386,66 @@ This is a convenience endpoint for getting info about a market from it slug (eve ``` - Response type: A `FullMarket` ; same as above. -## Deprecated +### `POST /v0/bet` -- Our old Markets API was available at [https://us-central1-mantic-markets.cloudfunctions.net/markets](https://us-central1-mantic-markets.cloudfunctions.net/markets) -- We don’t plan on continuing to change this, but we’ll support this endpoint until 2022-03-30 +Places a new bet on behalf of the authorized user. + +Parameters: + +- `amount`: Required. The amount to bet, in M$, before fees. +- `contractId`: Required. The ID of the contract (market) to bet on. +- `outcome`: Required. The outcome to bet on. For binary markets, this is `YES` + or `NO`. For free response markets, this is the ID of the free response + answer. For numeric markets, this is a string representing the target bucket, + and an additional `value` parameter is required which is a number representing + the target value. (Bet on numeric markets at your own peril.) + +Example request: + +``` +$ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"amount":1, \ + "outcome":"YES", \ + "contractId":"{...}"}' +``` + +### `POST /v0/market` + +Creates a new market on behalf of the authorized user. + +Parameters: + +- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. +- `question`: Required. The headline question for the market. +- `description`: Required. A long description describing the rules for the market. +- `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch. +- `tags`: Optional. An array of string tags for the market. + +For binary markets, you must also provide: + +- `initialProb`: An initial probability for the market, between 1 and 99. + +For numeric markets, you must also provide: + +- `min`: The minimum value that the market may resolve to. +- `max`: The maximum value that the market may resolve to. + +Example request: + +``` +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"outcomeType":"BINARY", \ + "question":"Is there life on Mars?", \ + "description":"I'm not going to type some long ass example description.", \ + "closeTime":1700000000000, \ + initialProb:25}' +``` ## Changelog +- 2022-06-05: Add new authorized write endpoints - 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition - 2022-02-19: Removed user IDs from bets - 2022-02-17: Released our v0 API, with `/markets`, `/market/[marketId]`, and `/slug/[slugId]` From 18044e7302b31eefb81c1184a4a5c79d4b504314 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Tue, 7 Jun 2022 15:44:07 -0700 Subject: [PATCH 007/217] api/v0/bets -> api/v0/bet --- web/pages/api/v0/{bets => bet}/index.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename web/pages/api/v0/{bets => bet}/index.ts (100%) diff --git a/web/pages/api/v0/bets/index.ts b/web/pages/api/v0/bet/index.ts similarity index 100% rename from web/pages/api/v0/bets/index.ts rename to web/pages/api/v0/bet/index.ts From 879ab272e0572b37390d13a5e3644c0f4b030c93 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 7 Jun 2022 22:24:18 -0500 Subject: [PATCH 008/217] Following and follower list (#456) * Create following button that opens follow list in modal. * Move react query deps to web package.json * UseFollowers hook * Following and followers button, dialog with tabs. * Fix line endings * Remove carriage return from default vscode eol * Add placeholder message if no users followed / no followers * Tweak spacing --- .vscode/settings.json | 2 +- firestore.rules | 3 +- functions/package.json | 2 - web/components/follow-list.tsx | 68 ++++++++++++++++++ web/components/following-button.tsx | 104 ++++++++++++++++++++++++++++ web/components/user-page.tsx | 7 +- web/hooks/use-follows.ts | 12 +++- web/hooks/use-user.ts | 27 ++++++++ web/lib/firebase/users.ts | 27 +++++++- web/package.json | 4 +- 10 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 web/components/follow-list.tsx create mode 100644 web/components/following-button.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 7819cbe0..ed5544ec 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "javascript.preferences.importModuleSpecifier": "shortest", "typescript.preferences.importModuleSpecifier": "shortest", - "files.eol": "\r\n", + "files.eol": "\n", "search.exclude": { "**/node_modules": true, "**/package-lock.json": true, diff --git a/firestore.rules b/firestore.rules index 32649752..b4a58074 100644 --- a/firestore.rules +++ b/firestore.rules @@ -24,9 +24,8 @@ service cloud.firestore { allow write: if request.auth.uid == userId; } - match /users/{userId}/follows/{followUserId} { + match /{somePath=**}/follows/{followUserId} { allow read; - allow write: if request.auth.uid == userId; } match /private-users/{userId} { diff --git a/functions/package.json b/functions/package.json index 3f3315f3..c51afd82 100644 --- a/functions/package.json +++ b/functions/package.json @@ -20,7 +20,6 @@ }, "main": "lib/functions/src/index.js", "dependencies": { - "@react-query-firebase/firestore": "0.4.2", "cors": "2.8.5", "fetch": "1.1.0", "firebase-admin": "10.0.0", @@ -28,7 +27,6 @@ "lodash": "4.17.21", "mailgun-js": "0.22.0", "module-alias": "2.2.2", - "react-query": "3.39.0", "stripe": "8.194.0", "zod": "3.17.2" }, diff --git a/web/components/follow-list.tsx b/web/components/follow-list.tsx new file mode 100644 index 00000000..0eef1170 --- /dev/null +++ b/web/components/follow-list.tsx @@ -0,0 +1,68 @@ +import clsx from 'clsx' +import { useFollows } from 'web/hooks/use-follows' +import { useUser, useUserById } from 'web/hooks/use-user' +import { follow, unfollow } from 'web/lib/firebase/users' +import { Avatar } from './avatar' +import { FollowButton } from './follow-button' +import { Col } from './layout/col' +import { Row } from './layout/row' +import { UserLink } from './user-page' + +export function FollowList(props: { userIds: string[] }) { + const { userIds } = props + const currentUser = useUser() + const followedUserIds = useFollows(currentUser?.id) + + const onFollow = (userId: string) => { + if (!currentUser) return + follow(currentUser.id, userId) + } + const onUnfollow = (userId: string) => { + if (!currentUser) return + unfollow(currentUser.id, userId) + } + + return ( + + {userIds.length === 0 && ( +
No users yet...
+ )} + {userIds.map((userId) => ( + onFollow(userId)} + onUnfollow={() => onUnfollow(userId)} + /> + ))} + + ) +} + +function UserFollowItem(props: { + userId: string + isFollowing: boolean + onFollow: () => void + onUnfollow: () => void + className?: string +}) { + const { userId, isFollowing, onFollow, onUnfollow, className } = props + const user = useUserById(userId) + + return ( + + + + {user && } + + + + ) +} diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx new file mode 100644 index 00000000..b36ee37d --- /dev/null +++ b/web/components/following-button.tsx @@ -0,0 +1,104 @@ +import { User } from 'common/user' +import { useEffect, useState } from 'react' +import { useFollowers, useFollows } from 'web/hooks/use-follows' +import { prefetchUsers } from 'web/hooks/use-user' +import { FollowList } from './follow-list' +import { Col } from './layout/col' +import { Modal } from './layout/modal' +import { Tabs } from './layout/tabs' + +export function FollowingButton(props: { user: User }) { + const { user } = props + const [open, setOpen] = useState(false) + const followingIds = useFollows(user.id) + const followerIds = useFollowers(user.id) + + return ( + <> +
setOpen(true)} + > + {followingIds?.length ?? ''}{' '} + Following +
+ + + + ) +} + +export function FollowersButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + const followingIds = useFollows(user.id) + const followerIds = useFollowers(user.id) + + return ( + <> +
setIsOpen(true)} + > + {followerIds?.length ?? ''}{' '} + Followers +
+ + + + ) +} + +function FollowingFollowersDialog(props: { + user: User + followingIds: string[] + followerIds: string[] + defaultTab: 'following' | 'followers' + isOpen: boolean + setIsOpen: (isOpen: boolean) => void +}) { + const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } = + props + + useEffect(() => { + prefetchUsers([...followingIds, ...followerIds]) + }, [followingIds, followerIds]) + + return ( + + +
{user.name}
+
@{user.username}
+ , + }, + { + title: 'Followers', + content: , + }, + ]} + defaultIndex={defaultTab === 'following' ? 0 : 1} + /> + +
+ ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 0c704056..8b63d01e 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -23,6 +23,7 @@ import { BetsList } from './bets-list' import { Bet } from 'common/bet' import { getUserBets } from 'web/lib/firebase/bets' import { uniq } from 'lodash' +import { FollowersButton, FollowingButton } from './following-button' export function UserLink(props: { name: string @@ -149,9 +150,10 @@ export function UserPage(props: { {user.name} @{user.username} + + {user.bio && ( <> -
@@ -160,6 +162,9 @@ export function UserPage(props: { )} + + + {user.website && ( { const [followIds, setFollowIds] = useState() @@ -10,3 +10,13 @@ export const useFollows = (userId: string | undefined) => { return followIds } + +export const useFollowers = (userId: string | undefined) => { + const [followerIds, setFollowerIds] = useState() + + useEffect(() => { + if (userId) return listenForFollowers(userId, setFollowerIds) + }, [userId]) + + return followerIds +} diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index bd4caf6e..7641f6e2 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -1,10 +1,15 @@ import { useEffect, useState } from 'react' +import { useFirestoreDocumentData } from '@react-query-firebase/firestore' +import { QueryClient } from 'react-query' + +import { DocumentData } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { listenForLogin, listenForPrivateUser, listenForUser, User, + userDocRef, } from 'web/lib/firebase/users' import { useStateCheckEquality } from './use-state-check-equality' @@ -35,3 +40,25 @@ export const usePrivateUser = (userId?: string) => { return privateUser } + +export const useUserById = (userId: string) => { + const result = useFirestoreDocumentData( + ['users', userId], + userDocRef(userId), + { subscribe: true, includeMetadataChanges: true } + ) + + return result.isLoading ? undefined : result.data +} + +const queryClient = new QueryClient() + +export const prefetchUser = (userId: string) => { + queryClient.prefetchQuery(['users', userId]) +} + +export const prefetchUsers = (userIds: string[]) => { + userIds.forEach((userId) => { + queryClient.prefetchQuery(['users', userId]) + }) +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 61b4fbb3..344ba7e6 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -11,6 +11,8 @@ import { orderBy, updateDoc, deleteDoc, + collectionGroup, + onSnapshot, } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' @@ -29,14 +31,17 @@ import { DAY_MS } from 'common/util/time' import { feed } from 'common/feed' import { CATEGORY_LIST } from 'common/categories' import { safeLocalStorage } from '../util/local' +import { filterDefined } from 'common/util/array' export type { User } const db = getFirestore(app) export const auth = getAuth(app) +export const userDocRef = (userId: string) => doc(db, 'users', userId) + export async function getUser(userId: string) { - const docSnap = await getDoc(doc(db, 'users', userId)) + const docSnap = await getDoc(userDocRef(userId)) return docSnap.data() as User } @@ -263,3 +268,23 @@ export function listenForFollows( setFollowIds(docs.map(({ userId }) => userId)) ) } + +export function listenForFollowers( + userId: string, + setFollowerIds: (followerIds: string[]) => void +) { + const followersQuery = query( + collectionGroup(db, 'follows'), + where('userId', '==', userId) + ) + return onSnapshot( + followersQuery, + { includeMetadataChanges: true }, + (snapshot) => { + if (snapshot.metadata.fromCache) return + + const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id) + setFollowerIds(filterDefined(values)) + } + ) +} diff --git a/web/package.json b/web/package.json index 889265c9..35b71341 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@heroicons/react": "1.0.5", "@nivo/core": "0.74.0", "@nivo/line": "0.74.0", + "@react-query-firebase/firestore": "0.4.2", "algoliasearch": "4.13.0", "clsx": "1.1.1", "cors": "2.8.5", @@ -37,7 +38,8 @@ "react-dom": "17.0.2", "react-expanding-textarea": "2.3.5", "react-hot-toast": "^2.2.0", - "react-instantsearch-hooks-web": "6.24.1" + "react-instantsearch-hooks-web": "6.24.1", + "react-query": "3.39.0" }, "devDependencies": { "@tailwindcss/forms": "0.4.0", From 66cf69e4256bb694543d7ff92f2e335ccf00145d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 7 Jun 2022 23:06:14 -0500 Subject: [PATCH 009/217] Fix react query console error --- web/hooks/use-user.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index 7641f6e2..b57a0256 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -5,6 +5,7 @@ import { QueryClient } from 'react-query' import { DocumentData } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { + getUser, listenForLogin, listenForPrivateUser, listenForUser, @@ -54,11 +55,9 @@ export const useUserById = (userId: string) => { const queryClient = new QueryClient() export const prefetchUser = (userId: string) => { - queryClient.prefetchQuery(['users', userId]) + queryClient.prefetchQuery(['users', userId], () => getUser(userId)) } export const prefetchUsers = (userIds: string[]) => { - userIds.forEach((userId) => { - queryClient.prefetchQuery(['users', userId]) - }) + userIds.forEach(prefetchUser) } From 00cbec230931343dfbd5ad5aabca2f4622d51620 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 7 Jun 2022 23:07:05 -0500 Subject: [PATCH 010/217] Show edit following button on home --- web/components/contract-search.tsx | 15 ++++------ web/components/feed/category-selector.tsx | 3 +- web/components/following-button.tsx | 34 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8ea2d1bd..fa22e0ef 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -25,6 +25,7 @@ import { useFollows } from 'web/hooks/use-follows' import { EditCategoriesButton } from './feed/category-selector' import { CATEGORIES } from 'common/categories' import { Tabs } from './layout/tabs' +import { EditFollowingButton } from './following-button' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -249,10 +250,6 @@ function CategoryFollowSelector(props: { const user = useUser() - const categoriesLabel = `Categories ${ - followedCategories.length ? followedCategories.length : '(All)' - }` - let categoriesDescription = `Showing all categories` if (followedCategories.length) { @@ -267,14 +264,12 @@ function CategoryFollowSelector(props: { categoriesDescription = `Showing ${categoriesLabel}${andMoreLabel}` } - const followingLabel = `Following ${follows.length}` - return (
{categoriesDescription}
@@ -285,11 +280,11 @@ function CategoryFollowSelector(props: { ...(user ? [ { - title: followingLabel, - + title: 'Following', content: ( - +
Showing markets by users you are following.
+
), }, diff --git a/web/components/feed/category-selector.tsx b/web/components/feed/category-selector.tsx index a9e6244a..4827186a 100644 --- a/web/components/feed/category-selector.tsx +++ b/web/components/feed/category-selector.tsx @@ -91,11 +91,12 @@ export function EditCategoriesButton(props: {
setIsOpen(true)} > + Categories setOpen(true)} + > + +
+ {followingIds?.length ?? ''}{' '} + Following +
+ + +
+ ) +} + export function FollowersButton(props: { user: User }) { const { user } = props const [isOpen, setIsOpen] = useState(false) From ac763de16bb0c96e799cb0d96b5c39855a4b9fec Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 7 Jun 2022 23:16:05 -0500 Subject: [PATCH 011/217] Run prettier! --- web/components/feed/category-selector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/feed/category-selector.tsx b/web/components/feed/category-selector.tsx index 4827186a..1b966c8c 100644 --- a/web/components/feed/category-selector.tsx +++ b/web/components/feed/category-selector.tsx @@ -91,7 +91,7 @@ export function EditCategoriesButton(props: {
setIsOpen(true)} > From ad6594f0bcf2627471df911ca2d3954fb218be92 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 7 Jun 2022 23:42:42 -0500 Subject: [PATCH 012/217] Add discover tab of users based on markets you have bet on --- web/components/following-button.tsx | 18 ++++++++++++++++- web/hooks/use-users.ts | 30 +++++++++++++++++++++++++++++ web/lib/firebase/bets.ts | 13 ++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx index 4e6a660b..32b1edf1 100644 --- a/web/components/following-button.tsx +++ b/web/components/following-button.tsx @@ -3,11 +3,12 @@ import { PencilIcon } from '@heroicons/react/outline' import { User } from 'common/user' import { useEffect, useState } from 'react' import { useFollowers, useFollows } from 'web/hooks/use-follows' -import { prefetchUsers } from 'web/hooks/use-user' +import { prefetchUsers, useUser } from 'web/hooks/use-user' import { FollowList } from './follow-list' import { Col } from './layout/col' import { Modal } from './layout/modal' import { Tabs } from './layout/tabs' +import { useDiscoverUsers } from 'web/hooks/use-users' export function FollowingButton(props: { user: User }) { const { user } = props @@ -114,6 +115,13 @@ function FollowingFollowersDialog(props: { prefetchUsers([...followingIds, ...followerIds]) }, [followingIds, followerIds]) + const currentUser = useUser() + + const discoverUserIds = useDiscoverUsers() + useEffect(() => { + prefetchUsers(discoverUserIds) + }, [discoverUserIds]) + return ( @@ -129,6 +137,14 @@ function FollowingFollowersDialog(props: { title: 'Followers', content: , }, + ...(currentUser + ? [ + { + title: 'Discover', + content: , + }, + ] + : []), ]} defaultIndex={defaultTab === 'following' ? 0 : 1} /> diff --git a/web/hooks/use-users.ts b/web/hooks/use-users.ts index 1b5656a0..428674ab 100644 --- a/web/hooks/use-users.ts +++ b/web/hooks/use-users.ts @@ -5,6 +5,10 @@ import { listenForAllUsers, listenForPrivateUsers, } from 'web/lib/firebase/users' +import { useUser } from './use-user' +import { groupBy, sortBy, difference } from 'lodash' +import { getContractsOfUserBets } from 'web/lib/firebase/bets' +import { useFollows } from './use-follows' export const useUsers = () => { const [users, setUsers] = useState([]) @@ -37,3 +41,29 @@ export const usePrivateUsers = () => { return users } + +export const useDiscoverUsers = () => { + const user = useUser() + + const [discoverUserIds, setDiscoverUserIds] = useState([]) + + useEffect(() => { + if (user) + getContractsOfUserBets(user.id).then((contracts) => { + const creatorCounts = Object.entries( + groupBy(contracts, 'creatorId') + ).map(([id, contracts]) => [id, contracts.length] as const) + + const topCreatorIds = sortBy(creatorCounts, ([_, i]) => i) + .map(([id]) => id) + .reverse() + + setDiscoverUserIds(topCreatorIds) + }) + }, [user]) + + const followedUserIds = useFollows(user?.id) + const nonSuggestions = [user?.id ?? '', ...(followedUserIds ?? [])] + + return difference(discoverUserIds, nonSuggestions).slice(0, 50) +} diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 74f30957..5311cee4 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -5,12 +5,14 @@ import { where, orderBy, } from 'firebase/firestore' -import { range } from 'lodash' +import { range, uniq } from 'lodash' import { db } from './init' import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { getValues, listenForValues } from './utils' +import { getContractFromId } from './contracts' +import { filterDefined } from 'common/util/array' export type { Bet } function getBetsCollection(contractId: string) { @@ -76,6 +78,15 @@ export async function getUserBets( .catch((reason) => reason) } +export async function getContractsOfUserBets(userId: string) { + const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false }) + const contractIds = uniq(bets.map((bet) => bet.contractId)) + const contracts = await Promise.all( + contractIds.map((contractId) => getContractFromId(contractId)) + ) + return filterDefined(contracts) +} + export function listenForUserBets( userId: string, setBets: (bets: Bet[]) => void, From 7e37fc776c1379bde3b40281b3395805832a5306 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 8 Jun 2022 07:24:12 -0600 Subject: [PATCH 013/217] Fr comment ux improvements (#451) * Extend comment input box, only use airplane * Only 1 commentable bet, shrink input, fix feed lines * Pad sign in to comment button * Small changes --- web/components/feed/activity-items.ts | 62 +---- .../feed/feed-answer-comment-group.tsx | 135 ++++++---- web/components/feed/feed-comments.tsx | 230 ++++++++++-------- 3 files changed, 221 insertions(+), 206 deletions(-) diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index f40de1df..4af7d385 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -29,7 +29,6 @@ export type CommentInputItem = BaseActivityItem & { type: 'commentInput' betsByCurrentUser: Bet[] commentsByCurrentUser: Comment[] - answerOutcome?: string } export type DescriptionItem = BaseActivityItem & { @@ -74,10 +73,10 @@ export type BetGroupItem = BaseActivityItem & { export type AnswerGroupItem = BaseActivityItem & { type: 'answergroup' + user: User | undefined | null answer: Answer - items: ActivityItem[] - betsByCurrentUser?: Bet[] - commentsByCurrentUser?: Comment[] + comments: Comment[] + bets: Bet[] } export type CloseItem = BaseActivityItem & { @@ -232,31 +231,19 @@ function getAnswerGroups( const answerGroups = outcomes .map((outcome) => { - const answerBets = bets.filter((bet) => bet.outcome === outcome) - const answerComments = comments.filter((comment) => - answerBets.some((bet) => bet.id === comment.betId) - ) const answer = contract.answers?.find( (answer) => answer.id === outcome ) as Answer - let items = groupBets(answerBets, answerComments, contract, user?.id, { - hideOutcome: true, - abbreviated, - smallAvatar: true, - reversed, - }) - - if (abbreviated) - items = items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW) - + // TODO: this doesn't abbreviate these groups for activity feed anymore return { id: outcome, type: 'answergroup' as const, contract, - answer, - items, user, + answer, + comments, + bets, } }) .filter((group) => group.answer) @@ -276,7 +263,6 @@ function getAnswerAndCommentInputGroups( outcomes = sortBy(outcomes, (outcome) => getOutcomeProbability(contract, outcome) ) - const betsByCurrentUser = bets.filter((bet) => bet.userId === user?.id) const answerGroups = outcomes .map((outcome) => { @@ -284,25 +270,14 @@ function getAnswerAndCommentInputGroups( (answer) => answer.id === outcome ) as Answer - const answerBets = bets.filter((bet) => bet.outcome === outcome) - const answerComments = comments.filter( - (comment) => - comment.answerOutcome === outcome || - answerBets.some((bet) => bet.id === comment.betId) - ) - const items = getCommentThreads(bets, answerComments, contract) - return { id: outcome, type: 'answergroup' as const, contract, - answer, - items, user, - betsByCurrentUser, - commentsByCurrentUser: answerComments.filter( - (comment) => comment.userId === user?.id - ), + answer, + comments, + bets, } }) .filter((group) => group.answer) as ActivityItem[] @@ -425,13 +400,6 @@ export function getAllContractActivityItems( } ) ) - items.push({ - type: 'commentInput' as const, - id: 'commentInput', - contract, - betsByCurrentUser: [], - commentsByCurrentUser: [], - }) } else { items.push( ...groupBetsAndComments(bets, comments, contract, user?.id, { @@ -450,16 +418,6 @@ export function getAllContractActivityItems( items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract }) } - if (outcomeType === 'BINARY') { - items.push({ - type: 'commentInput' as const, - id: 'commentInput', - contract, - betsByCurrentUser: [], - commentsByCurrentUser: [], - }) - } - if (reversed) items.reverse() return items diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 1b976668..a745a8d9 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -1,8 +1,6 @@ import { Answer } from 'common/answer' -import { ActivityItem } from 'web/components/feed/activity-items' import { Bet } from 'common/bet' import { Comment } from 'common/comment' -import { useUser } from 'web/hooks/use-user' import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { formatPercent } from 'common/util/format' import React, { useEffect, useState } from 'react' @@ -16,44 +14,80 @@ import { Linkify } from 'web/components/linkify' import clsx from 'clsx' import { tradingAllowed } from 'web/lib/firebase/contracts' import { BuyButton } from 'web/components/yes-no-selector' -import { FeedItem } from 'web/components/feed/feed-items' import { CommentInput, + CommentRepliesList, getMostRecentCommentableBet, } from 'web/components/feed/feed-comments' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { useRouter } from 'next/router' +import { groupBy } from 'lodash' +import { User } from 'common/user' export function FeedAnswerCommentGroup(props: { contract: any + user: User | undefined | null answer: Answer - items: ActivityItem[] - type: string - betsByCurrentUser?: Bet[] - commentsByCurrentUser?: Comment[] + comments: Comment[] + bets: Bet[] }) { - const { answer, items, contract, betsByCurrentUser, commentsByCurrentUser } = - props + const { answer, contract, comments, bets, user } = props const { username, avatarUrl, name, text } = answer - const answerElementId = `answer-${answer.id}` - const user = useUser() - const mostRecentCommentableBet = getMostRecentCommentableBet( - betsByCurrentUser ?? [], - commentsByCurrentUser ?? [], - user, - answer.number + '' - ) - const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) - const probPercent = formatPercent(prob) + + const [replyToUsername, setReplyToUsername] = useState('') const [open, setOpen] = useState(false) const [showReply, setShowReply] = useState(false) - const isFreeResponseContractPage = !!commentsByCurrentUser - if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true) const [inputRef, setInputRef] = useState(null) + const [highlighted, setHighlighted] = useState(false) + const router = useRouter() - // If they've already opened the input box, focus it once again - function setShowReplyAndFocus(show: boolean) { - setShowReply(show) + const answerElementId = `answer-${answer.id}` + const betsByUserId = groupBy(bets, (bet) => bet.userId) + const commentsByUserId = groupBy(comments, (comment) => comment.userId) + const answerComments = comments.filter( + (comment) => comment.answerOutcome === answer.number.toString() + ) + const commentReplies = comments.filter( + (comment) => + comment.replyToCommentId && + !comment.answerOutcome && + answerComments.map((c) => c.id).includes(comment.replyToCommentId) + ) + const commentsList = answerComments.concat(commentReplies) + + const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) + const probPercent = formatPercent(prob) + const betsByCurrentUser = (user && betsByUserId[user.id]) ?? [] + const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] + const isFreeResponseContractPage = !!commentsByCurrentUser + useEffect(() => { + const mostRecentCommentableBet = getMostRecentCommentableBet( + betsByCurrentUser, + commentsByCurrentUser, + user, + answer.number.toString() + ) + if (mostRecentCommentableBet && !showReply) + scrollAndOpenReplyInput(undefined, answer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [betsByCurrentUser]) + + useEffect(() => { + // Only show one comment input for a bet at a time + const usersMostRecentBet = bets + .filter((b) => b.userId === user?.id) + .sort((a, b) => b.createdTime - a.createdTime)[0] + if ( + usersMostRecentBet && + usersMostRecentBet.outcome !== answer.number.toString() + ) { + setShowReply(false) + } + }, [answer.number, bets, user]) + + function scrollAndOpenReplyInput(comment?: Comment, answer?: Answer) { + setReplyToUsername(comment?.userUsername ?? answer?.username ?? '') + setShowReply(true) inputRef?.focus() } @@ -61,8 +95,6 @@ export function FeedAnswerCommentGroup(props: { if (showReply && inputRef) inputRef.focus() }, [inputRef, showReply]) - const [highlighted, setHighlighted] = useState(false) - const router = useRouter() useEffect(() => { if (router.asPath.endsWith(`#${answerElementId}`)) { setHighlighted(true) @@ -70,7 +102,7 @@ export function FeedAnswerCommentGroup(props: { }, [answerElementId, router.asPath]) return ( - + setShowReplyAndFocus(true)} + onClick={() => scrollAndOpenReplyInput(undefined, answer)} > Reply @@ -143,7 +175,7 @@ export function FeedAnswerCommentGroup(props: {