diff --git a/common/access.ts b/common/access.ts new file mode 100644 index 00000000..acd894b1 --- /dev/null +++ b/common/access.ts @@ -0,0 +1,14 @@ +export function isWhitelisted(email?: string) { + return true + // e.g. return email.endsWith('@theoremone.co') || isAdmin(email) +} + +export function isAdmin(email: string) { + const ADMINS = [ + 'akrolsmir@gmail.com', // Austin + 'jahooma@gmail.com', // James + 'taowell@gmail.com', // Stephen + 'manticmarkets@gmail.com', // Manifold + ] + return ADMINS.includes(email) +} diff --git a/common/bet.ts b/common/bet.ts index 7da4b18c..a3e8e714 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -4,6 +4,7 @@ export type Bet = { contractId: string amount: number // bet size; negative if SELL bet + loanAmount?: number outcome: string shares: number // dynamic parimutuel pool weight; negative if SELL bet @@ -21,3 +22,5 @@ export type Bet = { createdTime: number } + +export const MAX_LOAN_PER_CONTRACT = 20 diff --git a/common/new-bet.ts b/common/new-bet.ts index 4405987a..161a44a9 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,4 +1,5 @@ -import { Bet } from './bet' +import * as _ from 'lodash' +import { Bet, MAX_LOAN_PER_CONTRACT } from './bet' import { calculateShares, getProbability, @@ -20,6 +21,7 @@ export const getNewBinaryCpmmBetInfo = ( outcome: 'YES' | 'NO', amount: number, contract: FullContract, + loanAmount: number, newBetId: string ) => { const { pool, k } = contract @@ -32,7 +34,7 @@ export const getNewBinaryCpmmBetInfo = ( ? [y - shares + amount, amount] : [amount, n - shares + amount] - const newBalance = user.balance - amount + const newBalance = user.balance - (amount - loanAmount) const probBefore = getCpmmProbability(pool) const newPool = { YES: newY, NO: newN } @@ -45,6 +47,7 @@ export const getNewBinaryCpmmBetInfo = ( amount, shares, outcome, + loanAmount, probBefore, probAfter, createdTime: Date.now(), @@ -58,6 +61,7 @@ export const getNewBinaryDpmBetInfo = ( outcome: 'YES' | 'NO', amount: number, contract: FullContract, + loanAmount: number, newBetId: string ) => { const { YES: yesPool, NO: noPool } = contract.pool @@ -91,6 +95,7 @@ export const getNewBinaryDpmBetInfo = ( userId: user.id, contractId: contract.id, amount, + loanAmount, shares, outcome, probBefore, @@ -98,7 +103,7 @@ export const getNewBinaryDpmBetInfo = ( createdTime: Date.now(), } - const newBalance = user.balance - amount + const newBalance = user.balance - (amount - loanAmount) return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } @@ -108,6 +113,7 @@ export const getNewMultiBetInfo = ( outcome: string, amount: number, contract: FullContract, + loanAmount: number, newBetId: string ) => { const { pool, totalShares, totalBets } = contract @@ -131,6 +137,7 @@ export const getNewMultiBetInfo = ( userId: user.id, contractId: contract.id, amount, + loanAmount, shares, outcome, probBefore, @@ -138,7 +145,17 @@ export const getNewMultiBetInfo = ( createdTime: Date.now(), } - const newBalance = user.balance - amount + const newBalance = user.balance - (amount - loanAmount) return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } + +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/payouts.ts b/common/payouts.ts index 495d734f..ed9e0613 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -281,3 +281,12 @@ export const getPayoutsMultiOutcome = ( .map(({ userId, payout }) => ({ userId, payout })) .concat([{ userId: contract.creatorId, payout: creatorPayout }]) // add creator fee } + +export const getLoanPayouts = (bets: Bet[]) => { + const betsWithLoans = bets.filter((bet) => bet.loanAmount) + const betsByUser = _.groupBy(betsWithLoans, (bet) => bet.userId) + const loansByUser = _.mapValues(betsByUser, (bets) => + _.sumBy(bets, (bet) => -(bet.loanAmount ?? 0)) + ) + return _.toPairs(loansByUser).map(([userId, payout]) => ({ userId, payout })) +} diff --git a/common/scoring.ts b/common/scoring.ts index 36068652..78571377 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -48,8 +48,9 @@ export function scoreUsersByContract( const investments = bets .filter((bet) => !bet.sale) .map((bet) => { - const { userId, amount } = bet - return { userId, payout: -amount } + const { userId, amount, loanAmount } = bet + const payout = -amount - (loanAmount ?? 0) + return { userId, payout } }) const netPayouts = [...resolvePayouts, ...salePayouts, ...investments] diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 778ff000..907391e4 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -16,7 +16,7 @@ export const getSellBetInfo = ( newBetId: string ) => { const { pool, totalShares, totalBets } = contract - const { id: betId, amount, shares, outcome } = bet + const { id: betId, amount, shares, outcome, loanAmount } = bet const adjShareValue = calculateShareValue(contract, bet) @@ -62,7 +62,7 @@ export const getSellBetInfo = ( }, } - const newBalance = user.balance + saleAmount + const newBalance = user.balance + saleAmount - (loanAmount ?? 0) return { newBet, diff --git a/common/user.ts b/common/user.ts index 73186ac8..8f8e6d0d 100644 --- a/common/user.ts +++ b/common/user.ts @@ -28,6 +28,8 @@ export type PrivateUser = { email?: string unsubscribedFromResolutionEmails?: boolean + unsubscribedFromCommentEmails?: boolean + unsubscribedFromAnswerEmails?: boolean initialDeviceToken?: string initialIpAddress?: string } diff --git a/firebase.json b/firebase.json index 988e04a4..8da7d8f3 100644 --- a/firebase.json +++ b/firebase.json @@ -5,6 +5,7 @@ "source": "functions" }, "firestore": { - "rules": "firestore.rules" + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" } } diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 00000000..ac88ccf6 --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,296 @@ +{ + "indexes": [ + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "creatorId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "closeTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "closeTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "volume24Hours", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "volume24Hours", + "order": "ASCENDING" + }, + { + "fieldPath": "closeTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "volume24Hours", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "volume24Hours", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "slug", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + } + ], + "fieldOverrides": [ + { + "collectionGroup": "answers", + "fieldPath": "isAnte", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "answers", + "fieldPath": "username", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "bets", + "fieldPath": "createdTime", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "bets", + "fieldPath": "userId", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "comments", + "fieldPath": "createdTime", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "comments", + "fieldPath": "userUsername", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "followers", + "fieldPath": "userId", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + } + ] +} diff --git a/functions/.gitignore b/functions/.gitignore index e0ba0181..8b54e3dc 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -1,3 +1,6 @@ +# Secrets +.env* + # Compiled JavaScript files lib/**/*.js lib/**/*.js.map diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index d18a5a2a..f192fc7e 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -3,9 +3,11 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { User } from '../../common/user' -import { getNewMultiBetInfo } from '../../common/new-bet' +import { getLoanAmount, getNewMultiBetInfo } from '../../common/new-bet' import { Answer } from '../../common/answer' -import { getValues } from './utils' +import { getContract, getValues } from './utils' +import { sendNewAnswerEmail } from './emails' +import { Bet } from '../../common/bet' export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -28,7 +30,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( return { status: 'error', message: 'Invalid text' } // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { + const result = await firestore.runTransaction(async (transaction) => { const userDoc = firestore.doc(`users/${userId}`) const userSnap = await transaction.get(userDoc) if (!userSnap.exists) @@ -54,6 +56,11 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( if (closeTime && Date.now() > closeTime) return { status: 'error', message: 'Trading is closed' } + const yourBetsSnap = await transaction.get( + contractDoc.collection('bets').where('userId', '==', userId) + ) + const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) + const [lastAnswer] = await getValues( firestore .collection(`contracts/${contractId}/answers`) @@ -91,8 +98,17 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() + const loanAmount = getLoanAmount(yourBets, amount) + const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = - getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id) + getNewMultiBetInfo( + user, + answerId, + amount, + loanAmount, + contract, + newBetDoc.id + ) transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { @@ -103,8 +119,15 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( }) transaction.update(userDoc, { balance: newBalance }) - return { status: 'success', answerId, betId: newBetDoc.id } + return { status: 'success', answerId, betId: newBetDoc.id, answer } }) + + const { answer } = result + const contract = await getContract(contractId) + + if (answer && contract) await sendNewAnswerEmail(answer, contract) + + return result } ) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 13c880f3..f583abe4 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -14,6 +14,7 @@ import { cleanUsername, } from '../../common/util/clean-username' import { sendWelcomeEmail } from './emails' +import { isWhitelisted } from '../../common/access' export const createUser = functions .runWith({ minInstances: 1 }) @@ -32,6 +33,9 @@ export const createUser = functions const fbUser = await admin.auth().getUser(userId) const email = fbUser.email + if (!isWhitelisted(email)) { + return { status: 'error', message: `${email} is not whitelisted` } + } const emailName = email?.replace(/@.*$/, '') const rawName = fbUser.displayName || emailName || 'User' + randomString(4) diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html new file mode 100644 index 00000000..4e1a2bfa --- /dev/null +++ b/functions/src/email-templates/market-answer-comment.html @@ -0,0 +1,561 @@ + + + + + + Market comment + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + +
+ Manifold Markets +
+ + + + + + + + + + + + + + + + +
+ Answer {{answerNumber}} +
+
+ {{answer}} +
+
+
+ + {{commentorName}} + {{betDescription}} +
+
+
+ {{comment}} +
+
+ +
+
+
+ +
+
+ + diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html new file mode 100644 index 00000000..225436ad --- /dev/null +++ b/functions/src/email-templates/market-answer.html @@ -0,0 +1,499 @@ + + + + + + Market answer + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + +
+ Manifold Markets +
+ + + + + + + + + + +
+
+ + {{name}} +
+
+
+ {{answer}} +
+
+ +
+
+
+ +
+
+ + diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html new file mode 100644 index 00000000..a602f527 --- /dev/null +++ b/functions/src/email-templates/market-close.html @@ -0,0 +1,647 @@ + + + + + + Market closed + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ Manifold Markets +
+ You asked +
+ + {{question}} +
+

+ Market closed +

+
+ + + + + + + +
+ Hi {{name}}, +
+
+ A market you created has closed. It's attracted + {{pool}} in + bets — congrats! +
+
+ Resolve your market to earn {{creatorFee}}% of the + profits as the creator commission. +
+
+ Thanks, +
+ Manifold Team +
+
+
+ +
+
+
+ +
+
+ + diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html new file mode 100644 index 00000000..84118964 --- /dev/null +++ b/functions/src/email-templates/market-comment.html @@ -0,0 +1,501 @@ + + + + + + Market comment + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + +
+ Manifold Markets +
+ + + + + + + + + + +
+
+ + {{commentorName}} + {{betDescription}} +
+
+
+ {{comment}} +
+
+ +
+
+
+ +
+
+ + diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html new file mode 100644 index 00000000..42d4e2d8 --- /dev/null +++ b/functions/src/email-templates/market-resolved.html @@ -0,0 +1,669 @@ + + + + + + Market resolved + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ Manifold Markets +
+ {{creatorName}} asked +
+ + {{question}} +
+

+ Resolved {{outcome}} +

+
+ + + + + + + +
+ Dear {{name}}, +
+
+ A market you bet in has been resolved! +
+
+ Your investment was + M$ {{investment}}. +
+
+ Your payout is + M$ {{payout}}. +
+
+ Thanks, +
+ Manifold Team +
+
+
+ +
+
+
+ +
+
+ + diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 2b5981f5..0ded7b7d 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,11 +1,14 @@ import _ = require('lodash') +import { Answer } from '../../common/answer' +import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' +import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' import { formatMoney, formatPercent } from '../../common/util/format' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getPrivateUser, getUser } from './utils' +import { getPrivateUser, getUser, isProd } from './utils' type market_resolved_template = { userId: string @@ -13,13 +16,14 @@ type market_resolved_template = { creatorName: string question: string outcome: string + investment: string payout: string url: string } const toDisplayResolution = ( outcome: string, - prob: number, + prob?: number, resolutions?: { [outcome: string]: number } ) => { if (outcome === 'MKT' && resolutions) return 'MULTI' @@ -28,7 +32,7 @@ const toDisplayResolution = ( YES: 'YES', NO: 'NO', CANCEL: 'N/A', - MKT: formatPercent(prob), + MKT: formatPercent(prob ?? 0), }[outcome] return display === undefined ? `#${outcome}` : display @@ -36,6 +40,7 @@ const toDisplayResolution = ( export const sendMarketResolutionEmail = async ( userId: string, + investment: number, payout: number, creator: User, contract: Contract, @@ -66,6 +71,7 @@ export const sendMarketResolutionEmail = async ( creatorName: creator.name, question: contract.question, outcome, + investment: `${Math.round(investment)}`, payout: `${Math.round(payout)}`, url: `https://manifold.markets/${creator.username}/${contract.slug}`, } @@ -138,3 +144,119 @@ export const sendMarketCloseEmail = async ( } ) } + +export const sendNewCommentEmail = async ( + userId: string, + commentCreator: User, + contract: Contract, + comment: Comment, + bet: Bet, + answer?: Answer +) => { + const privateUser = await getPrivateUser(userId) + if ( + !privateUser || + !privateUser.email || + privateUser.unsubscribedFromCommentEmails + ) + return + + const { question, creatorUsername, slug } = contract + const marketUrl = `https://manifold.markets/${creatorUsername}/${slug}` + + const unsubscribeUrl = `https://us-central1-${ + isProd ? 'mantic-markets' : 'dev-mantic-markets' + }.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` + + const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator + const { text } = comment + + const { amount, sale, outcome } = bet + let betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}` + + const subject = `Comment on ${question}` + const from = `${commentorName} ` + + if (contract.outcomeType === 'FREE_RESPONSE') { + const answerText = answer?.text ?? '' + const answerNumber = `#${answer?.id ?? ''}` + + await sendTemplateEmail( + privateUser.email, + subject, + 'market-answer-comment', + { + answer: answerText, + answerNumber, + commentorName, + commentorAvatarUrl: commentorAvatarUrl ?? '', + comment: text, + marketUrl, + unsubscribeUrl, + betDescription, + }, + { from } + ) + } else { + betDescription = `${betDescription} of ${toDisplayResolution(outcome)}` + + await sendTemplateEmail( + privateUser.email, + subject, + 'market-comment', + { + commentorName, + commentorAvatarUrl: commentorAvatarUrl ?? '', + comment: text, + marketUrl, + unsubscribeUrl, + betDescription, + }, + { from } + ) + } +} + +export const sendNewAnswerEmail = async ( + answer: Answer, + contract: Contract +) => { + // Send to just the creator for now. + const { creatorId: userId } = contract + + // Don't send the creator's own answers. + if (answer.userId === userId) return + + const privateUser = await getPrivateUser(userId) + if ( + !privateUser || + !privateUser.email || + privateUser.unsubscribedFromAnswerEmails + ) + return + + const { question, creatorUsername, slug } = contract + const { name, avatarUrl, text } = answer + + const marketUrl = `https://manifold.markets/${creatorUsername}/${slug}` + const unsubscribeUrl = `https://us-central1-${ + isProd ? 'mantic-markets' : 'dev-mantic-markets' + }.cloudfunctions.net/unsubscribe?id=${userId}&type=market-answer` + + const subject = `New answer on ${question}` + const from = `${name} ` + + await sendTemplateEmail( + privateUser.email, + subject, + 'market-answer', + { + name, + avatarUrl: avatarUrl ?? '', + answer: text, + marketUrl, + unsubscribeUrl, + }, + { from } + ) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 50c748af..dc333343 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -12,6 +12,7 @@ export * from './create-contract' export * from './create-user' export * from './create-fold' export * from './create-answer' +export * from './on-create-comment' export * from './on-fold-follow' export * from './on-fold-delete' export * from './unsubscribe' diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment.ts new file mode 100644 index 00000000..85af37ee --- /dev/null +++ b/functions/src/on-create-comment.ts @@ -0,0 +1,60 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { getContract, getUser, getValues } from './utils' +import { Comment } from '../../common/comment' +import { sendNewCommentEmail } from './emails' +import { Bet } from '../../common/bet' + +const firestore = admin.firestore() + +export const onCreateComment = functions.firestore + .document('contracts/{contractId}/comments/{commentId}') + .onCreate(async (change, context) => { + const { contractId } = context.params as { + contractId: string + } + + const contract = await getContract(contractId) + if (!contract) return + + const comment = change.data() as Comment + + const commentCreator = await getUser(comment.userId) + if (!commentCreator) return + + const betSnapshot = await firestore + .collection('contracts') + .doc(contractId) + .collection('bets') + .doc(comment.betId) + .get() + const bet = betSnapshot.data() as Bet + + const answer = + contract.answers && + contract.answers.find((answer) => answer.id === bet.outcome) + + const comments = await getValues( + firestore.collection('contracts').doc(contractId).collection('comments') + ) + + const recipientUserIds = _.uniq([ + contract.creatorId, + ...comments.map((comment) => comment.userId), + ]).filter((id) => id !== comment.userId) + + await Promise.all( + recipientUserIds.map((userId) => + sendNewCommentEmail( + userId, + commentCreator, + contract, + comment, + bet, + answer + ) + ) + ) + }) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index c1947def..de018de0 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -7,8 +7,11 @@ import { getNewBinaryCpmmBetInfo, getNewBinaryDpmBetInfo, getNewMultiBetInfo, + getLoanAmount, } from '../../common/new-bet' import { removeUndefinedProps } from '../../common/util/object' +import { Bet } from '../../common/bet' +import { getValues } from './utils' export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -51,6 +54,11 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( if (closeTime && Date.now() > closeTime) return { status: 'error', message: 'Trading is closed' } + const yourBetsSnap = await transaction.get( + contractDoc.collection('bets').where('userId', '==', userId) + ) + const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) + if (outcomeType === 'FREE_RESPONSE') { const answerSnap = await transaction.get( contractDoc.collection('answers').doc(outcome) @@ -63,6 +71,8 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() + const loanAmount = getLoanAmount(yourBets, amount) + const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = outcomeType === 'BINARY' ? mechanism === 'dpm-2' @@ -71,6 +81,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( outcome as 'YES' | 'NO', amount, contract, + loanAmount, newBetDoc.id ) : (getNewBinaryCpmmBetInfo( @@ -78,6 +89,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( outcome as 'YES' | 'NO', amount, contract, + loanAmount, newBetDoc.id ) as any) : getNewMultiBetInfo( @@ -85,6 +97,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( outcome, amount, contract as any, + loanAmount, newBetDoc.id ) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 824edac3..9872456d 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -7,7 +7,12 @@ import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, payUser } from './utils' import { sendMarketResolutionEmail } from './emails' -import { getPayouts, getPayoutsMultiOutcome } from '../../common/payouts' +import { + getLoanPayouts, + getPayouts, + getPayoutsMultiOutcome, +} from '../../common/payouts' +import { removeUndefinedProps } from '../../common/util/object' export const resolveMarket = functions .runWith({ minInstances: 1 }) @@ -31,7 +36,7 @@ export const resolveMarket = functions if (!contractSnap.exists) return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - const { creatorId, outcomeType } = contract + const { creatorId, outcomeType, closeTime } = contract if (outcomeType === 'BINARY') { if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome)) @@ -68,15 +73,21 @@ export const resolveMarket = functions const resolutionProbability = probabilityInt !== undefined ? probabilityInt / 100 : undefined - await contractDoc.update({ - isResolved: true, - resolution: outcome, - resolutionTime: Date.now(), - ...(resolutionProbability === undefined - ? {} - : { resolutionProbability }), - ...(resolutions === undefined ? {} : { resolutions }), - }) + const resolutionTime = Date.now() + const newCloseTime = closeTime + ? Math.min(closeTime, resolutionTime) + : closeTime + + await contractDoc.update( + removeUndefinedProps({ + isResolved: true, + resolution: outcome, + resolutionTime, + closeTime: newCloseTime, + resolutionProbability, + resolutions, + }) + ) console.log('contract ', contractId, 'resolved to:', outcome) @@ -92,13 +103,23 @@ export const resolveMarket = functions ? getPayoutsMultiOutcome(resolutions, contract as any, openBets) : getPayouts(outcome, contract, openBets, resolutionProbability) + const loanPayouts = getLoanPayouts(openBets) + console.log('payouts:', payouts) - const groups = _.groupBy(payouts, (payout) => payout.userId) + const groups = _.groupBy( + [...payouts, ...loanPayouts], + (payout) => payout.userId + ) const userPayouts = _.mapValues(groups, (group) => _.sumBy(group, (g) => g.payout) ) + const groupsWithoutLoans = _.groupBy(payouts, (payout) => payout.userId) + const userPayoutsWithoutLoans = _.mapValues(groupsWithoutLoans, (group) => + _.sumBy(group, (g) => g.payout) + ) + const payoutPromises = Object.entries(userPayouts).map( ([userId, payout]) => payUser(userId, payout) ) @@ -109,7 +130,7 @@ export const resolveMarket = functions await sendResolutionEmails( openBets, - userPayouts, + userPayoutsWithoutLoans, creator, contract, outcome, @@ -134,14 +155,24 @@ const sendResolutionEmails = async ( _.uniq(openBets.map(({ userId }) => userId)), Object.keys(userPayouts) ) + const investedByUser = _.mapValues( + _.groupBy(openBets, (bet) => bet.userId), + (bets) => _.sumBy(bets, (bet) => bet.amount) + ) const emailPayouts = [ ...Object.entries(userPayouts), ...nonWinners.map((userId) => [userId, 0] as const), - ] + ].map(([userId, payout]) => ({ + userId, + investment: investedByUser[userId], + payout, + })) + await Promise.all( - emailPayouts.map(([userId, payout]) => + emailPayouts.map(({ userId, investment, payout }) => sendMarketResolutionEmail( userId, + investment, payout, creator, contract, diff --git a/functions/src/scripts/remove-answer-ante.ts b/functions/src/scripts/remove-answer-ante.ts new file mode 100644 index 00000000..555b5fc0 --- /dev/null +++ b/functions/src/scripts/remove-answer-ante.ts @@ -0,0 +1,44 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' + +import { initAdmin } from './script-init' +initAdmin('james') + +import { Bet } from '../../../common/bet' +import { Contract } from '../../../common/contract' +import { getValues } from '../utils' + +async function removeAnswerAnte() { + const firestore = admin.firestore() + console.log('Removing isAnte from bets on answers') + + const contracts = await getValues( + firestore + .collection('contracts') + .where('outcomeType', '==', 'FREE_RESPONSE') + ) + + console.log('Loaded', contracts, 'contracts') + + for (const contract of contracts) { + const betsSnapshot = await firestore + .collection('contracts') + .doc(contract.id) + .collection('bets') + .get() + + console.log('updating', contract.question) + + for (const doc of betsSnapshot.docs) { + const bet = doc.data() as Bet + if (bet.isAnte && bet.outcome !== '0') { + console.log('updating', bet.outcome) + await doc.ref.update('isAnte', false) + } + } + } +} + +if (require.main === module) { + removeAnswerAnte().then(() => process.exit()) +} diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index 938e3fc5..b0ac58a6 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -24,10 +24,11 @@ export const sendTemplateEmail = ( to: string, subject: string, templateId: string, - templateData: Record + templateData: Record, + options?: { from: string } ) => { const data = { - from: 'Manifold Markets ', + from: options?.from ?? 'Manifold Markets ', to, subject, template: templateId, diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index 4dffe541..eef9f40f 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import Stripe from 'stripe' -import { payUser } from './utils' +import { isProd, payUser } from './utils' export type StripeTransaction = { userId: string @@ -18,20 +18,19 @@ const stripe = new Stripe(functions.config().stripe.apikey, { }) // manage at https://dashboard.stripe.com/test/products?active=true -const manticDollarStripePrice = - admin.instanceId().app.options.projectId === 'mantic-markets' - ? { - 500: 'price_1KFQXcGdoFKoCJW770gTNBrm', - 1000: 'price_1KFQp1GdoFKoCJW7Iu0dsF65', - 2500: 'price_1KFQqNGdoFKoCJW7SDvrSaEB', - 10000: 'price_1KFQraGdoFKoCJW77I4XCwM3', - } - : { - 500: 'price_1K8W10GdoFKoCJW7KWORLec1', - 1000: 'price_1K8bC1GdoFKoCJW76k3g5MJk', - 2500: 'price_1K8bDSGdoFKoCJW7avAwpV0e', - 10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE', - } +const manticDollarStripePrice = isProd + ? { + 500: 'price_1KFQXcGdoFKoCJW770gTNBrm', + 1000: 'price_1KFQp1GdoFKoCJW7Iu0dsF65', + 2500: 'price_1KFQqNGdoFKoCJW7SDvrSaEB', + 10000: 'price_1KFQraGdoFKoCJW77I4XCwM3', + } + : { + 500: 'price_1K8W10GdoFKoCJW7KWORLec1', + 1000: 'price_1K8bC1GdoFKoCJW76k3g5MJk', + 2500: 'price_1K8bDSGdoFKoCJW7avAwpV0e', + 10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE', + } export const createCheckoutSession = functions .runWith({ minInstances: 1 }) diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index d556f4ba..25b11771 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -1,30 +1,47 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import * as _ from 'lodash' -import { getPrivateUser } from './utils' +import { getUser } from './utils' import { PrivateUser } from '../../common/user' export const unsubscribe = functions .runWith({ minInstances: 1 }) .https.onRequest(async (req, res) => { - let id = req.query.id as string - if (!id) return + const { id, type } = req.query as { id: string; type: string } + if (!id || !type) return - let privateUser = await getPrivateUser(id) + const user = await getUser(id) - if (privateUser) { - let { username } = privateUser + if (user) { + const { name } = user const update: Partial = { - unsubscribedFromResolutionEmails: true, + ...(type === 'market-resolve' && { + unsubscribedFromResolutionEmails: true, + }), + ...(type === 'market-comment' && { + unsubscribedFromCommentEmails: true, + }), + ...(type === 'market-answer' && { + unsubscribedFromAnswerEmails: true, + }), } await firestore.collection('private-users').doc(id).update(update) - res.send( - username + - ', you have been unsubscribed from market resolution emails on Manifold Markets.' - ) + if (type === 'market-resolve') + res.send( + `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` + ) + else if (type === 'market-comment') + res.send( + `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` + ) + else if (type === 'market-answer') + res.send( + `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` + ) + else res.send(`${name}, you have been unsubscribed.`) } else { res.send('This user is not currently subscribed or does not exist.') } diff --git a/functions/src/update-user-metrics.ts b/functions/src/update-user-metrics.ts index f59d4a34..d1d13727 100644 --- a/functions/src/update-user-metrics.ts +++ b/functions/src/update-user-metrics.ts @@ -52,7 +52,8 @@ const computeInvestmentValue = async ( if (!contract || contract.isResolved) return 0 if (bet.sale || bet.isSold) return 0 - return calculatePayout(contract, bet, 'MKT') + const payout = calculatePayout(contract, bet, 'MKT') + return payout - (bet.loanAmount ?? 0) }) } diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 9f3777e8..0e87538a 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -3,6 +3,9 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' +export const isProd = + admin.instanceId().app.options.projectId === 'mantic-markets' + export const getValue = async (collection: string, doc: string) => { const snap = await admin.firestore().collection(collection).doc(doc).get() diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index dcc29465..e36962a2 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -1,15 +1,20 @@ import clsx from 'clsx' +import _ from 'lodash' import { useUser } from '../hooks/use-user' import { formatMoney } from '../../common/util/format' -import { AddFundsButton } from './add-funds-button' import { Col } from './layout/col' import { Row } from './layout/row' +import { useUserContractBets } from '../hooks/use-user-bets' +import { MAX_LOAN_PER_CONTRACT } from '../../common/bet' +import { InfoTooltip } from './info-tooltip' +import { Spacer } from './layout/spacer' export function AmountInput(props: { amount: number | undefined onChange: (newAmount: number | undefined) => void error: string | undefined setError: (error: string | undefined) => void + contractId: string | undefined minimumAmount?: number disabled?: boolean className?: string @@ -22,6 +27,7 @@ export function AmountInput(props: { onChange, error, setError, + contractId, disabled, className, inputClassName, @@ -31,10 +37,24 @@ export function AmountInput(props: { const user = useUser() + const userBets = useUserContractBets(user?.id, contractId) ?? [] + const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) + const prevLoanAmount = _.sumBy(openUserBets, (bet) => bet.loanAmount ?? 0) + + const loanAmount = Math.min( + amount ?? 0, + MAX_LOAN_PER_CONTRACT - prevLoanAmount + ) + const onAmountChange = (str: string) => { + if (str.includes('-')) { + onChange(undefined) + return + } const amount = parseInt(str.replace(/[^\d]/, '')) if (str && isNaN(amount)) return + if (amount >= 10 ** 9) return onChange(str ? amount : undefined) @@ -47,7 +67,8 @@ export function AmountInput(props: { } } - const remainingBalance = Math.max(0, (user?.balance ?? 0) - (amount ?? 0)) + const amountNetLoan = (amount ?? 0) - loanAmount + const remainingBalance = Math.max(0, (user?.balance ?? 0) - amountNetLoan) return ( @@ -68,19 +89,34 @@ export function AmountInput(props: { onChange={(e) => onAmountChange(e.target.value)} /> + + + {error && ( -
+
{error}
)} {user && ( - -
- Remaining balance -
- -
{formatMoney(Math.floor(remainingBalance))}
- {user.balance !== 1000 && } + + {contractId && ( + + + Amount loaned{' '} + + + {formatMoney(loanAmount)}{' '} + + )} + + Remaining balance{' '} + + {formatMoney(Math.floor(remainingBalance))} + )} diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 33a0593f..26939b35 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -30,8 +30,9 @@ export function AnswerBetPanel(props: { answer: Answer contract: Contract closePanel: () => void + className?: string }) { - const { answer, contract, closePanel } = props + const { answer, contract, closePanel, className } = props const { id: answerId } = answer const user = useUser() @@ -97,7 +98,7 @@ export function AnswerBetPanel(props: { const currentReturnPercent = (currentReturn * 100).toFixed() + '%' return ( - +
Buy this answer
@@ -114,40 +115,44 @@ export function AnswerBetPanel(props: { setError={setError} disabled={isSubmitting} inputRef={inputRef} + contractId={contract.id} /> + + +
Probability
+ +
{formatPercent(initialProb)}
+
+
{formatPercent(resultProb)}
+
+
- - -
Implied probability
- -
{formatPercent(initialProb)}
-
-
{formatPercent(resultProb)}
-
- - - - - Payout if chosen - - -
- {formatMoney(currentPayout)} -   (+{currentReturnPercent}) -
+ + +
Payout if chosen
+ +
+ + + {formatMoney(currentPayout)} + + (+{currentReturnPercent}) + +
+ {user ? (
) } diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 934310e4..2dbadd9e 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -86,7 +86,7 @@ export function CreateAnswerPanel(props: { contract: Contract }) {
@@ -101,34 +101,42 @@ export function CreateAnswerPanel(props: { contract: Contract }) { setError={setAmountError} minimumAmount={1} disabled={isSubmitting} + contractId={contract.id} /> - -
Implied probability
- -
{formatPercent(0)}
-
-
{formatPercent(resultProb)}
+ + +
Probability
+ +
{formatPercent(0)}
+
+
{formatPercent(resultProb)}
+
- - Payout if chosen - + + + +
Payout if chosen
+ +
+ + + {formatMoney(currentPayout)} + + (+{currentReturnPercent}) +
-
- {formatMoney(currentPayout)} -   (+{currentReturnPercent}) -
)} {user ? (
*/} - -
- Payout if -
-
- {formatMoney(yesWinnings)} -
- - -
- Payout if -
-
- {formatMoney(noWinnings)} -
- + {isBinary && ( + <> + +
+ Payout if +
+
+ {formatMoney(yesWinnings)} +
+ + +
+ Payout if +
+
+ {formatMoney(noWinnings)} +
+ + + )}
{isBinary ? ( @@ -421,9 +423,10 @@ export function ContractBetsTable(props: { - {isResolved ? <>Payout : <>Sale price} Outcome Amount + {isResolved ? <>Payout : <>Sale price} + {!isResolved && Payout if chosen} Probability Shares Date @@ -455,6 +458,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { shares, isSold, isAnte, + loanAmount, } = bet const { isResolved, closeTime } = contract @@ -462,7 +466,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { const saleAmount = saleBet?.sale?.amount - const saleDisplay = bet.isAnte ? ( + const saleDisplay = isAnte ? ( 'ANTE' ) : saleAmount !== undefined ? ( <>{formatMoney(saleAmount)} (sold) @@ -474,6 +478,11 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { ) ) + const payoutIfChosenDisplay = + bet.outcome === '0' && bet.isAnte + ? 'N/A' + : formatMoney(calculatePayout(contract, bet, bet.outcome)) + return ( @@ -481,11 +490,15 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { )} - {saleDisplay} - {formatMoney(amount)} + + {formatMoney(amount)} + {loanAmount ? ` (${formatMoney(loanAmount ?? 0)} loan)` : ''} + + {saleDisplay} + {!isResolved && {payoutIfChosenDisplay}} {formatPercent(probBefore)} → {formatPercent(probAfter)} @@ -502,17 +515,19 @@ function SellButton(props: { contract: Contract; bet: Bet }) { }, []) const { contract, bet } = props + const { outcome, shares, loanAmount } = bet + const [isSubmitting, setIsSubmitting] = useState(false) const initialProb = getOutcomeProbability( contract.totalShares, - bet.outcome === 'NO' ? 'YES' : bet.outcome + outcome === 'NO' ? 'YES' : outcome ) const outcomeProb = getProbabilityAfterSale( contract.totalShares, - bet.outcome, - bet.shares + outcome, + shares ) const saleAmount = calculateSaleAmount(contract, bet) @@ -524,7 +539,7 @@ function SellButton(props: { contract: Contract; bet: Bet }) { className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'), label: 'Sell', }} - submitBtn={{ className: 'btn-primary' }} + submitBtn={{ className: 'btn-primary', label: 'Sell' }} onSubmit={async () => { setIsSubmitting(true) await sellBet({ contractId: contract.id, betId: bet.id }) @@ -532,15 +547,18 @@ function SellButton(props: { contract: Contract; bet: Bet }) { }} >
- Sell -
-
- Do you want to sell {formatWithCommas(bet.shares)} shares of{' '} - for {formatMoney(saleAmount)}? + Sell {formatWithCommas(shares)} shares of{' '} + for {formatMoney(saleAmount)}?
+ {!!loanAmount && ( +
+ You will also pay back {formatMoney(loanAmount)} of your loan, for a + net of {formatMoney(saleAmount - loanAmount)}. +
+ )} -
- Implied probability: {formatPercent(initialProb)} →{' '} +
+ Market probability: {formatPercent(initialProb)} →{' '} {formatPercent(outcomeProb)}
diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index 7491efbe..36bfd26b 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -19,6 +19,7 @@ import { fromNow } from '../lib/util/time' import { Avatar } from './avatar' import { Spacer } from './layout/spacer' import { useState } from 'react' +import { TweetButton } from './tweet-button' export function ContractCard(props: { contract: Contract @@ -149,7 +150,8 @@ function AbbrContractDetails(props: { ) : showCloseTime ? ( - Closes {fromNow(closeTime || 0)} + {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} + {fromNow(closeTime || 0)} ) : ( @@ -170,6 +172,8 @@ export function ContractDetails(props: { const { closeTime, creatorName, creatorUsername } = contract const { truePool, createdDate, resolvedDate } = contractMetrics(contract) + const tweetText = getTweetText(contract, !!isCreator) + return ( @@ -222,6 +226,8 @@ export function ContractDetails(props: {
{formatMoney(truePool)} pool
+ +
) @@ -307,9 +313,28 @@ function EditableCloseDate(props: { className="btn btn-xs btn-ghost" onClick={() => setIsEditingCloseTime(true)} > - Edit + Edit ))} ) } + +const getTweetText = (contract: Contract, isCreator: boolean) => { + const { question, creatorName, resolution, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + + const tweetQuestion = isCreator + ? question + : `${question} Asked by ${creatorName}.` + const tweetDescription = resolution + ? `Resolved ${resolution}!` + : isBinary + ? `Currently ${getBinaryProbPercent( + contract + )} chance, place your bets here:` + : `Submit your own answer:` + const url = `https://manifold.markets${contractPath(contract)}` + + return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` +} diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 318a07a5..5a3be62f 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -46,7 +46,7 @@ import { useAdmin } from '../hooks/use-admin' function FeedComment(props: { activityItem: any moreHref: string - feedType: 'activity' | 'market' + feedType: FeedType }) { const { activityItem, moreHref, feedType } = props const { person, text, amount, outcome, createdTime } = activityItem @@ -65,7 +65,8 @@ function FeedComment(props: { username={person.username} name={person.name} />{' '} - {bought} {money} of {' '} + {bought} {money} +

@@ -90,8 +91,8 @@ function Timestamp(props: { time: number }) { ) } -function FeedBet(props: { activityItem: any }) { - const { activityItem } = props +function FeedBet(props: { activityItem: any; feedType: FeedType }) { + const { activityItem, feedType } = props const { id, contractId, amount, outcome, createdTime } = activityItem const user = useUser() const isSelf = user?.id == activityItem.userId @@ -122,8 +123,9 @@ function FeedBet(props: { activityItem: any }) {
- {isSelf ? 'You' : 'A trader'} {bought} {money} of{' '} - + {isSelf ? 'You' : 'A trader'} {bought} {money} + + {canComment && ( // Allow user to comment in an textarea if they are the creator
@@ -134,7 +136,7 @@ function FeedBet(props: { activityItem: any }) { placeholder="Add a comment..." rows={3} onKeyDown={(e) => { - if (e.key === 'Enter' && e.ctrlKey) { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { submitComment() } }} @@ -179,7 +181,7 @@ function EditContract(props: { e.target.setSelectionRange(text.length, text.length) } onKeyDown={(e) => { - if (e.key === 'Enter' && e.ctrlKey) { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { onSave(text) } }} @@ -305,15 +307,13 @@ function FeedQuestion(props: { contract: Contract }) { const { truePool } = contractMetrics(contract) const isBinary = outcomeType === 'BINARY' - // Currently hidden on mobile; ideally we'd fit this in somewhere. const closeMessage = contract.isResolved || !contract.closeTime ? null : ( - - {formatMoney(truePool)} pool + <> {contract.closeTime > Date.now() ? 'Closes' : 'Closed'} - + ) return ( @@ -330,7 +330,11 @@ function FeedQuestion(props: { contract: Contract }) { username={creatorUsername} />{' '} asked - {closeMessage} + {/* Currently hidden on mobile; ideally we'd fit this in somewhere. */} + + {formatMoney(truePool)} pool + {closeMessage} +
+ +
+
+ {' '} + submitted answer {' '} + +
+
+ + ) +} + function OutcomeIcon(props: { outcome?: string }) { const { outcome } = props switch (outcome) { @@ -538,8 +565,12 @@ function groupBets( return items as ActivityItem[] } -function BetGroupSpan(props: { bets: Bet[]; outcome: string }) { - const { bets, outcome } = props +function BetGroupSpan(props: { + bets: Bet[] + outcome: string + feedType: FeedType +}) { + const { bets, outcome, feedType } = props const numberTraders = _.uniqBy(bets, (b) => b.userId).length @@ -554,14 +585,14 @@ function BetGroupSpan(props: { bets: Bet[]; outcome: string }) { {buyTotal > 0 && <>bought {formatMoney(buyTotal)} } {sellTotal > 0 && <>sold {formatMoney(sellTotal)} } - of + {' '} ) } // TODO: Make this expandable to show all grouped bets? -function FeedBetGroup(props: { activityItem: any }) { - const { activityItem } = props +function FeedBetGroup(props: { activityItem: any; feedType: FeedType }) { + const { activityItem, feedType } = props const bets: Bet[] = activityItem.bets const betGroups = _.groupBy(bets, (bet) => bet.outcome) @@ -583,7 +614,11 @@ function FeedBetGroup(props: { activityItem: any }) {
{outcomes.map((outcome, index) => ( - + {index !== outcomes.length - 1 &&
}
))} @@ -621,6 +656,18 @@ function FeedExpand(props: { setExpanded: (expanded: boolean) => void }) { ) } +// On 'multi' feeds, the outcome is redundant, so we hide it +function MaybeOutcomeLabel(props: { outcome: string; feedType: FeedType }) { + const { outcome, feedType } = props + return feedType === 'multi' ? null : ( + + {' '} + of + {/* TODO: Link to the correct e.g. #23 */} + + ) +} + // Missing feed items: // - Bet sold? type ActivityItem = { @@ -635,15 +682,23 @@ type ActivityItem = { | 'expand' } +type FeedType = + // Main homepage/fold feed, + | 'activity' + // Comments feed on a market + | 'market' + // Grouped for a multi-category outcome + | 'multi' + export function ContractFeed(props: { contract: Contract bets: Bet[] comments: Comment[] - // Feed types: 'activity' = Activity feed, 'market' = Comments feed on a market - feedType: 'activity' | 'market' + feedType: FeedType + outcome?: string // Which multi-category outcome to filter betRowClassName?: string }) { - const { contract, feedType, betRowClassName } = props + const { contract, feedType, outcome, betRowClassName } = props const { id, outcomeType } = contract const isBinary = outcomeType === 'BINARY' @@ -655,6 +710,10 @@ export function ContractFeed(props: { ? bets.filter((bet) => !bet.isAnte) : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) + if (feedType === 'multi') { + bets = bets.filter((bet) => bet.outcome === outcome) + } + const comments = useComments(id) ?? props.comments const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS @@ -669,6 +728,10 @@ export function ContractFeed(props: { if (contract.resolution) { allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) } + if (feedType === 'multi') { + // Hack to add some more padding above the 'multi' feedType, by adding a null item + allItems.unshift({ type: '', id: -1 }) + } // If there are more than 5 items, only show the first, an expand item, and last 3 let items = allItems @@ -682,45 +745,69 @@ export function ContractFeed(props: { return (
-
    +
    {items.map((activityItem, activityItemIdx) => ( -
  • -
    - {activityItemIdx !== items.length - 1 ? ( -
  • +
    ))} -
+
+ {isBinary && tradingAllowed(contract) && ( + + )} +
+ ) +} + +export function ContractSummaryFeed(props: { + contract: Contract + betRowClassName?: string +}) { + const { contract, betRowClassName } = props + const { outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + + return ( +
+
+
+
+ +
+
+
{isBinary && tradingAllowed(contract) && ( )} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index d8a81c35..fcdf3372 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -1,9 +1,7 @@ import { Contract, deleteContract, - contractPath, tradingAllowed, - getBinaryProbPercent, } from '../lib/firebase/contracts' import { Col } from './layout/col' import { Spacer } from './layout/spacer' @@ -15,7 +13,6 @@ import { Linkify } from './linkify' import clsx from 'clsx' import { ContractDetails, ResolutionOrChance } from './contract-card' import { ContractFeed } from './contract-feed' -import { TweetButton } from './tweet-button' import { Bet } from '../../common/bet' import { Comment } from '../../common/comment' import { RevealableTagsInput, TagsInput } from './tags-input' @@ -38,8 +35,6 @@ export const ContractOverview = (props: { const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' - const tweetText = getTweetText(contract, isCreator) - return ( @@ -92,11 +87,9 @@ export const ContractOverview = (props: { ) : ( )} - - {folds.length === 0 ? ( ) : ( @@ -136,22 +129,3 @@ export const ContractOverview = (props: { ) } - -const getTweetText = (contract: Contract, isCreator: boolean) => { - const { question, creatorName, resolution, outcomeType } = contract - const isBinary = outcomeType === 'BINARY' - - const tweetQuestion = isCreator - ? question - : `${question} Asked by ${creatorName}.` - const tweetDescription = resolution - ? `Resolved ${resolution}!` - : isBinary - ? `Currently ${getBinaryProbPercent( - contract - )} chance, place your bets here:` - : `Submit your own answer:` - const url = `https://manifold.markets${contractPath(contract)}` - - return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` -} diff --git a/web/components/contract-prob-graph.tsx b/web/components/contract-prob-graph.tsx index 23daabfb..d764179c 100644 --- a/web/components/contract-prob-graph.tsx +++ b/web/components/contract-prob-graph.tsx @@ -83,7 +83,7 @@ export function ContractProbGraph(props: { contract: Contract; bets: Bet[] }) { format: (time) => formatTime(+time, lessThanAWeek), }} colors={{ datum: 'color' }} - pointSize={10} + pointSize={bets.length > 100 ? 0 : 10} pointBorderWidth={1} pointBorderColor="#fff" enableSlices="x" diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index f8f41527..d64df8d8 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -205,16 +205,19 @@ export function SearchableGrid(props: { }) { const { contracts, query, setQuery, sort, setSort, byOneCreator } = props + const queryWords = query.toLowerCase().split(' ') function check(corpus: String) { - return corpus.toLowerCase().includes(query.toLowerCase()) + return queryWords.every((word) => corpus.toLowerCase().includes(word)) } + let matches = contracts.filter( (c) => check(c.question) || check(c.description) || check(c.creatorName) || check(c.creatorUsername) || - check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) + check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) || + check((c.answers ?? []).map((answer) => answer.text).join(' ')) ) if (sort === 'newest' || sort === 'all') { @@ -226,11 +229,13 @@ export function SearchableGrid(props: { ) } else if (sort === 'oldest') { matches.sort((a, b) => a.createdTime - b.createdTime) - } else if (sort === 'close-date') { + } else if (sort === 'close-date' || sort === 'closed') { matches = _.sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) matches = _.sortBy(matches, (contract) => contract.closeTime) - // Hide contracts that have already closed - matches = matches.filter(({ closeTime }) => (closeTime || 0) > Date.now()) + const hideClosed = sort === 'closed' + matches = matches.filter( + ({ closeTime }) => closeTime && closeTime > Date.now() !== hideClosed + ) } else if (sort === 'most-traded') { matches.sort( (a, b) => contractMetrics(b).truePool - contractMetrics(a).truePool @@ -269,6 +274,7 @@ export function SearchableGrid(props: { + @@ -286,7 +292,7 @@ export function SearchableGrid(props: { ) : ( )}
diff --git a/web/components/fast-fold-following.tsx b/web/components/fast-fold-following.tsx index d4d350ee..db0bd105 100644 --- a/web/components/fast-fold-following.tsx +++ b/web/components/fast-fold-following.tsx @@ -101,7 +101,7 @@ export const FastFoldFollowing = (props: { ]} /> - + ) } diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index 04b37406..7cc00e6a 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -34,10 +34,6 @@ function getNavigationOptions( name: 'Home', href: user ? '/home' : '/', }, - { - name: `Your profile`, - href: `/${user?.username}`, - }, ...(mobile ? [ { @@ -50,10 +46,18 @@ function getNavigationOptions( }, ] : []), + { + name: `Your profile`, + href: `/${user?.username}`, + }, { name: 'Your trades', href: '/trades', }, + { + name: 'Add funds', + href: '/add-funds', + }, { name: 'Leaderboards', href: '/leaderboards', diff --git a/web/hooks/use-admin.ts b/web/hooks/use-admin.ts index 733f73f6..bbeaf59c 100644 --- a/web/hooks/use-admin.ts +++ b/web/hooks/use-admin.ts @@ -1,12 +1,8 @@ -import { useUser } from './use-user' +import { isAdmin } from '../../common/access' +import { usePrivateUser, useUser } from './use-user' export const useAdmin = () => { const user = useUser() - const adminIds = [ - 'igi2zGXsfxYPgB0DJTXVJVmwCOr2', // Austin - '5LZ4LgYuySdL1huCWe7bti02ghx2', // James - 'tlmGNz9kjXc2EteizMORes4qvWl2', // Stephen - 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2', // Manifold - ] - return adminIds.includes(user?.id || '') + const privateUser = usePrivateUser(user?.id) + return isAdmin(privateUser?.email || '') } diff --git a/web/hooks/use-bets.ts b/web/hooks/use-bets.ts index 5ea66e1c..4c530f5b 100644 --- a/web/hooks/use-bets.ts +++ b/web/hooks/use-bets.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Contract } from '../../common/contract' import { Bet, + getRecentBets, listenForBets, listenForRecentBets, withoutAnteBets, @@ -36,3 +37,11 @@ export const useRecentBets = () => { useEffect(() => listenForRecentBets(setRecentBets), []) return recentBets } + +export const useGetRecentBets = () => { + const [recentBets, setRecentBets] = useState() + useEffect(() => { + getRecentBets().then(setRecentBets) + }, []) + return recentBets +} diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 41137f33..af36cd82 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -2,8 +2,10 @@ import _ from 'lodash' import { useEffect, useState } from 'react' import { Contract, + listenForActiveContracts, listenForContracts, listenForHotContracts, + listenForInactiveContracts, } from '../lib/firebase/contracts' import { listenForTaggedContracts } from '../lib/firebase/folds' @@ -17,6 +19,26 @@ export const useContracts = () => { return contracts } +export const useActiveContracts = () => { + const [contracts, setContracts] = useState() + + useEffect(() => { + return listenForActiveContracts(setContracts) + }, []) + + return contracts +} + +export const useInactiveContracts = () => { + const [contracts, setContracts] = useState() + + useEffect(() => { + return listenForInactiveContracts(setContracts) + }, []) + + return contracts +} + export const useUpdatedContracts = (initialContracts: Contract[]) => { const [contracts, setContracts] = useState(initialContracts) diff --git a/web/hooks/use-active-contracts.ts b/web/hooks/use-find-active-contracts.ts similarity index 62% rename from web/hooks/use-active-contracts.ts rename to web/hooks/use-find-active-contracts.ts index 0bd099e2..f8aa5627 100644 --- a/web/hooks/use-active-contracts.ts +++ b/web/hooks/use-find-active-contracts.ts @@ -1,24 +1,22 @@ import _ from 'lodash' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import { Fold } from '../../common/fold' import { User } from '../../common/user' import { filterDefined } from '../../common/util/array' import { Bet, getRecentBets } from '../lib/firebase/bets' import { Comment, getRecentComments } from '../lib/firebase/comments' -import { Contract, listAllContracts } from '../lib/firebase/contracts' +import { Contract, getActiveContracts } from '../lib/firebase/contracts' import { listAllFolds } from '../lib/firebase/folds' import { findActiveContracts } from '../pages/activity' -import { useRecentBets } from './use-bets' -import { useRecentComments } from './use-comments' -import { useContracts } from './use-contracts' +import { useInactiveContracts } from './use-contracts' import { useFollowedFolds } from './use-fold' import { useUserBetContracts } from './use-user-bets' // used in static props export const getAllContractInfo = async () => { let [contracts, folds] = await Promise.all([ - listAllContracts().catch((_) => []), + getActiveContracts().catch((_) => []), listAllFolds().catch(() => []), ]) @@ -30,25 +28,15 @@ export const getAllContractInfo = async () => { return { contracts, recentBets, recentComments, folds } } -export const useActiveContracts = ( - props: { - contracts: Contract[] - folds: Fold[] - recentBets: Bet[] - recentComments: Comment[] - }, - user: User | undefined | null +export const useFilterYourContracts = ( + user: User | undefined | null, + folds: Fold[], + contracts: Contract[] ) => { - const contracts = useContracts() ?? props.contracts - const recentBets = useRecentBets() ?? props.recentBets - const recentComments = useRecentComments() ?? props.recentComments - const followedFoldIds = useFollowedFolds(user) const followedFolds = filterDefined( - (followedFoldIds ?? []).map((id) => - props.folds.find((fold) => fold.id === id) - ) + (followedFoldIds ?? []).map((id) => folds.find((fold) => fold.id === id)) ) // Save the initial followed fold slugs. @@ -67,23 +55,35 @@ export const useActiveContracts = ( : undefined // Show no contracts before your info is loaded. - let feedContracts: Contract[] = [] + let yourContracts: Contract[] = [] if (yourBetContracts && followedFoldIds) { // Show all contracts if no folds are followed. - if (followedFoldIds.length === 0) feedContracts = contracts + if (followedFoldIds.length === 0) yourContracts = contracts else - feedContracts = contracts.filter( + yourContracts = contracts.filter( (contract) => contract.lowercaseTags.some((tag) => tagSet.has(tag)) || yourBetContracts.has(contract.id) ) } + return { + yourContracts, + initialFollowedFoldSlugs, + } +} + +export const useFindActiveContracts = (props: { + contracts: Contract[] + recentBets: Bet[] + recentComments: Comment[] +}) => { + const { contracts, recentBets, recentComments } = props + const activeContracts = findActiveContracts( - feedContracts, + contracts, recentComments, - recentBets, - 365 + recentBets ) const betsByContract = _.groupBy(recentBets, (bet) => bet.contractId) @@ -105,6 +105,24 @@ export const useActiveContracts = ( activeContracts, activeBets, activeComments, - initialFollowedFoldSlugs, } } + +export const useExploreContracts = (maxContracts = 75) => { + const inactiveContracts = useInactiveContracts() + + const contractsDict = _.fromPairs( + (inactiveContracts ?? []).map((c) => [c.id, c]) + ) + + // Preserve random ordering once inactiveContracts loaded. + const exploreContractIds = useMemo( + () => _.shuffle(Object.keys(contractsDict)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [!!inactiveContracts] + ).slice(0, maxContracts) + + if (!inactiveContracts) return undefined + + return filterDefined(exploreContractIds.map((id) => contractsDict[id])) +} diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index c7e7336d..3eca5f3c 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,4 +1,6 @@ +import _ from 'lodash' import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' export type Sort = | 'creator' @@ -8,6 +10,7 @@ export type Sort = | 'most-traded' | '24-hour-vol' | 'close-date' + | 'closed' | 'resolved' | 'all' @@ -24,19 +27,34 @@ export function useQueryAndSortParams(options?: { defaultSort: Sort }) { router.push(router, undefined, { shallow: true }) } - const setQuery = (query: string | undefined) => { - if (query) { - router.query.q = query - } else { - delete router.query.q - } + const [queryState, setQueryState] = useState(query) - router.push(router, undefined, { shallow: true }) + useEffect(() => { + setQueryState(query) + }, [query]) + + // Debounce router query update. + const pushQuery = useMemo( + () => + _.debounce((query: string | undefined) => { + if (query) { + router.query.q = query + } else { + delete router.query.q + } + router.push(router, undefined, { shallow: true }) + }, 500), + [router] + ) + + const setQuery = (query: string | undefined) => { + setQueryState(query) + pushQuery(query) } return { sort: sort ?? options?.defaultSort ?? '24-hour-vol', - query: query ?? '', + query: queryState ?? '', setSort, setQuery, } diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index f4d421fb..0f1ceecb 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -1,6 +1,10 @@ import _ from 'lodash' import { useEffect, useState } from 'react' -import { Bet, listenForUserBets } from '../lib/firebase/bets' +import { + Bet, + listenForUserBets, + listenForUserContractBets, +} from '../lib/firebase/bets' export const useUserBets = (userId: string | undefined) => { const [bets, setBets] = useState(undefined) @@ -12,6 +16,20 @@ export const useUserBets = (userId: string | undefined) => { return bets } +export const useUserContractBets = ( + userId: string | undefined, + contractId: string | undefined +) => { + const [bets, setBets] = useState(undefined) + + useEffect(() => { + if (userId && contractId) + return listenForUserContractBets(userId, contractId, setBets) + }, [userId, contractId]) + + return bets +} + export const useUserBetContracts = (userId: string | undefined) => { const [contractIds, setContractIds] = useState() diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 8c78540d..f03b293b 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -74,6 +74,21 @@ export function listenForUserBets( }) } +export function listenForUserContractBets( + userId: string, + contractId: string, + setBets: (bets: Bet[]) => void +) { + const betsQuery = query( + collection(db, 'contracts', contractId, 'bets'), + where('userId', '==', userId) + ) + return listenForValues(betsQuery, (bets) => { + bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) + setBets(bets) + }) +} + export function withoutAnteBets(contract: Contract, bets?: Bet[]) { const { createdTime } = contract diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 88470a89..eb1b65e1 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -115,6 +115,41 @@ export function listenForContracts( return listenForValues(q, setContracts) } +const activeContractsQuery = query( + contractCollection, + where('isResolved', '==', false), + where('visibility', '==', 'public'), + where('volume24Hours', '>', 0) +) + +export function getActiveContracts() { + return getValues(activeContractsQuery) +} + +export function listenForActiveContracts( + setContracts: (contracts: Contract[]) => void +) { + return listenForValues(activeContractsQuery, setContracts) +} + +const inactiveContractsQuery = query( + contractCollection, + where('isResolved', '==', false), + where('closeTime', '>', Date.now()), + where('visibility', '==', 'public'), + where('volume24Hours', '==', 0) +) + +export function getInactiveContracts() { + return getValues(inactiveContractsQuery) +} + +export function listenForInactiveContracts( + setContracts: (contracts: Contract[]) => void +) { + return listenForValues(inactiveContractsQuery, setContracts) +} + export function listenForContract( contractId: string, setContract: (contract: Contract | null) => void diff --git a/web/lib/firebase/folds.ts b/web/lib/firebase/folds.ts index 7ca4e912..ffe2bdef 100644 --- a/web/lib/firebase/folds.ts +++ b/web/lib/firebase/folds.ts @@ -54,13 +54,12 @@ export async function getFoldBySlug(slug: string) { } function contractsByTagsQuery(tags: string[]) { + // TODO: if tags.length > 10, execute multiple parallel queries + const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10) + return query( contractCollection, - where( - 'lowercaseTags', - 'array-contains-any', - tags.map((tag) => tag.toLowerCase()) - ) + where('lowercaseTags', 'array-contains-any', lowercaseTags) ) } @@ -74,7 +73,6 @@ export async function getFoldContracts(fold: Fold) { } = fold const [tagsContracts, includedContracts] = await Promise.all([ - // TODO: if tags.length > 10, execute multiple parallel queries tags.length > 0 ? getValues(contractsByTagsQuery(tags)) : [], // TODO: if contractIds.length > 10, execute multiple parallel queries @@ -163,9 +161,10 @@ export function listenForFollow( export async function getFoldsByTags(tags: string[]) { if (tags.length === 0) return [] - const lowercaseTags = tags.map((tag) => tag.toLowerCase()) + // TODO: split into multiple queries if tags.length > 10. + const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10) + const folds = await getValues( - // TODO: split into multiple queries if tags.length > 10. query( foldCollection, where('lowercaseTags', 'array-contains-any', lowercaseTags) @@ -179,13 +178,13 @@ export function listenForFoldsWithTags( tags: string[], setFolds: (folds: Fold[]) => void ) { - const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - const q = - // TODO: split into multiple queries if tags.length > 10. - query( - foldCollection, - where('lowercaseTags', 'array-contains-any', lowercaseTags) - ) + // TODO: split into multiple queries if tags.length > 10. + const lowercaseTags = tags.map((tag) => tag.toLowerCase()).slice(0, 10) + + const q = query( + foldCollection, + where('lowercaseTags', 'array-contains-any', lowercaseTags) + ) return listenForValues(q, (folds) => { const sorted = _.sortBy(folds, (fold) => -1 * fold.followCount) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 64e808de..145d67c5 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,28 +1,41 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp, getApps, getApp } from 'firebase/app' +// Used to decide which Stripe instance to point to export const isProd = process.env.NEXT_PUBLIC_FIREBASE_ENV !== 'DEV' -const firebaseConfig = isProd - ? { - apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', - authDomain: 'mantic-markets.firebaseapp.com', - projectId: 'mantic-markets', - storageBucket: 'mantic-markets.appspot.com', - messagingSenderId: '128925704902', - appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', - measurementId: 'G-SSFK1Q138D', - } - : { - apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', - authDomain: 'dev-mantic-markets.firebaseapp.com', - projectId: 'dev-mantic-markets', - storageBucket: 'dev-mantic-markets.appspot.com', - messagingSenderId: '134303100058', - appId: '1:134303100058:web:27f9ea8b83347251f80323', - measurementId: 'G-YJC9E37P37', - } - +const FIREBASE_CONFIGS = { + PROD: { + apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', + authDomain: 'mantic-markets.firebaseapp.com', + projectId: 'mantic-markets', + storageBucket: 'mantic-markets.appspot.com', + messagingSenderId: '128925704902', + appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', + measurementId: 'G-SSFK1Q138D', + }, + DEV: { + apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', + authDomain: 'dev-mantic-markets.firebaseapp.com', + projectId: 'dev-mantic-markets', + storageBucket: 'dev-mantic-markets.appspot.com', + messagingSenderId: '134303100058', + appId: '1:134303100058:web:27f9ea8b83347251f80323', + measurementId: 'G-YJC9E37P37', + }, + THEOREMONE: { + apiKey: 'AIzaSyBSXL6Ys7InNHnCKSy-_E_luhh4Fkj4Z6M', + authDomain: 'theoremone-manifold.firebaseapp.com', + projectId: 'theoremone-manifold', + storageBucket: 'theoremone-manifold.appspot.com', + messagingSenderId: '698012149198', + appId: '1:698012149198:web:b342af75662831aa84b79f', + measurementId: 'G-Y3EZ1WNT6E', + }, +} +const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD' +// @ts-ignore +const firebaseConfig = FIREBASE_CONFIGS[ENV] // Initialize Firebase export const app = getApps().length ? getApp() : initializeApp(firebaseConfig) diff --git a/web/lib/firebase/utils.ts b/web/lib/firebase/utils.ts index b6174d48..b937743b 100644 --- a/web/lib/firebase/utils.ts +++ b/web/lib/firebase/utils.ts @@ -1,6 +1,5 @@ import { db } from './init' import { - doc, getDoc, getDocs, onSnapshot, @@ -22,7 +21,9 @@ export function listenForValue( docRef: DocumentReference, setValue: (value: T | null) => void ) { - return onSnapshot(docRef, (snapshot) => { + // Exclude cached snapshots so we only trigger on fresh data. + // includeMetadataChanges ensures listener is called even when server data is the same as cached data. + return onSnapshot(docRef, { includeMetadataChanges: true }, (snapshot) => { if (snapshot.metadata.fromCache) return const value = snapshot.exists() ? (snapshot.data() as T) : null @@ -34,7 +35,9 @@ export function listenForValues( query: Query, setValues: (values: T[]) => void ) { - return onSnapshot(query, (snapshot) => { + // Exclude cached snapshots so we only trigger on fresh data. + // includeMetadataChanges ensures listener is called even when server data is the same as cached data. + return onSnapshot(query, { includeMetadataChanges: true }, (snapshot) => { if (snapshot.metadata.fromCache) return const values = snapshot.docs.map((doc) => doc.data() as T) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 72cacd3f..1020816d 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -145,7 +145,7 @@ export default function ContractPage(props: { {allowTrade && ( - + )} {allowResolve && ( @@ -177,12 +177,8 @@ function BetsSection(props: { return (
- {isBinary && ( - <> - <MyBetsSummary className="px-2" contract={contract} bets={userBets} /> - <Spacer h={6} /> - </> - )} + <MyBetsSummary className="px-2" contract={contract} bets={userBets} /> + <Spacer h={6} /> <ContractBetsTable contract={contract} bets={userBets} /> <Spacer h={12} /> </div> diff --git a/web/pages/about.tsx b/web/pages/about.tsx index c818be8e..85e3eaee 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -139,6 +139,15 @@ function Contents() { bettors that are correct more often will gain influence, leading to better-calibrated forecasts over time. </p> + <p> + Since our launch, we've seen hundreds of users trade each day, on over a + thousand different markets! You can track the popularity of our platform + at{' '} + <a href="http://manifold.markets/analytics"> + http://manifold.markets/analytics + </a> + . + </p> <h3 id="how-are-markets-resolved-">How are markets resolved?</h3> <p> The creator of the prediction market decides the outcome and earns{' '} diff --git a/web/pages/activity.tsx b/web/pages/activity.tsx index e7683298..bab58328 100644 --- a/web/pages/activity.tsx +++ b/web/pages/activity.tsx @@ -1,14 +1,12 @@ import _ from 'lodash' -import { ContractFeed } from '../components/contract-feed' +import { ContractFeed, ContractSummaryFeed } from '../components/contract-feed' import { Page } from '../components/page' import { Contract } from '../lib/firebase/contracts' import { Comment } from '../lib/firebase/comments' import { Col } from '../components/layout/col' import { Bet } from '../../common/bet' -import { filterDefined } from '../../common/util/array' const MAX_ACTIVE_CONTRACTS = 75 -const MAX_HOT_MARKETS = 10 // This does NOT include comment times, since those aren't part of the contract atm. // TODO: Maybe store last activity time directly in the contract? @@ -25,12 +23,11 @@ function lastActivityTime(contract: Contract) { // - Comment on a market // - New market created // - Market resolved -// - Markets with most betting in last 24 hours +// - Bet on market export function findActiveContracts( allContracts: Contract[], recentComments: Comment[], - recentBets: Bet[], - daysAgo = 3 + recentBets: Bet[] ) { const idToActivityTime = new Map<string, number>() function record(contractId: string, time: number) { @@ -39,52 +36,38 @@ export function findActiveContracts( idToActivityTime.set(contractId, Math.max(oldTime ?? 0, time)) } - let contracts: Contract[] = [] + const contractsById = new Map(allContracts.map((c) => [c.id, c])) - // Find contracts with activity in the last 3 days - const DAY_IN_MS = 24 * 60 * 60 * 1000 - for (const contract of allContracts || []) { - if (lastActivityTime(contract) > Date.now() - daysAgo * DAY_IN_MS) { - contracts.push(contract) - record(contract.id, lastActivityTime(contract)) - } + // Record contract activity. + for (const contract of allContracts) { + record(contract.id, lastActivityTime(contract)) } // Add every contract that had a recent comment, too - const contractsById = new Map(allContracts.map((c) => [c.id, c])) for (const comment of recentComments) { const contract = contractsById.get(comment.contractId) - if (contract) { - contracts.push(contract) - record(contract.id, comment.createdTime) - } + if (contract) record(contract.id, comment.createdTime) } - // Add recent top-trading contracts, ordered by last bet. + // Add contracts by last bet time. const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) - const contractTotalBets = _.mapValues(contractBets, (bets) => - _.sumBy(bets, (bet) => bet.amount) + const contractMostRecentBet = _.mapValues( + contractBets, + (bets) => _.maxBy(bets, (bet) => bet.createdTime) as Bet ) - const sortedPairs = _.sortBy( - _.toPairs(contractTotalBets), - ([_, total]) => -1 * total - ) - const topTradedContracts = filterDefined( - sortedPairs.map(([id]) => contractsById.get(id)) - ).slice(0, MAX_HOT_MARKETS) - - for (const contract of topTradedContracts) { - const bet = recentBets.find((bet) => bet.contractId === contract.id) - if (bet) { - contracts.push(contract) - record(contract.id, bet.createdTime) - } + for (const bet of Object.values(contractMostRecentBet)) { + const contract = contractsById.get(bet.contractId) + if (contract) record(contract.id, bet.createdTime) } - contracts = _.uniqBy(contracts, (c) => c.id) - contracts = contracts.filter((contract) => contract.visibility === 'public') - contracts = _.sortBy(contracts, (c) => -(idToActivityTime.get(c.id) ?? 0)) - return contracts.slice(0, MAX_ACTIVE_CONTRACTS) + let activeContracts = allContracts.filter( + (contract) => contract.visibility === 'public' && !contract.isResolved + ) + activeContracts = _.sortBy( + activeContracts, + (c) => -(idToActivityTime.get(c.id) ?? 0) + ) + return activeContracts.slice(0, MAX_ACTIVE_CONTRACTS) } export function ActivityFeed(props: { @@ -116,6 +99,24 @@ export function ActivityFeed(props: { ) } +export function SummaryActivityFeed(props: { contracts: Contract[] }) { + const { contracts } = props + + return ( + <Col className="items-center"> + <Col className="w-full max-w-3xl"> + <Col className="w-full divide-y divide-gray-300 self-center bg-white"> + {contracts.map((contract) => ( + <div key={contract.id} className="py-6 px-2 sm:px-4"> + <ContractSummaryFeed contract={contract} /> + </div> + ))} + </Col> + </Col> + </Col> + ) +} + export default function ActivityPage() { return ( <Page> diff --git a/web/pages/add-funds.tsx b/web/pages/add-funds.tsx index dcf65088..2ecf6317 100644 --- a/web/pages/add-funds.tsx +++ b/web/pages/add-funds.tsx @@ -19,11 +19,11 @@ export default function AddFundsPage() { <SEO title="Add funds" description="Add funds" url="/add-funds" /> <Col className="items-center"> - <Col> - <Title text="Get Manifold Dollars" /> + <Col className="bg-white rounded sm:shadow-md p-4 py-8 sm:p-8 h-full"> + <Title className="!mt-0" text="Get Manifold Dollars" /> <img - className="mt-6 block" - src="/praying-mantis-light.svg" + className="mb-6 block self-center -scale-x-100" + src="/stylized-crane-black.png" width={200} height={200} /> @@ -50,7 +50,7 @@ export default function AddFundsPage() { <form action={checkoutURL(user?.id || '', amountSelected)} method="POST" - className="mt-12" + className="mt-8" > <button type="submit" diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index 6dc4b245..44a6119e 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -26,6 +26,7 @@ export type LiteMarket = { volume24Hours: number isResolved: boolean resolution?: string + resolutionTime?: number } export type FullMarket = LiteMarket & { @@ -54,6 +55,7 @@ export function toLiteMarket({ volume24Hours, isResolved, resolution, + resolutionTime, }: Contract): LiteMarket { return { id, @@ -61,7 +63,10 @@ export function toLiteMarket({ creatorName, createdTime, creatorAvatarUrl, - closeTime, + closeTime: + resolutionTime && closeTime + ? Math.min(resolutionTime, closeTime) + : closeTime, question, description, tags, @@ -72,5 +77,6 @@ export function toLiteMarket({ volume24Hours, isResolved, resolution, + resolutionTime, } } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 8a8f7e2b..76afa8d2 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -248,6 +248,7 @@ export function NewContract(props: { question: string; tag?: string }) { error={anteError} setError={setAnteError} disabled={isSubmitting} + contractId={undefined} /> </div> diff --git a/web/pages/fold/[...slugs]/index.tsx b/web/pages/fold/[...slugs]/index.tsx index aa3742a4..1d77444b 100644 --- a/web/pages/fold/[...slugs]/index.tsx +++ b/web/pages/fold/[...slugs]/index.tsx @@ -5,12 +5,7 @@ import { Fold } from '../../../../common/fold' import { Comment } from '../../../../common/comment' import { Page } from '../../../components/page' import { Title } from '../../../components/title' -import { - Bet, - getRecentContractBets, - listAllBets, -} from '../../../lib/firebase/bets' -import { listAllComments } from '../../../lib/firebase/comments' +import { Bet, listAllBets } from '../../../lib/firebase/bets' import { Contract } from '../../../lib/firebase/contracts' import { foldPath, @@ -40,6 +35,7 @@ import FeedCreate from '../../../components/feed-create' import { SEO } from '../../../components/SEO' import { useTaggedContracts } from '../../../hooks/use-contracts' import { Linkify } from '../../../components/linkify' +import { filterDefined } from '../../../../common/util/array' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params @@ -49,42 +45,21 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) { const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : [] - const betsPromise = Promise.all( + const bets = await Promise.all( contracts.map((contract) => listAllBets(contract.id)) ) + const betsByContract = _.fromPairs(contracts.map((c, i) => [c.id, bets[i]])) - const [contractComments, contractRecentBets] = await Promise.all([ - Promise.all( - contracts.map((contract) => listAllComments(contract.id).catch((_) => [])) - ), - Promise.all( - contracts.map((contract) => - getRecentContractBets(contract.id).catch((_) => []) - ) - ), - ]) - - let activeContracts = findActiveContracts( - contracts, - _.flatten(contractComments), - _.flatten(contractRecentBets), - 365 - ) + let activeContracts = findActiveContracts(contracts, [], _.flatten(bets)) const [resolved, unresolved] = _.partition( activeContracts, ({ isResolved }) => isResolved ) activeContracts = [...unresolved, ...resolved] - const activeContractBets = await Promise.all( - activeContracts.map((contract) => listAllBets(contract.id).catch((_) => [])) + const activeContractBets = activeContracts.map( + (contract) => betsByContract[contract.id] ?? [] ) - const activeContractComments = activeContracts.map( - (contract) => - contractComments[contracts.findIndex((c) => c.id === contract.id)] - ) - - const bets = await betsPromise const creatorScores = scoreCreators(contracts, bets) const traderScores = scoreTraders(contracts, bets) @@ -102,7 +77,7 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) { contracts, activeContracts, activeContractBets, - activeContractComments, + activeContractComments: activeContracts.map(() => []), traderScores, topTraders, creatorScores, @@ -169,9 +144,11 @@ export default function FoldPage(props: { taggedContracts.map((contract) => [contract.id, contract]) ) - const contracts = props.contracts.map((contract) => contractsMap[contract.id]) - const activeContracts = props.activeContracts.map( - (contract) => contractsMap[contract.id] + const contracts = filterDefined( + props.contracts.map((contract) => contractsMap[contract.id]) + ) + const activeContracts = filterDefined( + props.activeContracts.map((contract) => contractsMap[contract.id]) ) if (fold === null || !foldSubpages.includes(page) || slugs[2]) { diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 4a91fd59..d7afdad1 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,9 +1,12 @@ -import React from 'react' +import React, { useState } from 'react' import Router from 'next/router' +import { SparklesIcon, GlobeAltIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import _ from 'lodash' import { Contract } from '../lib/firebase/contracts' import { Page } from '../components/page' -import { ActivityFeed } from './activity' +import { ActivityFeed, SummaryActivityFeed } from './activity' import { Comment } from '../lib/firebase/comments' import { Bet } from '../lib/firebase/bets' import FeedCreate from '../components/feed-create' @@ -13,12 +16,15 @@ import { useUser } from '../hooks/use-user' import { Fold } from '../../common/fold' import { LoadingIndicator } from '../components/loading-indicator' import { Row } from '../components/layout/row' -import { SparklesIcon } from '@heroicons/react/solid' import { FastFoldFollowing } from '../components/fast-fold-following' import { getAllContractInfo, - useActiveContracts, -} from '../hooks/use-active-contracts' + useExploreContracts, + useFilterYourContracts, + useFindActiveContracts, +} from '../hooks/use-find-active-contracts' +import { useGetRecentBets } from '../hooks/use-bets' +import { useActiveContracts } from '../hooks/use-contracts' export async function getStaticProps() { const contractInfo = await getAllContractInfo() @@ -35,14 +41,27 @@ const Home = (props: { recentBets: Bet[] recentComments: Comment[] }) => { + const { folds, recentComments } = props const user = useUser() - const { - activeContracts, - activeBets, - activeComments, - initialFollowedFoldSlugs, - } = useActiveContracts(props, user) + const contracts = useActiveContracts() ?? props.contracts + const { yourContracts, initialFollowedFoldSlugs } = useFilterYourContracts( + user, + folds, + contracts + ) + + const recentBets = useGetRecentBets() + const { activeContracts, activeBets, activeComments } = + useFindActiveContracts({ + contracts: yourContracts, + recentBets: recentBets ?? [], + recentComments, + }) + + const exploreContracts = useExploreContracts() + + const [feedMode, setFeedMode] = useState<'activity' | 'explore'>('activity') if (user === null) { Router.replace('/') @@ -64,22 +83,52 @@ const Home = (props: { /> )} + <Spacer h={5} /> + <Col className="mx-3 mb-3 gap-2 text-sm text-gray-800 sm:flex-row"> <Row className="gap-2"> - <SparklesIcon className="inline h-5 w-5" aria-hidden="true" /> - <span className="whitespace-nowrap">Recent activity</span> + <div className="tabs"> + <div + className={clsx( + 'tab gap-2', + feedMode === 'activity' && 'tab-active' + )} + onClick={() => setFeedMode('activity')} + > + <SparklesIcon className="inline h-5 w-5" aria-hidden="true" /> + Recent activity + </div> + <div + className={clsx( + 'tab gap-2', + feedMode === 'explore' && 'tab-active' + )} + onClick={() => setFeedMode('explore')} + > + <GlobeAltIcon className="inline h-5 w-5" aria-hidden="true" /> + Explore + </div> + </div> </Row> </Col> - {activeContracts ? ( - <ActivityFeed - contracts={activeContracts} - contractBets={activeBets} - contractComments={activeComments} - /> - ) : ( - <LoadingIndicator className="mt-4" /> - )} + {feedMode === 'activity' && + (recentBets ? ( + <ActivityFeed + contracts={activeContracts} + contractBets={activeBets} + contractComments={activeComments} + /> + ) : ( + <LoadingIndicator className="mt-4" /> + ))} + + {feedMode === 'explore' && + (exploreContracts ? ( + <SummaryActivityFeed contracts={exploreContracts} /> + ) : ( + <LoadingIndicator className="mt-4" /> + ))} </Col> </Col> </Page> diff --git a/web/pages/make-predictions.tsx b/web/pages/make-predictions.tsx index 3f37f535..37872c39 100644 --- a/web/pages/make-predictions.tsx +++ b/web/pages/make-predictions.tsx @@ -245,6 +245,7 @@ ${TEST_VALUE} error={anteError} setError={setAnteError} disabled={isSubmitting} + contractId={undefined} /> </div> diff --git a/web/public/logo-banner.png b/web/public/logo-banner.png index 776e94ff..608d6f87 100644 Binary files a/web/public/logo-banner.png and b/web/public/logo-banner.png differ diff --git a/web/public/stylized-crane-black.png b/web/public/stylized-crane-black.png new file mode 100644 index 00000000..4bdf2bc6 Binary files /dev/null and b/web/public/stylized-crane-black.png differ