diff --git a/common/access.ts b/common/access.ts deleted file mode 100644 index acd894b1..00000000 --- a/common/access.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/contract.ts b/common/contract.ts index 954c23fb..445f6212 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -73,3 +73,7 @@ export type FreeResponse = { } export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' + +export const MAX_QUESTION_LENGTH = 480 +export const MAX_DESCRIPTION_LENGTH = 10000 +export const MAX_TAG_LENGTH = 60 diff --git a/common/envs/constants.ts b/common/envs/constants.ts new file mode 100644 index 00000000..b87948a7 --- /dev/null +++ b/common/envs/constants.ts @@ -0,0 +1,30 @@ +import { DEV_CONFIG } from './dev' +import { EnvConfig, PROD_CONFIG } from './prod' +import { THEOREMONE_CONFIG } from './theoremone' + +const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD' + +const CONFIGS = { + PROD: PROD_CONFIG, + DEV: DEV_CONFIG, + THEOREMONE: THEOREMONE_CONFIG, +} +// @ts-ignore +export const ENV_CONFIG: EnvConfig = CONFIGS[ENV] + +export function isWhitelisted(email?: string) { + if (!ENV_CONFIG.whitelistEmail) { + return true + } + return email && (email.endsWith(ENV_CONFIG.whitelistEmail) || isAdmin(email)) +} + +// TODO: Before open sourcing, we should turn these into env vars +export function isAdmin(email: string) { + return ENV_CONFIG.adminEmails.includes(email) +} + +export const DOMAIN = ENV_CONFIG.domain +export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig +export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId +export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' diff --git a/common/envs/dev.ts b/common/envs/dev.ts new file mode 100644 index 00000000..9b82f3c0 --- /dev/null +++ b/common/envs/dev.ts @@ -0,0 +1,14 @@ +import { EnvConfig, PROD_CONFIG } from './prod' + +export const DEV_CONFIG: EnvConfig = { + ...PROD_CONFIG, + firebaseConfig: { + 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', + }, +} diff --git a/common/envs/prod.ts b/common/envs/prod.ts new file mode 100644 index 00000000..20866429 --- /dev/null +++ b/common/envs/prod.ts @@ -0,0 +1,55 @@ +export type EnvConfig = { + domain: string + firebaseConfig: FirebaseConfig + + // Access controls + adminEmails: string[] + whitelistEmail?: string // e.g. '@theoremone.co'. If not provided, all emails are whitelisted + visibility: 'PRIVATE' | 'PUBLIC' + + // Branding + moneyMoniker: string // e.g. 'M$' + faviconPath?: string // Should be a file in /public + navbarLogoPath?: string + newQuestionPlaceholders: string[] +} + +type FirebaseConfig = { + apiKey: string + authDomain: string + projectId: string + storageBucket: string + messagingSenderId: string + appId: string + measurementId: string +} + +export const PROD_CONFIG: EnvConfig = { + domain: 'manifold.markets', + firebaseConfig: { + 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', + }, + adminEmails: [ + 'akrolsmir@gmail.com', // Austin + 'jahooma@gmail.com', // James + 'taowell@gmail.com', // Stephen + 'manticmarkets@gmail.com', // Manifold + ], + visibility: 'PUBLIC', + + moneyMoniker: 'M$', + navbarLogoPath: '', + faviconPath: '/favicon.ico', + newQuestionPlaceholders: [ + 'Will anyone I know get engaged this year?', + 'Will humans set foot on Mars by the end of 2030?', + 'Will any cryptocurrency eclipse Bitcoin by market cap this year?', + 'Will the Democrats win the 2024 presidential election?', + ], +} diff --git a/common/envs/theoremone.ts b/common/envs/theoremone.ts new file mode 100644 index 00000000..ebe504d9 --- /dev/null +++ b/common/envs/theoremone.ts @@ -0,0 +1,26 @@ +import { EnvConfig, PROD_CONFIG } from './prod' + +export const THEOREMONE_CONFIG: EnvConfig = { + domain: 'theoremone.manifold.markets', + firebaseConfig: { + 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', + }, + adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'], + whitelistEmail: '@theoremone.co', + moneyMoniker: 'T$', + visibility: 'PRIVATE', + faviconPath: '/theoremone/logo.ico', + navbarLogoPath: '/theoremone/TheoremOne-Logo.svg', + newQuestionPlaceholders: [ + 'Will we have at least 5 new team members by the end of this quarter?', + 'Will we meet or exceed our goals this sprint?', + 'Will we sign on 3 or more new clients this month?', + 'Will Paul shave his beard by the end of the month?', + ], +} diff --git a/common/util/format.ts b/common/util/format.ts index 4507bada..c8d363d3 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -1,3 +1,5 @@ +import { ENV_CONFIG } from '../envs/constants' + const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', @@ -7,7 +9,9 @@ const formatter = new Intl.NumberFormat('en-US', { export function formatMoney(amount: number) { const newAmount = Math.round(amount) === 0 ? 0 : amount // handle -0 case - return 'M$ ' + formatter.format(newAmount).replace('$', '') + return ( + ENV_CONFIG.moneyMoniker + ' ' + formatter.format(newAmount).replace('$', '') + ) } export function formatWithCommas(amount: number) { diff --git a/common/util/parse.ts b/common/util/parse.ts index 10876b12..b73bdfb3 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,7 +1,9 @@ +import { MAX_TAG_LENGTH } from '../contract' + export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi const matches = (text.match(regex) || []).map((match) => - match.trim().substring(1) + match.trim().substring(1).substring(0, MAX_TAG_LENGTH) ) const tagSet = new Set() const uniqueTags: string[] = [] diff --git a/firestore.indexes.json b/firestore.indexes.json index ac88ccf6..e611f18a 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -242,6 +242,10 @@ "arrayConfig": "CONTAINS", "queryScope": "COLLECTION" }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, { "order": "DESCENDING", "queryScope": "COLLECTION_GROUP" diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index cc5c531d..bdafbf98 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -44,6 +44,8 @@ export const backupDb = functions.pubsub 'users', 'bets', 'comments', + 'followers', + 'answers', ], }) .then((responses) => { diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 764db018..4762a03e 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -1,5 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import * as _ from 'lodash' import { chargeUser, getUser } from './utils' import { @@ -9,6 +10,9 @@ import { DPM, FreeResponse, FullContract, + MAX_DESCRIPTION_LENGTH, + MAX_QUESTION_LENGTH, + MAX_TAG_LENGTH, outcomeType, } from '../../common/contract' import { slugify } from '../../common/util/slugify' @@ -44,10 +48,19 @@ export const createContract = functions const creator = await getUser(userId) if (!creator) return { status: 'error', message: 'User not found' } - const { question, description, initialProb, ante, closeTime, tags } = data + let { question, description, initialProb, ante, closeTime, tags } = data - if (!question) - return { status: 'error', message: 'Missing question field' } + if (!question || typeof question != 'string') + return { status: 'error', message: 'Missing or invalid question field' } + question = question.slice(0, MAX_QUESTION_LENGTH) + + if (typeof description !== 'string') + return { status: 'error', message: 'Invalid description field' } + description = description.slice(0, MAX_DESCRIPTION_LENGTH) + + if (tags !== undefined && !_.isArray(tags)) + return { status: 'error', message: 'Invalid tags field' } + tags = tags?.map((tag) => tag.toString().slice(0, MAX_TAG_LENGTH)) let outcomeType = data.outcomeType ?? 'BINARY' if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index f583abe4..f73b868b 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -14,7 +14,7 @@ import { cleanUsername, } from '../../common/util/clean-username' import { sendWelcomeEmail } from './emails' -import { isWhitelisted } from '../../common/access' +import { isWhitelisted } from '../../common/envs/constants' export const createUser = functions .runWith({ minInstances: 1 }) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 12dda2b5..df2a1e5b 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,5 +1,6 @@ import * as _ from 'lodash' +import { DOMAIN, PROJECT_ID } from '../../common/envs/constants' import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' @@ -9,7 +10,7 @@ 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, isProd } from './utils' +import { getPrivateUser, getUser } from './utils' export const sendMarketResolutionEmail = async ( userId: string, @@ -49,7 +50,7 @@ export const sendMarketResolutionEmail = async ( outcome, investment: `${Math.round(investment)}`, payout: `${Math.round(payout)}`, - url: `https://manifold.markets/${creator.username}/${contract.slug}`, + url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, } // Modify template here: @@ -118,7 +119,7 @@ Or come chat with us on Discord: https://discord.gg/eHQBNBqXuh Best, Austin from Manifold -https://manifold.markets/` +https://${DOMAIN}/` ) } @@ -139,7 +140,7 @@ export const sendMarketCloseEmail = async ( const { question, pool: pools, slug } = contract const pool = formatMoney(_.sum(_.values(pools))) - const url = `https://manifold.markets/${username}/${slug}` + const url = `https://${DOMAIN}/${username}/${slug}` await sendTemplateEmail( privateUser.email, @@ -173,11 +174,9 @@ export const sendNewCommentEmail = async ( return const { question, creatorUsername, slug } = contract - const marketUrl = `https://manifold.markets/${creatorUsername}/${slug}` + const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` - const unsubscribeUrl = `https://us-central1-${ - isProd ? 'mantic-markets' : 'dev-mantic-markets' - }.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` + const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator const { text } = comment @@ -252,10 +251,8 @@ export const sendNewAnswerEmail = async ( 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 marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` + const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-answer` const subject = `New answer on ${question}` const from = `${name} ` diff --git a/functions/src/scripts/pay-out-contract-again.ts b/functions/src/scripts/pay-out-contract-again.ts new file mode 100644 index 00000000..96671ed8 --- /dev/null +++ b/functions/src/scripts/pay-out-contract-again.ts @@ -0,0 +1,105 @@ +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 { + getLoanPayouts, + getPayouts, + getPayoutsMultiOutcome, +} from '../../../common/payouts' +import { filterDefined } from '../../../common/util/array' +import { payUser } from '../utils' + +type DocRef = admin.firestore.DocumentReference + +const firestore = admin.firestore() + +async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) { + const bets = await contractRef + .collection('bets') + .get() + .then((snap) => snap.docs.map((bet) => bet.data() as Bet)) + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + const loanedBets = openBets.filter((bet) => bet.loanAmount) + + if (loanedBets.length && contract.resolution) { + const { resolution, outcomeType, resolutions, resolutionProbability } = + contract + const payouts = + outcomeType === 'FREE_RESPONSE' && resolutions + ? getPayoutsMultiOutcome(resolutions, contract, openBets) + : getPayouts(resolution, contract, openBets, resolutionProbability) + + const loanPayouts = getLoanPayouts(openBets) + const groups = _.groupBy( + [...payouts, ...loanPayouts], + (payout) => payout.userId + ) + const userPayouts = _.mapValues(groups, (group) => + _.sumBy(group, (g) => g.payout) + ) + + const entries = Object.entries(userPayouts) + const firstNegative = entries.findIndex(([_, payout]) => payout < 0) + const toBePaidOut = firstNegative === -1 ? [] : entries.slice(firstNegative) + + if (toBePaidOut.length) { + console.log( + 'to be paid out', + toBePaidOut.length, + 'already paid out', + entries.length - toBePaidOut.length + ) + const positivePayouts = toBePaidOut.filter(([_, payout]) => payout > 0) + if (positivePayouts.length) + return { contract, toBePaidOut: positivePayouts } + } + } + return undefined +} + +async function payOutContractAgain() { + console.log('Recalculating contract info') + + const snapshot = await firestore.collection('contracts').get() + + const [startTime, endTime] = [ + new Date('2022-03-02'), + new Date('2022-03-07'), + ].map((date) => date.getTime()) + + const contracts = snapshot.docs + .map((doc) => doc.data() as Contract) + .filter((contract) => { + const { resolutionTime } = contract + return ( + resolutionTime && resolutionTime > startTime && resolutionTime < endTime + ) + }) + + console.log('Loaded', contracts.length, 'contracts') + + const toPayOutAgain = filterDefined( + await Promise.all( + contracts.map(async (contract) => { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + return await checkIfPayOutAgain(contractRef, contract) + }) + ) + ) + + const flattened = _.flatten(toPayOutAgain.map((d) => d.toBePaidOut)) + + for (const [userId, payout] of flattened) { + console.log('Paying out', userId, payout) + // await payUser(userId, payout) + } +} + +if (require.main === module) payOutContractAgain().then(() => process.exit()) diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index 25b11771..c6edee92 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -7,8 +7,18 @@ import { PrivateUser } from '../../common/user' export const unsubscribe = functions .runWith({ minInstances: 1 }) .https.onRequest(async (req, res) => { - const { id, type } = req.query as { id: string; type: string } - if (!id || !type) return + let { id, type } = req.query as { id: string; type: string } + if (!id || !type) { + res.status(400).send('Empty id or type parameter.') + return + } + + if (type === 'market-resolved') type = 'market-resolve' + + if (!['market-resolve', 'market-comment', 'market-answer'].includes(type)) { + res.status(400).send('Invalid type parameter.') + return + } const user = await getUser(id) diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 3e9ebd5c..eafcc74d 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -4,5 +4,6 @@ module.exports = { rules: { // Add or disable rules here. '@next/next/no-img-element': 'off', + '@next/next/no-typos': 'off', }, } diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 5453439c..1ec4175b 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -1,6 +1,6 @@ // From https://tailwindui.com/components/application-ui/lists/feeds import { Fragment, useState } from 'react' -import _ from 'lodash' +import * as _ from 'lodash' import { BanIcon, CheckIcon, @@ -43,7 +43,11 @@ import { parseTags } from '../../common/util/parse' import { Avatar } from './avatar' import { useAdmin } from '../hooks/use-admin' import { FreeResponse, FullContract } from '../../common/contract' -import { getCpmmLiquidity } from '../../common/calculate-cpmm' +import { Answer } from '../../common/answer' + +const canAddComment = (createdTime: number, isSelf: boolean) => { + return isSelf && Date.now() - createdTime < 60 * 60 * 1000 +} function FeedComment(props: { activityItem: any @@ -95,11 +99,13 @@ function Timestamp(props: { time: number }) { function FeedBet(props: { activityItem: any; feedType: FeedType }) { const { activityItem, feedType } = props - const { id, contractId, amount, outcome, createdTime } = activityItem + const { id, contractId, amount, outcome, createdTime, contract } = + activityItem const user = useUser() const isSelf = user?.id == activityItem.userId - // The creator can comment if the bet was posted in the last hour - const canComment = isSelf && Date.now() - createdTime < 60 * 60 * 1000 + const isCreator = contract.creatorId == activityItem.userId + // You can comment if your bet was posted in the last hour + const canComment = canAddComment(createdTime, isSelf) const [comment, setComment] = useState('') async function submitComment() { @@ -110,11 +116,19 @@ function FeedBet(props: { activityItem: any; feedType: FeedType }) { const bought = amount >= 0 ? 'bought' : 'sold' const money = formatMoney(Math.abs(amount)) + const answer = + feedType !== 'multi' && + (contract.answers?.find((answer: Answer) => answer?.id === outcome) as + | Answer + | undefined) + return ( <>
{isSelf ? ( + ) : isCreator ? ( + ) : (
@@ -123,10 +137,20 @@ function FeedBet(props: { activityItem: any; feedType: FeedType }) {
)}
-
+
+ {answer && ( +
+ {answer.text} +
+ )}
- {isSelf ? 'You' : 'A trader'} {bought} {money} - + + {isSelf ? 'You' : isCreator ? contract.creatorName : 'A trader'} + {' '} + {bought} {money} + {!answer && ( + + )} {canComment && ( // Allow user to comment in an textarea if they are the creator @@ -356,9 +380,9 @@ function FeedQuestion(props: { {!showDescription && ( -
See more...
+
See more...
)} @@ -499,7 +523,7 @@ function FeedClose(props: { contract: Contract }) { ) } -function toFeedBet(bet: Bet) { +function toFeedBet(bet: Bet, contract: Contract) { return { id: bet.id, contractId: bet.contractId, @@ -509,6 +533,7 @@ function toFeedBet(bet: Bet) { outcome: bet.outcome, createdTime: bet.createdTime, date: fromNow(bet.createdTime), + contract, } } @@ -538,12 +563,13 @@ const DAY_IN_MS = 24 * 60 * 60 * 1000 // Group together bets that are: // - Within `windowMs` of the first in the group // - Do not have a comment -// - Were not created by this user +// - Were not created by this user or the contract creator // Return a list of ActivityItems function groupBets( bets: Bet[], comments: Comment[], windowMs: number, + contract: Contract, userId?: string ) { const commentsMap = mapCommentsByBetId(comments) @@ -553,25 +579,25 @@ function groupBets( // Turn the current group into an ActivityItem function pushGroup() { if (group.length == 1) { - items.push(toActivityItem(group[0])) + items.push(toActivityItem(group[0], false)) } else if (group.length > 1) { items.push({ type: 'betgroup', bets: [...group], id: group[0].id }) } group = [] } - function toActivityItem(bet: Bet) { + function toActivityItem(bet: Bet, isPublic: boolean) { const comment = commentsMap[bet.id] - return comment ? toFeedComment(bet, comment) : toFeedBet(bet) + return comment ? toFeedComment(bet, comment) : toFeedBet(bet, contract) } for (const bet of bets) { - const isCreator = userId === bet.userId + const isCreator = userId === bet.userId || contract.creatorId === bet.userId if (commentsMap[bet.id] || isCreator) { pushGroup() // Create a single item for this - items.push(toActivityItem(bet)) + items.push(toActivityItem(bet, true)) } else { if ( group.length > 0 && @@ -791,6 +817,8 @@ export function ContractFeed(props: { const [expanded, setExpanded] = useState(false) const user = useUser() + const comments = useComments(id) ?? props.comments + let bets = useBets(contract.id) ?? props.bets bets = isBinary ? bets.filter((bet) => !bet.isAnte) @@ -798,15 +826,21 @@ export function ContractFeed(props: { if (feedType === 'multi') { bets = bets.filter((bet) => bet.outcome === outcome) + } else if (outcomeType === 'FREE_RESPONSE') { + // Keep bets on comments or your bets where you can comment. + const commentBetIds = new Set(comments.map((comment) => comment.betId)) + bets = bets.filter( + (bet) => + commentBetIds.has(bet.id) || + canAddComment(bet.createdTime, user?.id === bet.userId) + ) } - const comments = useComments(id) ?? props.comments - const groupWindow = feedType == 'activity' ? 10 * DAY_IN_MS : DAY_IN_MS const allItems: ActivityItem[] = [ { type: 'start', id: '0' }, - ...groupBets(bets, comments, groupWindow, user?.id), + ...groupBets(bets, comments, groupWindow, contract, user?.id), ] if (contract.closeTime && contract.closeTime <= Date.now()) { allItems.push({ type: 'close', id: `${contract.closeTime}` }) @@ -847,16 +881,27 @@ export function ContractActivityFeed(props: { comments: Comment[] betRowClassName?: string }) { - const { contract, betRowClassName, bets, comments } = props + const { contract, betRowClassName, comments } = props const user = useUser() - bets.sort((b1, b2) => b1.createdTime - b2.createdTime) + let bets = props.bets.sort((b1, b2) => b1.createdTime - b2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime) + if (contract.outcomeType === 'FREE_RESPONSE') { + // Keep bets on comments, and the last non-comment bet. + const commentBetIds = new Set(comments.map((comment) => comment.betId)) + const [commentBets, nonCommentBets] = _.partition(bets, (bet) => + commentBetIds.has(bet.id) + ) + bets = [...commentBets, ...nonCommentBets.slice(-1)].sort( + (b1, b2) => b1.createdTime - b2.createdTime + ) + } + const allItems: ActivityItem[] = [ { type: 'start', id: '0' }, - ...groupBets(bets, comments, DAY_IN_MS, user?.id), + ...groupBets(bets, comments, DAY_IN_MS, contract, user?.id), ] if (contract.closeTime && contract.closeTime <= Date.now()) { allItems.push({ type: 'close', id: `${contract.closeTime}` }) diff --git a/web/components/feed-create.tsx b/web/components/feed-create.tsx index 2a2d291c..de4d6e22 100644 --- a/web/components/feed-create.tsx +++ b/web/components/feed-create.tsx @@ -5,10 +5,11 @@ import { Spacer } from './layout/spacer' import { NewContract } from '../pages/create' import { firebaseLogin, User } from '../lib/firebase/users' import { ContractsGrid } from './contracts-list' -import { Contract } from '../../common/contract' +import { Contract, MAX_QUESTION_LENGTH } from '../../common/contract' import { Col } from './layout/col' import clsx from 'clsx' import { Row } from './layout/row' +import { ENV_CONFIG } from '../../common/envs/constants' export function FeedPromo(props: { hotContracts: Contract[] }) { const { hotContracts } = props @@ -72,16 +73,10 @@ export default function FeedCreate(props: { const [isExpanded, setIsExpanded] = useState(false) const inputRef = useRef() - const placeholders = [ - 'Will anyone I know get engaged this year?', - 'Will humans set foot on Mars by the end of 2030?', - 'Will any cryptocurrency eclipse Bitcoin by market cap this year?', - 'Will the Democrats win the 2024 presidential election?', - ] + const placeholders = ENV_CONFIG.newQuestionPlaceholders // Rotate through a new placeholder each day // Easter egg idea: click your own name to shuffle the placeholder // const daysSinceEpoch = Math.floor(Date.now() / 1000 / 60 / 60 / 24) - const [randIndex] = useState( Math.floor(Math.random() * 1e10) % placeholders.length ) @@ -90,7 +85,7 @@ export default function FeedCreate(props: { return (
68 ? 4 : 2} + maxLength={MAX_QUESTION_LENGTH} onClick={(e) => e.stopPropagation()} onChange={(e) => setQuestion(e.target.value.replace('\n', ''))} onFocus={() => setIsExpanded(true)} diff --git a/web/components/manifold-logo.tsx b/web/components/manifold-logo.tsx index 75134ef8..26344bf3 100644 --- a/web/components/manifold-logo.tsx +++ b/web/components/manifold-logo.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import clsx from 'clsx' import { useUser } from '../hooks/use-user' +import { ENV_CONFIG } from '../../common/envs/constants' export function ManifoldLogo(props: { className?: string @@ -20,24 +21,30 @@ export function ManifoldLogo(props: { width={45} height={45} /> -
- Manifold -
- Markets -
- + {ENV_CONFIG.navbarLogoPath ? ( + + ) : ( + <> +
+ Manifold +
+ Markets +
+ + + )} ) diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index 7cc00e6a..a01614b1 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -3,6 +3,7 @@ import { formatMoney } from '../../common/util/format' import { Avatar } from './avatar' import { Col } from './layout/col' import { MenuButton } from './menu' +import { IS_PRIVATE_MANIFOLD } from '../../common/envs/constants' export function ProfileMenu(props: { user: User | undefined }) { const { user } = props @@ -54,22 +55,32 @@ function getNavigationOptions( name: 'Your trades', href: '/trades', }, - { - name: 'Add funds', - href: '/add-funds', - }, - { - name: 'Leaderboards', - href: '/leaderboards', - }, - { - name: 'Discord', - href: 'https://discord.gg/eHQBNBqXuh', - }, - { - name: 'About', - href: '/about', - }, + // Disable irrelevant menu options for teams. + ...(IS_PRIVATE_MANIFOLD + ? [ + { + name: 'Leaderboards', + href: '/leaderboards', + }, + ] + : [ + { + name: 'Add funds', + href: '/add-funds', + }, + { + name: 'Leaderboards', + href: '/leaderboards', + }, + { + name: 'Discord', + href: 'https://discord.gg/eHQBNBqXuh', + }, + { + name: 'About', + href: '/about', + }, + ]), { name: 'Sign out', href: '#', diff --git a/web/hooks/use-admin.ts b/web/hooks/use-admin.ts index bbeaf59c..7c8b8449 100644 --- a/web/hooks/use-admin.ts +++ b/web/hooks/use-admin.ts @@ -1,4 +1,4 @@ -import { isAdmin } from '../../common/access' +import { isAdmin } from '../../common/envs/constants' import { usePrivateUser, useUser } from './use-user' export const useAdmin = () => { diff --git a/web/hooks/use-contract.ts b/web/hooks/use-contract.ts index d48cd26a..ceaa125b 100644 --- a/web/hooks/use-contract.ts +++ b/web/hooks/use-contract.ts @@ -29,7 +29,7 @@ export const useContractWithPreload = ( useEffect(() => { if (contractId) return listenForContract(contractId, setContract) - if (contractId !== null) + if (contractId !== null && slug) getContractFromSlug(slug).then((c) => setContractId(c?.id || null)) }, [contractId, slug]) diff --git a/web/hooks/use-find-active-contracts.ts b/web/hooks/use-find-active-contracts.ts index 2a6a3b47..e6f849f6 100644 --- a/web/hooks/use-find-active-contracts.ts +++ b/web/hooks/use-find-active-contracts.ts @@ -25,6 +25,22 @@ export const getAllContractInfo = async () => { return { contracts, recentComments, folds } } +const defaultSkippedTags = [ + 'meta', + 'test', + 'trolling', + 'spam', + 'transaction', + 'personal', +] +const includedWithDefaultFeed = (contract: Contract) => { + const { tags } = contract + + if (tags.length === 0) return false + if (tags.some((tag) => defaultSkippedTags.includes(tag))) return false + return true +} + export const useFilterYourContracts = ( user: User | undefined | null, folds: Fold[], @@ -54,8 +70,9 @@ export const useFilterYourContracts = ( // Show no contracts before your info is loaded. let yourContracts: Contract[] = [] if (yourBetContracts && followedFoldIds) { - // Show all contracts if no folds are followed. - if (followedFoldIds.length === 0) yourContracts = contracts + // Show default contracts if no folds are followed. + if (followedFoldIds.length === 0) + yourContracts = contracts.filter(includedWithDefaultFeed) else yourContracts = contracts.filter( (contract) => diff --git a/web/hooks/use-propz.ts b/web/hooks/use-propz.ts new file mode 100644 index 00000000..5aee4c61 --- /dev/null +++ b/web/hooks/use-propz.ts @@ -0,0 +1,39 @@ +import _ from 'lodash' +import { useRouter } from 'next/router' +import { useState, useEffect } from 'react' +import { IS_PRIVATE_MANIFOLD } from '../../common/envs/constants' + +type PropzProps = { + // Params from the router query + params: any +} + +// getStaticPropz should exactly match getStaticProps +// This allows us to client-side render the page for authenticated users. +// TODO: Could cache the result using stale-while-revalidate: https://swr.vercel.app/ +export function usePropz( + initialProps: Object, + getStaticPropz: (props: PropzProps) => Promise +) { + // If props were successfully server-side generated, just use those + if (!_.isEmpty(initialProps)) { + return initialProps + } + + // Otherwise, get params from router + const router = useRouter() + const params = router.query + + const [propz, setPropz] = useState(undefined) + useEffect(() => { + if (router.isReady) { + getStaticPropz({ params }).then((result) => setPropz(result.props)) + } + }, [params]) + return propz +} + +// Conditionally disable SSG for private Manifold instances +export function fromPropz(getStaticPropz: (props: PropzProps) => Promise) { + return IS_PRIVATE_MANIFOLD ? async () => ({ props: {} }) : getStaticPropz +} diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 145d67c5..b11ad355 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,42 +1,8 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp, getApps, getApp } from 'firebase/app' +import { FIREBASE_CONFIG } from '../../../common/envs/constants' -// Used to decide which Stripe instance to point to -export const isProd = process.env.NEXT_PUBLIC_FIREBASE_ENV !== 'DEV' - -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) +export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) export const db = getFirestore(app) diff --git a/web/lib/service/stripe.ts b/web/lib/service/stripe.ts index 3d69c284..6c093483 100644 --- a/web/lib/service/stripe.ts +++ b/web/lib/service/stripe.ts @@ -1,13 +1,11 @@ -import { isProd } from '../firebase/init' +import { PROJECT_ID } from '../../../common/envs/constants' export const checkoutURL = ( userId: string, manticDollarQuantity: number, referer = '' ) => { - const endpoint = isProd - ? 'https://us-central1-mantic-markets.cloudfunctions.net/createCheckoutSession' - : 'https://us-central1-dev-mantic-markets.cloudfunctions.net/createCheckoutSession' + const endpoint = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/createCheckoutSession` return `${endpoint}?userId=${userId}&manticDollarQuantity=${manticDollarQuantity}&referer=${encodeURIComponent( referer diff --git a/web/package.json b/web/package.json index 6a7caecf..61342e4d 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"", "devdev": "NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=DEV next dev -p 3000\" \"FIREBASE_ENV=DEV yarn ts --watch\"", + "dev:dev": "yarn devdev", + "dev:the": "NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"FIREBASE_ENV=THEOREMONE yarn ts --watch\"", "ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty", "build": "next build", "start": "next start", diff --git a/web/pages/404.tsx b/web/pages/404.tsx index 7e9c9e27..f710263d 100644 --- a/web/pages/404.tsx +++ b/web/pages/404.tsx @@ -1,8 +1,13 @@ -import { useEffect } from 'gridjs' +import { IS_PRIVATE_MANIFOLD } from '../../common/envs/constants' import { Page } from '../components/page' import { Title } from '../components/title' export default function Custom404() { + if (IS_PRIVATE_MANIFOLD) { + // Since private Manifolds are client-side rendered, they'll blink the 404 + // So we just show a blank page here: + return + } return (
diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 38b7cd3c..ea985f81 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -29,8 +29,10 @@ import { useFoldsWithTags } from '../../hooks/use-fold' import { listAllAnswers } from '../../lib/firebase/answers' import { Answer } from '../../../common/answer' import { AnswersPanel } from '../../components/answers/answers-panel' +import { fromPropz, usePropz } from '../../hooks/use-propz' -export async function getStaticProps(props: { +export const getStaticProps = fromPropz(getStaticPropz) +export async function getStaticPropz(props: { params: { username: string; contractSlug: string } }) { const { username, contractSlug } = props.params @@ -77,6 +79,15 @@ export default function ContractPage(props: { slug: string folds: Fold[] }) { + props = usePropz(props, getStaticPropz) ?? { + contract: null, + username: '', + comments: [], + answers: [], + bets: [], + slug: '', + folds: [], + } const user = useUser() const contract = useContractWithPreload(props.slug, props.contract) @@ -143,7 +154,7 @@ export default function ContractPage(props: { <>
- + {allowTrade && ( )} diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index 2388a222..18329b12 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -1,10 +1,11 @@ import { Html, Head, Main, NextScript } from 'next/document' +import { ENV_CONFIG } from '../../common/envs/constants' export default function Document() { return ( - + - + {!IS_PRIVATE_MANIFOLD && } ) } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 5e8830e0..c4feb11c 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -18,7 +18,7 @@ import { ProbabilitySelector } from '../components/probability-selector' import { parseWordsAsTags } from '../../common/util/parse' import { TagsList } from '../components/tags-list' import { Row } from '../components/layout/row' -import { outcomeType } from '../../common/contract' +import { MAX_DESCRIPTION_LENGTH, outcomeType } from '../../common/contract' export default function Create() { const [question, setQuestion] = useState('') @@ -186,6 +186,7 @@ export function NewContract(props: { question: string; tag?: string }) {