Merge branch 'main' into user-profile

This commit is contained in:
mantikoros 2022-02-03 12:09:08 -06:00
commit 4d86fa2256
38 changed files with 6931 additions and 133 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.DS_Store
.vercel
node_modules

13
common/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
# TypeScript v1 declaration files
typings/
# Node.js dependency directory
node_modules/
package-lock.json
ui-debug.log
firebase-debug.log

View File

@ -7,6 +7,7 @@ export type Fold = {
createdTime: number
tags: string[]
lowercaseTags: string[]
contractIds: string[]
excludedContractIds: string[]
@ -17,4 +18,6 @@ export type Fold = {
excludedCreatorIds?: string[]
followCount: number
disallowMarketCreation?: boolean
}

12
common/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "common",
"version": "1.0.0",
"private": true,
"scripts": {},
"dependencies": {
"lodash": "4.17.21"
},
"devDependencies": {
"@types/lodash": "4.14.178"
}
}

View File

@ -1,7 +1,7 @@
import _ from 'lodash'
import { Contract } from '../../../common/contract'
import { getPayouts } from '../../../common/payouts'
import { Bet } from './bets'
import * as _ from 'lodash'
import { Bet } from './bet'
import { Contract } from './contract'
import { getPayouts } from './payouts'
export function scoreCreators(contracts: Contract[], bets: Bet[][]) {
const creatorScore = _.mapValues(
@ -18,15 +18,12 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
)
const userScores: { [userId: string]: number } = {}
for (const scores of userScoresByContract) {
for (const [userId, score] of Object.entries(scores)) {
if (userScores[userId] === undefined) userScores[userId] = 0
userScores[userId] += score
}
addUserScores(scores, userScores)
}
return userScores
}
function scoreUsersByContract(contract: Contract, bets: Bet[]) {
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const { resolution, resolutionProbability } = contract
const [closedBets, openBets] = _.partition(
@ -61,3 +58,13 @@ function scoreUsersByContract(contract: Contract, bets: Bet[]) {
return userScore
}
export function addUserScores(
src: { [userId: string]: number },
dest: { [userId: string]: number }
) {
for (const [userId, score] of Object.entries(src)) {
if (dest[userId] === undefined) dest[userId] = 0
dest[userId] += score
}
}

View File

@ -21,7 +21,7 @@ export function parseTags(text: string) {
export function parseWordsAsTags(text: string) {
const taggedText = text
.split(/\s+/)
.map((tag) => `#${tag}`)
.map((tag) => (tag.startsWith('#') ? tag : `#${tag}`))
.join(' ')
return parseTags(taggedText)
}

13
common/yarn.lock Normal file
View File

@ -0,0 +1,13 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/lodash@4.14.178":
version "4.14.178"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8"
integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==
lodash@4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==

View File

@ -1,5 +1,6 @@
{
"name": "functions",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"watch": "tsc -w",

View File

@ -59,6 +59,7 @@ export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
name,
about,
tags,
lowercaseTags: tags.map((tag) => tag.toLowerCase()),
createdTime: Date.now(),
contractIds: [],
excludedContractIds: [],

View File

@ -0,0 +1,34 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init'
initAdmin('james')
import { getValues } from '../utils'
import { Fold } from '../../../common/fold'
async function lowercaseFoldTags() {
const firestore = admin.firestore()
console.log('Updating fold tags')
const folds = await getValues<Fold>(firestore.collection('folds'))
console.log('Loaded', folds.length, 'folds')
for (const fold of folds) {
const foldRef = firestore.doc(`folds/${fold.id}`)
const { tags } = fold
const lowercaseTags = _.uniq(tags.map((tag) => tag.toLowerCase()))
console.log('Adding lowercase tags', fold.slug, lowercaseTags)
await foldRef.update({
lowercaseTags,
} as Partial<Fold>)
}
}
if (require.main === module) {
lowercaseFoldTags().then(() => process.exit())
}

View File

@ -16,6 +16,7 @@ export const updateContractMetrics = functions.pubsub
const contracts = await getValues<Contract>(
firestore.collection('contracts')
)
await Promise.all(
contracts.map(async (contract) => {
const volume24Hours = await computeVolumeFrom(contract, oneDay)

View File

@ -7,7 +7,6 @@ import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
import { StripeTransaction } from '.'
const firestore = admin.firestore()
@ -29,16 +28,9 @@ export const updateUserMetrics = functions.pubsub
user,
contractsDict
)
const deposits = await getValues<StripeTransaction>(
firestore
.collection('stripe-transactions')
.where('userId', '==', user.id)
)
const totalDeposits =
1000 + _.sumBy(deposits, (deposit) => deposit.manticDollarQuantity)
const totalValue = user.balance + investmentValue
const totalPnL = totalValue - totalDeposits
const totalPnL = totalValue - user.totalDeposits
const creatorVolume = await computeTotalVolume(user, contractsDict)

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "mantic",
"private": true,
"workspaces": [
"common",
"functions",
"web"
],
"scripts": {},
"dependencies": {},
"devDependencies": {}
}

View File

@ -60,7 +60,7 @@ export function AmountInput(props: {
inputClassName
)}
ref={inputRef}
type="text"
type="number"
placeholder="0"
maxLength={9}
value={amount ?? ''}

View File

@ -4,6 +4,7 @@ export function ConfirmationButton(props: {
id: string
openModelBtn: {
label: string
icon?: any
className?: string
}
cancelBtn?: {
@ -25,7 +26,7 @@ export function ConfirmationButton(props: {
htmlFor={id}
className={clsx('btn modal-button', openModelBtn.className)}
>
{openModelBtn.label}
{openModelBtn.icon} {openModelBtn.label}
</label>
<input type="checkbox" id={id} className="modal-toggle" />

View File

@ -540,7 +540,8 @@ function FeedBetGroup(props: { activityItem: any }) {
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES')
const createdTime = bets[0].createdTime
// Use the time of the last bet for the entire group
const createdTime = bets[bets.length - 1].createdTime
return (
<>

View File

@ -18,16 +18,19 @@ import { ContractFeed } from './contract-feed'
import { TweetButton } from './tweet-button'
import { Bet } from '../../common/bet'
import { Comment } from '../../common/comment'
import { TagsInput } from './tags-input'
import { RevealableTagsInput, TagsInput } from './tags-input'
import BetRow from './bet-row'
import { Fold } from '../../common/fold'
import { FoldTagList } from './tags-list'
export const ContractOverview = (props: {
contract: Contract
bets: Bet[]
comments: Comment[]
folds: Fold[]
className?: string
}) => {
const { contract, bets, comments, className } = props
const { contract, bets, comments, folds, className } = props
const { resolution, creatorId, creatorName } = contract
const { probPercent, truePool } = contractMetrics(contract)
@ -85,11 +88,28 @@ export const ContractOverview = (props: {
<ContractProbGraph contract={contract} />
<Row className="justify-between mt-6 ml-4 gap-4">
<TagsInput contract={contract} />
<Row className="hidden sm:flex justify-between items-center mt-6 ml-4 gap-4">
{folds.length === 0 ? (
<TagsInput className={clsx('mx-4')} contract={contract} />
) : (
<FoldTagList folds={folds} />
)}
<TweetButton tweetText={tweetText} />
</Row>
<Col className="sm:hidden mt-6 ml-4 gap-4">
<TweetButton className="self-end" tweetText={tweetText} />
{folds.length === 0 ? (
<TagsInput contract={contract} />
) : (
<FoldTagList folds={folds} />
)}
</Col>
{folds.length > 0 && (
<RevealableTagsInput className="mt-4 mx-4" contract={contract} />
)}
<Spacer h={12} />
{/* Show a delete button for contracts without any trading */}

View File

@ -1,6 +1,7 @@
import clsx from 'clsx'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { PlusCircleIcon } from '@heroicons/react/solid'
import { parseWordsAsTags } from '../../common/util/parse'
import { createFold } from '../lib/firebase/api-call'
import { foldPath } from '../lib/firebase/folds'
@ -49,7 +50,8 @@ export function CreateFoldButton() {
<ConfirmationButton
id="create-fold"
openModelBtn={{
label: 'Create a fold',
label: 'New',
icon: <PlusCircleIcon className="w-5 h-5 mr-2" />,
className: clsx(
isSubmitting ? 'loading btn-disabled' : 'btn-primary',
'btn-sm'
@ -61,11 +63,11 @@ export function CreateFoldButton() {
}}
onSubmit={onSubmit}
>
<Title className="!mt-0" text="Create a fold" />
<Title className="!mt-0" text="Create a community" />
<Col className="text-gray-500 gap-1">
<div>
Markets are included in a fold if they match one or more tags.
Markets are included in a community if they match one or more tags.
</div>
</Col>
@ -74,11 +76,11 @@ export function CreateFoldButton() {
<div>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">Fold name</span>
<span className="mb-1">Community name</span>
</label>
<input
placeholder="Your fold name"
placeholder="Name"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
@ -109,7 +111,7 @@ export function CreateFoldButton() {
<label className="label">
<span className="mb-1">Primary tag</span>
</label>
<TagsList noLink tags={[`#${toCamelCase(name)}`]} />
<TagsList noLink noLabel tags={[`#${toCamelCase(name)}`]} />
<Spacer h={4} />
@ -132,6 +134,7 @@ export function CreateFoldButton() {
<TagsList
tags={parseWordsAsTags(otherTags).map((tag) => `#${tag}`)}
noLink
noLabel
/>
</div>
</ConfirmationButton>

View File

@ -59,7 +59,7 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
<div className="modal-box">
<div className="form-control w-full">
<label className="label">
<span className="mb-1">Fold name</span>
<span className="mb-1">Community name</span>
</label>
<input
@ -105,7 +105,7 @@ export function EditFoldButton(props: { fold: Fold; className?: string }) {
</div>
<Spacer h={4} />
<TagsList tags={tags.map((tag) => `#${tag}`)} noLink />
<TagsList tags={tags.map((tag) => `#${tag}`)} noLink noLabel />
<Spacer h={4} />
<div className="modal-action">

View File

@ -81,7 +81,7 @@ export default function FeedCreate(props: {
return (
<div
className={clsx('w-full bg-white p-4 border-b-2 mt-2', className)}
className={clsx('w-full bg-white p-4 shadow-md mt-2', className)}
onClick={() => !question && inputRef.current?.focus()}
>
<div className="relative flex items-start space-x-3">

View File

@ -0,0 +1,17 @@
import clsx from 'clsx'
import { Fold } from '../../common/fold'
export function FoldTag(props: { fold: Fold }) {
const { fold } = props
const { name } = fold
return (
<div
className={clsx(
'bg-white hover:bg-gray-100 px-4 py-2 rounded-full shadow-md',
'cursor-pointer'
)}
>
<span className="text-gray-500">{name}</span>
</div>
)
}

View File

@ -16,7 +16,7 @@ export function Leaderboard(props: {
const { title, users, columns, className } = props
return (
<div className={clsx('w-full px-1', className)}>
<Title text={title} />
<Title text={title} className="!mt-0" />
{users.length === 0 ? (
<div className="text-gray-500 ml-2">None yet</div>
) : (

View File

@ -6,6 +6,12 @@ import { Row } from './layout/row'
import { firebaseLogin, User } from '../lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { ProfileMenu } from './profile-menu'
import {
CollectionIcon,
HomeIcon,
SearchIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
export function NavBar(props: {
darkBackground?: boolean
@ -22,25 +28,76 @@ export function NavBar(props: {
const themeClasses = clsx(darkBackground && 'text-white', hoverClasses)
return (
<nav className={clsx('w-full p-4 mb-4', className)} aria-label="Global">
<Row
className={clsx(
'justify-between items-center mx-auto sm:px-4',
wide ? 'max-w-6xl' : 'max-w-4xl'
)}
>
<ManifoldLogo className="my-1" darkBackground={darkBackground} />
<Row className="items-center gap-6 sm:gap-8 ml-6">
{(user || user === null || assertUser) && (
<NavOptions
user={user}
assertUser={assertUser}
themeClasses={themeClasses}
/>
<>
<nav className={clsx('w-full p-4 mb-4', className)} aria-label="Global">
<Row
className={clsx(
'justify-between items-center mx-auto sm:px-4',
wide ? 'max-w-6xl' : 'max-w-4xl'
)}
>
<ManifoldLogo className="my-1" darkBackground={darkBackground} />
<Row className="items-center gap-6 sm:gap-8 ml-6">
{(user || user === null || assertUser) && (
<NavOptions
user={user}
assertUser={assertUser}
themeClasses={themeClasses}
/>
)}
</Row>
</Row>
</Row>
</nav>
{user && <BottomNavBar user={user} />}
</>
)
}
// From https://codepen.io/chris__sev/pen/QWGvYbL
function BottomNavBar(props: { user: User }) {
const { user } = props
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 bg-white z-20 flex justify-between text-xs text-gray-700 border-t-2">
<Link href="/home">
<a
href="#"
className="w-full block py-2 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700 transition duration-300"
>
<HomeIcon className="h-6 w-6 my-1 mx-auto" aria-hidden="true" />
{/* Home */}
</a>
</Link>
<Link href="/markets">
<a
href="#"
className="w-full block py-2 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700"
>
<SearchIcon className="h-6 w-6 my-1 mx-auto" aria-hidden="true" />
{/* Explore */}
</a>
</Link>
<Link href="/folds">
<a
href="#"
className="w-full block py-2 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700"
>
<UserGroupIcon className="h-6 w-6 my-1 mx-auto" aria-hidden="true" />
{/* Folds */}
</a>
</Link>
<Link href="/trades">
<a
href="#"
className="w-full block py-2 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700"
>
<CollectionIcon className="h-6 w-6 my-1 mx-auto" aria-hidden="true" />
{/* Your Trades */}
</a>
</Link>
</nav>
)
}
@ -77,7 +134,7 @@ function NavOptions(props: {
themeClasses
)}
>
Folds
Communities
</a>
</Link>

View File

@ -15,7 +15,7 @@ export function Page(props: {
<div
className={clsx(
'w-full mx-auto',
'w-full mx-auto pb-16',
wide ? 'max-w-6xl' : 'max-w-4xl',
margin && 'px-4'
)}

View File

@ -1,11 +1,13 @@
import clsx from 'clsx'
import { useState } from 'react'
import { parseWordsAsTags } from '../../common/util/parse'
import { Contract, updateContract } from '../lib/firebase/contracts'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { TagsList } from './tags-list'
export function TagsInput(props: { contract: Contract }) {
const { contract } = props
export function TagsInput(props: { contract: Contract; className?: string }) {
const { contract, className } = props
const { tags } = contract
const [tagText, setTagText] = useState('')
@ -24,7 +26,7 @@ export function TagsInput(props: { contract: Contract }) {
}
return (
<Row className="flex-wrap gap-4">
<Col className={clsx('gap-4', className)}>
<TagsList tags={newTags.map((tag) => `#${tag}`)} />
<Row className="items-center gap-4">
@ -40,6 +42,28 @@ export function TagsInput(props: { contract: Contract }) {
Save tags
</button>
</Row>
</Row>
</Col>
)
}
export function RevealableTagsInput(props: {
contract: Contract
className?: string
}) {
const { contract, className } = props
const [hidden, setHidden] = useState(true)
if (hidden)
return (
<div
className={clsx(
'text-gray-500 cursor-pointer hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
onClick={() => setHidden((hidden) => !hidden)}
>
Show tags
</div>
)
return <TagsInput className={clsx('pt-2', className)} contract={contract} />
}

View File

@ -1,18 +1,18 @@
import clsx from 'clsx'
import { Row } from './layout/row'
import { Linkify } from './linkify'
import { SiteLink } from './site-link'
import { Fold } from '../../common/fold'
export function Hashtag(props: { tag: string; noLink?: boolean }) {
const { tag, noLink } = props
const body = (
<div
className={clsx(
'bg-white hover:bg-gray-100 px-4 py-2 rounded-full shadow-md',
'bg-gray-100 border-2 px-3 py-1 rounded-full shadow-md',
!noLink && 'cursor-pointer'
)}
>
<span className="text-gray-500">{tag}</span>
<span className="text-gray-600 text-sm">{tag}</span>
</div>
)
@ -28,10 +28,12 @@ export function TagsList(props: {
tags: string[]
className?: string
noLink?: boolean
noLabel?: boolean
}) {
const { tags, className, noLink } = props
const { tags, className, noLink, noLabel } = props
return (
<Row className={clsx('flex-wrap gap-2', className)}>
<Row className={clsx('items-center flex-wrap gap-2', className)}>
{!noLabel && <div className="text-gray-500 mr-1">Tags</div>}
{tags.map((tag) => (
<Hashtag key={tag} tag={tag} noLink={noLink} />
))}
@ -39,15 +41,35 @@ export function TagsList(props: {
)
}
export function CompactTagsList(props: { tags: string[] }) {
const { tags } = props
export function FoldTag(props: { fold: Fold }) {
const { fold } = props
const { name } = fold
return (
<Row className="gap-2 flex-wrap text-sm text-gray-500">
{tags.map((tag) => (
<div key={tag} className="bg-gray-100 px-1">
<Linkify text={tag} gray />
</div>
))}
<SiteLink href={`/fold/${fold.slug}`} className="flex items-center">
<div
className={clsx(
'bg-white border-2 px-4 py-1 rounded-full shadow-md',
'cursor-pointer'
)}
>
<span className="text-gray-500 text-sm">{name}</span>
</div>
</SiteLink>
)
}
export function FoldTagList(props: { folds: Fold[]; className?: string }) {
const { folds, className } = props
return (
<Row className={clsx('flex-wrap gap-2 items-center', className)}>
{folds.length > 0 && (
<>
<div className="text-gray-500 mr-1">Communities</div>
{folds.map((fold) => (
<FoldTag key={fold.id} fold={fold} />
))}
</>
)}
</Row>
)
}

View File

@ -4,6 +4,7 @@ import {
query,
onSnapshot,
where,
orderBy,
} from 'firebase/firestore'
import _ from 'lodash'
@ -23,6 +24,29 @@ export async function listAllBets(contractId: string) {
return bets
}
const DAY_IN_MS = 24 * 60 * 60 * 1000
// Define "recent" as "<24 hours ago" for now
const recentBetsQuery = query(
collectionGroup(db, 'bets'),
where('createdTime', '>', Date.now() - DAY_IN_MS),
orderBy('createdTime', 'desc')
)
export async function getRecentBets() {
return getValues<Bet>(recentBetsQuery)
}
export async function getRecentContractBets(contractId: string) {
const q = query(
getBetsCollection(contractId),
where('createdTime', '>', Date.now() - DAY_IN_MS),
orderBy('createdTime', 'desc')
)
return getValues<Bet>(q)
}
export function listenForBets(
contractId: string,
setBets: (bets: Bet[]) => void

View File

@ -7,6 +7,7 @@ import {
updateDoc,
where,
} from 'firebase/firestore'
import _ from 'lodash'
import { Fold } from '../../../common/fold'
import { Contract, contractCollection } from './contracts'
import { db } from './init'
@ -131,3 +132,18 @@ export function listenForFollow(
setFollow(!!value)
})
}
export async function getFoldsByTags(tags: string[]) {
if (tags.length === 0) return []
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const folds = await getValues<Fold>(
// TODO: split into multiple queries if tags.length > 10.
query(
foldCollection,
where('lowercaseTags', 'array-contains-any', lowercaseTags)
)
)
return _.sortBy(folds, (fold) => -1 * fold.followCount)
}

View File

@ -1,9 +1,7 @@
import { getFirestore } from '@firebase/firestore'
import { initializeApp, getApps, getApp } from 'firebase/app'
// TODO: Reenable this when we have a way to set the Firebase db in dev
// export const isProd = process.env.NODE_ENV === 'production'
export const isProd = true
export const isProd = process.env.NEXT_PUBLIC_FIREBASE_ENV !== 'DEV'
const firebaseConfig = isProd
? {

View File

@ -1,8 +1,10 @@
{
"name": "mantic",
"name": "web",
"version": "1.0.0",
"private": true,
"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\"",
"ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty",
"build": "next build",
"start": "next start",

View File

@ -23,6 +23,8 @@ import { contractTextDetails } from '../../components/contract-card'
import { Bet, listAllBets } from '../../lib/firebase/bets'
import { Comment, listAllComments } from '../../lib/firebase/comments'
import Custom404 from '../404'
import { getFoldsByTags } from '../../lib/firebase/folds'
import { Fold } from '../../../common/fold'
export async function getStaticProps(props: {
params: { username: string; contractSlug: string }
@ -31,18 +33,23 @@ export async function getStaticProps(props: {
const contract = (await getContractFromSlug(contractSlug)) || null
const contractId = contract?.id
const foldsPromise = getFoldsByTags(contract?.tags ?? [])
const [bets, comments] = await Promise.all([
contractId ? listAllBets(contractId) : null,
contractId ? listAllComments(contractId) : null,
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
])
const folds = await foldsPromise
return {
props: {
contract,
username,
slug: contractSlug,
contract,
bets,
comments,
folds,
},
revalidate: 60, // regenerate after a minute
@ -55,15 +62,16 @@ export async function getStaticPaths() {
export default function ContractPage(props: {
contract: Contract | null
bets: Bet[] | null
comments: Comment[] | null
slug: string
username: string
bets: Bet[]
comments: Comment[]
slug: string
folds: Fold[]
}) {
const user = useUser()
const contract = useContractWithPreload(props.slug, props.contract)
const { bets, comments } = props
const { bets, comments, folds } = props
if (!contract) {
return <Custom404 />
@ -103,6 +111,7 @@ export default function ContractPage(props: {
contract={contract}
bets={bets ?? []}
comments={comments ?? []}
folds={folds}
/>
<BetsSection contract={contract} user={user ?? null} />
</div>

View File

@ -1,6 +1,7 @@
import { cloneElement } from 'react'
import { Page } from '../components/page'
import { SEO } from '../components/SEO'
import { useContracts } from '../hooks/use-contracts'
import styles from './about.module.css'
export default function About() {
@ -244,7 +245,18 @@ function Contents() {
</li>
<li>
Office hours:{' '}
<a href="https://calendly.com/austinchen/manifold">Calendly</a>
<ul>
<li>
<a href="https://calendly.com/austinchen/manifold">
Calendly Austin
</a>
</li>
<li>
<a href="https://calendly.com/jamesgrugett/manifold">
Calendly James
</a>
</li>
</ul>
</li>
<li>
Chat:{' '}

View File

@ -7,6 +7,7 @@ import { Col } from '../components/layout/col'
import { Bet } from '../../common/bet'
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?
@ -23,9 +24,11 @@ function lastActivityTime(contract: Contract) {
// - Comment on a market
// - New market created
// - Market resolved
// - Markets with most betting in last 24 hours
export function findActiveContracts(
allContracts: Contract[],
recentComments: Comment[],
recentBets: Bet[],
daysAgo = 3
) {
const idToActivityTime = new Map<string, number>()
@ -56,6 +59,26 @@ export function findActiveContracts(
}
}
// Add recent top-trading contracts, ordered by last bet.
const contractBets = _.groupBy(recentBets, (bet) => bet.contractId)
const contractTotalBets = _.mapValues(contractBets, (bets) =>
_.sumBy(bets, (bet) => bet.amount)
)
const topTradedContracts = _.sortBy(
_.toPairs(contractTotalBets),
([_, total]) => -1 * total
)
.map(([id]) => contractsById.get(id) as Contract)
.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)
}
}
contracts = _.uniqBy(contracts, (c) => c.id)
contracts = contracts.filter((contract) => contract.visibility === 'public')
contracts = _.sortBy(contracts, (c) => -(idToActivityTime.get(c.id) ?? 0))

View File

@ -5,7 +5,11 @@ import { Fold } from '../../../../common/fold'
import { Comment } from '../../../../common/comment'
import { Page } from '../../../components/page'
import { Title } from '../../../components/title'
import { Bet, listAllBets } from '../../../lib/firebase/bets'
import {
Bet,
getRecentContractBets,
listAllBets,
} from '../../../lib/firebase/bets'
import { listAllComments } from '../../../lib/firebase/comments'
import { Contract } from '../../../lib/firebase/contracts'
import {
@ -26,7 +30,7 @@ import { SearchableGrid } from '../../../components/contracts-list'
import { useQueryAndSortParams } from '../../../hooks/use-sort-and-query-params'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { scoreCreators, scoreTraders } from '../../../lib/firebase/scoring'
import { scoreCreators, scoreTraders } from '../../../../common/scoring'
import { Leaderboard } from '../../../components/leaderboard'
import { formatMoney, toCamelCase } from '../../../../common/util/format'
import { EditFoldButton } from '../../../components/edit-fold-button'
@ -43,13 +47,26 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) {
const curatorPromise = fold ? getUser(fold.curatorId) : null
const contracts = fold ? await getFoldContracts(fold).catch((_) => []) : []
const contractComments = await Promise.all(
contracts.map((contract) => listAllComments(contract.id).catch((_) => []))
const betsPromise = Promise.all(
contracts.map((contract) => listAllBets(contract.id))
)
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
)
const [resolved, unresolved] = _.partition(
@ -66,17 +83,16 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) {
contractComments[contracts.findIndex((c) => c.id === contract.id)]
)
const curator = await curatorPromise
const bets = await Promise.all(
contracts.map((contract) => listAllBets(contract.id))
)
const bets = await betsPromise
const creatorScores = scoreCreators(contracts, bets)
const [topCreators, topCreatorScores] = await toUserScores(creatorScores)
const traderScores = scoreTraders(contracts, bets)
const [topTraders, topTraderScores] = await toUserScores(traderScores)
const [topCreators, topTraders] = await Promise.all([
toTopUsers(creatorScores),
toTopUsers(traderScores),
])
const curator = await curatorPromise
return {
props: {
@ -86,17 +102,17 @@ export async function getStaticProps(props: { params: { slugs: string[] } }) {
activeContracts,
activeContractBets,
activeContractComments,
traderScores,
topTraders,
topTraderScores,
creatorScores,
topCreators,
topCreatorScores,
},
revalidate: 60, // regenerate after a minute
}
}
async function toUserScores(userScores: { [userId: string]: number }) {
async function toTopUsers(userScores: { [userId: string]: number }) {
const topUserPairs = _.take(
_.sortBy(Object.entries(userScores), ([_, score]) => -1 * score),
10
@ -105,14 +121,7 @@ async function toUserScores(userScores: { [userId: string]: number }) {
const topUsers = await Promise.all(
topUserPairs.map(([userId]) => getUser(userId))
)
const existingPairs = topUserPairs.filter(([id, _]) =>
topUsers.find((user) => user?.id === id)
)
const topExistingUsers = existingPairs.map(
([id]) => topUsers.find((user) => user?.id === id) as User
)
const topUserScores = existingPairs.map(([_, score]) => score)
return [topExistingUsers, topUserScores] as const
return topUsers.filter((user) => user)
}
export async function getStaticPaths() {
@ -127,19 +136,19 @@ export default function FoldPage(props: {
activeContracts: Contract[]
activeContractBets: Bet[][]
activeContractComments: Comment[][]
traderScores: { [userId: string]: number }
topTraders: User[]
topTraderScores: number[]
creatorScores: { [userId: string]: number }
topCreators: User[]
topCreatorScores: number[]
}) {
const {
curator,
activeContractBets,
activeContractComments,
traderScores,
topTraders,
topTraderScores,
creatorScores,
topCreators,
topCreatorScores,
} = props
const router = useRouter()
@ -239,9 +248,9 @@ export default function FoldPage(props: {
{(page === 'activity' || page === 'markets') && (
<Row className={clsx(page === 'activity' ? 'gap-16' : 'gap-8')}>
<Col className="flex-1">
{user !== null && (
{user !== null && !fold.disallowMarketCreation && (
<FeedCreate
className={clsx(page !== 'activity' && 'hidden')}
className={clsx('border-b-2', page !== 'activity' && 'hidden')}
user={user}
tag={toCamelCase(fold.name)}
placeholder={`Type your question about ${fold.name}`}
@ -271,25 +280,28 @@ export default function FoldPage(props: {
/>
)}
</Col>
<Col className="hidden md:flex max-w-xs w-full gap-10">
<Col className="hidden md:flex max-w-xs w-full gap-12">
<FoldOverview fold={fold} curator={curator} />
<FoldLeaderboards
traderScores={traderScores}
creatorScores={creatorScores}
topTraders={topTraders}
topTraderScores={topTraderScores}
topCreators={topCreators}
topCreatorScores={topCreatorScores}
user={user}
/>
</Col>
</Row>
)}
{page === 'leaderboards' && (
<Col className="gap-8 lg:flex-row">
<Col className="gap-8 lg:flex-row px-4">
<FoldLeaderboards
traderScores={traderScores}
creatorScores={creatorScores}
topTraders={topTraders}
topTraderScores={topTraderScores}
topCreators={topCreators}
topCreatorScores={topCreatorScores}
user={user}
yourPerformanceClassName="lg:hidden"
/>
</Col>
)}
@ -323,23 +335,67 @@ function FoldOverview(props: { fold: Fold; curator: User }) {
</>
)}
<Spacer h={2} />
<div className="divider" />
<TagsList tags={tags.map((tag) => `#${tag}`)} />
<div className="text-gray-500 mb-2">
Includes markets matching any of these tags:
</div>
<TagsList tags={tags.map((tag) => `#${tag}`)} noLabel />
</Col>
</Col>
)
}
function FoldLeaderboards(props: {
traderScores: { [userId: string]: number }
creatorScores: { [userId: string]: number }
topTraders: User[]
topTraderScores: number[]
topCreators: User[]
topCreatorScores: number[]
user: User | null | undefined
yourPerformanceClassName?: string
}) {
const { topTraders, topTraderScores, topCreators, topCreatorScores } = props
const {
traderScores,
creatorScores,
topTraders,
topCreators,
user,
yourPerformanceClassName,
} = props
const yourTraderScore = user ? traderScores[user.id] : undefined
const yourCreatorScore = user ? creatorScores[user.id] : undefined
const topTraderScores = topTraders.map((user) => traderScores[user.id])
const topCreatorScores = topCreators.map((user) => creatorScores[user.id])
return (
<>
{user && (
<Col className={yourPerformanceClassName}>
<div className="bg-indigo-500 text-white text-sm px-4 py-3 rounded">
Your performance
</div>
<div className="bg-white p-2">
<table className="table table-compact text-gray-500 w-full">
<tbody>
<tr>
<td>Trading profit</td>
<td>{formatMoney(yourTraderScore ?? 0)}</td>
</tr>
{yourCreatorScore && (
<tr>
<td>Created market vol</td>
<td>{formatMoney(yourCreatorScore)}</td>
</tr>
)}
</tbody>
</table>
</div>
</Col>
)}
<Leaderboard
className="max-w-xl"
title="🏅 Top traders"
@ -352,13 +408,14 @@ function FoldLeaderboards(props: {
},
]}
/>
<Leaderboard
className="max-w-xl"
title="🏅 Top creators"
users={topCreators}
columns={[
{
header: 'Market pool',
header: 'Market vol',
renderCell: (user) =>
formatMoney(topCreatorScores[topCreators.indexOf(user)]),
},

View File

@ -66,12 +66,12 @@ export default function Folds(props: {
<Col className="max-w-lg w-full">
<Col className="px-4 sm:px-0">
<Row className="justify-between items-center">
<Title text="Explore folds" />
<Title text="Explore communities" />
{user && <CreateFoldButton />}
</Row>
<div className="text-gray-500 mb-6">
Folds are communities on Manifold centered around a collection of
Communities on Manifold are centered around a collection of
markets.
</div>
</Col>

View File

@ -10,7 +10,7 @@ import {
Comment,
listAllComments,
} from '../lib/firebase/comments'
import { Bet, listAllBets } from '../lib/firebase/bets'
import { Bet, getRecentBets, listAllBets } from '../lib/firebase/bets'
import FeedCreate from '../components/feed-create'
import { Spacer } from '../components/layout/spacer'
import { Col } from '../components/layout/col'
@ -18,12 +18,17 @@ import { useUser } from '../hooks/use-user'
import { useContracts } from '../hooks/use-contracts'
export async function getStaticProps() {
const [contracts, recentComments] = await Promise.all([
const [contracts, recentComments, recentBets] = await Promise.all([
listAllContracts().catch((_) => []),
getRecentComments().catch(() => []),
getRecentBets().catch(() => []),
])
const activeContracts = findActiveContracts(contracts, recentComments)
const activeContracts = findActiveContracts(
contracts,
recentComments,
recentBets
)
const activeContractBets = await Promise.all(
activeContracts.map((contract) => listAllBets(contract.id).catch((_) => []))
)

View File

@ -1,6 +1,11 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import Link from 'next/link'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { parseWordsAsTags } from '../../common/util/parse'
import { AmountInput } from '../components/amount-input'
import { InfoTooltip } from '../components/info-tooltip'
import { Col } from '../components/layout/col'
import { Row } from '../components/layout/row'
@ -97,9 +102,18 @@ export default function MakePredictions() {
const user = useUser()
const [predictionsString, setPredictionsString] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [createdContracts, setCreatedContracts] = useState<Contract[]>([])
const [ante, setAnte] = useState<number | undefined>(100)
const [anteError, setAnteError] = useState<string | undefined>()
// By default, close the market a week from today
const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DDT23:59')
const [closeDate, setCloseDate] = useState<undefined | string>(weekFromToday)
const closeTime = closeDate ? dayjs(closeDate).valueOf() : undefined
const bulkPlaceholder = `e.g.
${TEST_VALUE}
...
@ -138,6 +152,9 @@ ${TEST_VALUE}
question: prediction.question,
description: prediction.description,
initialProb: prediction.initialProb,
ante,
closeTime,
tags: parseWordsAsTags(tags),
}).then((r) => (r.data as any).contract)
setCreatedContracts((prev) => [...prev, contract])
@ -171,6 +188,19 @@ ${TEST_VALUE}
<Spacer h={4} />
<div className="form-control w-full">
<label className="label">
<span className="label-text">Description</span>
</label>
<Textarea
placeholder="e.g. This market is part of the ACX predictions for 2022..."
className="input"
value={description}
onChange={(e) => setDescription(e.target.value || '')}
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">Tags</span>
@ -178,10 +208,46 @@ ${TEST_VALUE}
<input
type="text"
placeholder="e.g. #ACX2021 #World"
placeholder="e.g. ACX2021 World"
className="input"
value={description}
onChange={(e) => setDescription(e.target.value || '')}
value={tags}
onChange={(e) => setTags(e.target.value || '')}
/>
</div>
<div className="form-control items-start mb-1">
<label className="label gap-2 mb-1">
<span>Market close</span>
<InfoTooltip text="Trading will be halted after this time (local timezone)." />
</label>
<input
type="datetime-local"
className="input input-bordered"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value || '')}
min={Date.now()}
disabled={isSubmitting}
value={closeDate}
/>
</div>
<Spacer h={4} />
<div className="form-control items-start mb-1">
<label className="label gap-2 mb-1">
<span>Market ante</span>
<InfoTooltip
text={`Subsidize your market to encourage trading. Ante bets are set to match your initial probability.
You earn ${0.01 * 100}% of trading volume.`}
/>
</label>
<AmountInput
amount={ante}
minimumAmount={10}
onChange={setAnte}
error={anteError}
setError={setAnteError}
disabled={isSubmitting}
/>
</div>
@ -226,3 +292,13 @@ ${TEST_VALUE}
</Page>
)
}
// Given a date string like '2022-04-02',
// return the time just before midnight on that date (in the user's local time), as millis since epoch
function dateToMillis(date: string) {
return dayjs(date)
.set('hour', 23)
.set('minute', 59)
.set('second', 59)
.valueOf()
}

6341
yarn.lock Normal file

File diff suppressed because it is too large Load Diff