Merge branch 'main' into CPM-ui

This commit is contained in:
Austin Chen 2022-04-28 19:01:08 -04:00
commit d55cf571c5
22 changed files with 452 additions and 183 deletions

View File

@ -1,14 +1,51 @@
# Manifold Markets # Manifold Markets
This [monorepo][] has basically everything involved in running and operating Manifold.
## Getting started
0. Make sure you have [Yarn 1.x][yarn]
1. `$ cd web`
2. `$ yarn`
3. `$ yarn dev:dev`
4. Your site will be available on http://localhost:3000
See [`web/README.md`][web-readme] for more details on hacking on the web client.
## General architecture
Manifold's public API and web app are hosted by [Vercel][vercel]. In general, the web app runs as much as possible on the client; we follow a [JAMStack][jamstack] architecture. All data is stored in Firebase's database, [Cloud Firestore][cloud-firestore]. This is directly accessed by the client for most data access operations.
Operations with complicated contracts (e.g. buying shares) are provided in a separate HTTP API using Firebase's [Cloud Functions][cloud-functions]. Those are deployed separately from the Vercel website; see [`functions/README.md`][functions-readme] for more details.
## Directory overview ## Directory overview
- `web/`: UI and biz logic. Where most of the site lives - `web/`: UI and business logic for the client. Where most of the site lives. The public API endpoints are also in here.
- `functions/`: Firebase cloud functions, for secure work (balances, Stripe payments, emails)
- `common/`: shared between web & functions
- `og-image/`: The OpenGraph image generator; creates the preview images shown on Twitter/social media
## Philosophies - `functions/`: Firebase cloud functions, for secure work (e.g. balances, Stripe payments, emails). Also contains in
`functions/src/scripts/` some Typescript scripts that do ad hoc CLI interaction with Firebase.
- [JAMStack](https://jamstack.org/): Keep things simple, no servers - `common/`: Typescript library code shared between `web/` & `functions/`. If you want to look at how the market math
- [Monorepo](https://semaphoreci.com/blog/what-is-monorepo): Good fit for our current size works, most of that's in here (it gets called from the `placeBet` and `sellBet` endpoints in `functions/`.) Also
- [Small PRs](https://google.github.io/eng-practices/review/developer/small-cls.html): Lots of little changes > one big diff contains in `common/envs` configuration for the different environments (i.e. prod, dev, Manifold for Teams instances.)
- `og-image/`: The OpenGraph image generator; creates the preview images shown on Twitter/social media.
Also: Our docs are currently in [a separate repo](https://github.com/manifoldmarkets/docs). TODO: move them into this monorepo.
## Contributing
Since we are just now open-sourcing things, we will see how things go. Feel free to open issues, submit PRs, and chat about the process on [Discord][discord]. We would prefer [small PRs][small-prs] that we can effectively evaluate and review -- maybe check in with us first if you are thinking to work on a big change.
If you need additional access to any infrastructure in order to work on something (e.g. Vercel, Firebase) let us know about that on Discord as well.
[vercel]: https://vercel.com/
[jamstack]: https://jamstack.org/
[monorepo]: https://semaphoreci.com/blog/what-is-monorepo
[yarn]: https://classic.yarnpkg.com/lang/en/docs/install/
[web-readme]: https://github.com/manifoldmarkets/manifold/blob/main/web/README.md
[functions-readme]: https://github.com/manifoldmarkets/manifold/blob/main/functions/README.md
[cloud-firestore]: https://firebase.google.com/docs/firestore
[cloud-functions]: https://firebase.google.com/docs/functions
[small-prs]: https://google.github.io/eng-practices/review/developer/small-cls.html
[discord]: https://discord.gg/eHQBNBqXuh

View File

@ -39,16 +39,24 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'tags', 'lowercaseTags']); .hasOnly(['description', 'closeTime', 'tags', 'lowercaseTags']);
allow update: if isAdmin(); allow update: if isAdmin();
allow delete: if resource.data.creatorId == request.auth.uid;
} }
match /{somePath=**}/bets/{betId} { match /{somePath=**}/bets/{betId} {
allow read; allow read;
} }
function commentMatchesUser(userId, comment) {
// it's a bad look if someone can impersonate other ids/names/avatars so check everything
let user = get(/databases/$(database)/documents/users/$(userId));
return comment.userId == userId
&& comment.userName == user.data.name
&& comment.userUsername == user.data.username
&& comment.userAvatarUrl == user.data.avatarUrl;
}
match /{somePath=**}/comments/{commentId} { match /{somePath=**}/comments/{commentId} {
allow read; allow read;
allow create: if request.auth != null; allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data);
} }
match /{somePath=**}/answers/{answerId} { match /{somePath=**}/answers/{answerId} {
@ -57,12 +65,16 @@ service cloud.firestore {
match /folds/{foldId} { match /folds/{foldId} {
allow read; allow read;
allow update, delete: if request.auth.uid == resource.data.curatorId; allow update: if request.auth.uid == resource.data.curatorId
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['name', 'about', 'tags', 'lowercaseTags']);
allow delete: if request.auth.uid == resource.data.curatorId;
} }
match /{somePath=**}/followers/{userId} { match /{somePath=**}/followers/{userId} {
allow read; allow read;
allow write: if request.auth.uid == userId; allow create, update: if request.auth.uid == userId && request.resource.data.userId == userId;
allow delete: if request.auth.uid == userId;
} }
match /txns/{txnId} { match /txns/{txnId} {

View File

@ -34,8 +34,9 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Developing locally ## Developing locally
0. `$ firebase use dev` if you haven't already
1. `$ yarn serve` to spin up the emulators 1. `$ yarn serve` to spin up the emulators
The Emulator UI is at http://localhost:4000; the functions are hosted on :5001. 1. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001.
Note: You have to kill and restart emulators when you change code; no hot reload =( Note: You have to kill and restart emulators when you change code; no hot reload =(
2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend
1. Note: emulated database is cleared after every shutdown 1. Note: emulated database is cleared after every shutdown

View File

@ -48,8 +48,8 @@ export const sendMarketResolutionEmail = async (
creatorName: creator.name, creatorName: creator.name,
question: contract.question, question: contract.question,
outcome, outcome,
investment: `${Math.round(investment)}`, investment: `${Math.floor(investment)}`,
payout: `${Math.round(payout)}`, payout: `${Math.floor(payout)}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
} }
@ -189,7 +189,9 @@ export const sendNewCommentEmail = async (
let betDescription = '' let betDescription = ''
if (bet) { if (bet) {
const { amount, sale } = bet const { amount, sale } = bet
betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}` betDescription = `${sale || amount < 0 ? 'sold' : 'bought'} ${formatMoney(
Math.abs(amount)
)}`
} }
const subject = `Comment on ${question}` const subject = `Comment on ${question}`

View File

@ -1,24 +1,64 @@
# Manifold Markets web # Manifold Markets web app
## Getting Started ## Getting started
To run the development server, install [Yarn 1.x][yarn], and then in this directory:
1. `yarn` to install all dependencies 1. `yarn` to install all dependencies
2. `yarn dev:dev` to bring up a local instance, pointing to dev database) 2. `yarn dev:dev` starts a development web server, pointing at the development database
3. Your site will be available on http://localhost:3000 3. Your site will be available on http://localhost:3000
(`yarn dev` will point you to prod database) Check package.json for other command-line tasks. (e.g. `yarn dev` will point the development server at the prod
database. `yarn emulate` will run against a local emulated database, if you are serving it via `yarn serve` from the
[`functions/` package][functions-readme].)
### Running with local emulated database and functions ## Tech stack
1. `yarn serve` first in `/functions` and wait for it to start Manifold's website uses [Next.js][nextjs], which is a [React][react]-based framework that handles concerns like routing,
2. `yarn dev:emulate` will point you to the emulated database builds, and a development server. It's also integrated with [Vercel][vercel], which is responsible for hosting the site
and providing some other production functionality like serving the API. The application code is written exclusively in
Typescript. Styling is done via CSS-in-JS in the React code and uses [Tailwind][tailwind] CSS classes.
## Formatting ## Building and deployment
Before committing, run `yarn format` to format your code. Vercel's GitHub integration monitors the repository and automatically builds (`next build`) and deploys both the `main`
branch (to production) and PR branches (to ephemeral staging servers that can be used for testing.)
Recommended: Use a [Prettier editor integration](https://prettier.io/docs/en/editors.html) to automatically format on save Parts of the file structure that directly map to HTTP endpoints are organized specially per Next.js's prescriptions:
### public/
These are static files that will be [served by Next verbatim][next-static-files].
### pages/
These are components that [Next's router][next-pages] is aware of and interprets as page roots per their filename,
e.g. the React component in pages/portfolio.tsx is rendered on the user portfolio page at /portfolio. You should
look in here or in `components/` to find any specific piece of UI you are interested in working on.
### pages/api/
Modules under this route are specially interpreted by Next/Vercel as [functions that will be hosted by
Vercel][vercel-functions]. This is where the public Manifold HTTP API lives.
## Contributing
Please format the code using [Prettier][prettier]; you can run `yarn format` to invoke it manually. It also runs by
default as a pre-commit Git hook thanks to the pretty-quick package. You may wish to use some kind of fancy [editor
integration][prettier-integrations] to format it in your editor.
## Developer Experience TODOs ## Developer Experience TODOs
- Prevent git pushing if there are Typescript errors? - Prevent git pushing if there are Typescript errors?
[react]: https://reactjs.org
[nextjs]: https://nextjs.org
[vercel]: https://vercel.com
[tailwind]: https://tailwindcss.com
[yarn]: https://classic.yarnpkg.com/lang/en/docs/install/
[prettier]: https://prettier.io
[prettier-integrations]: https://prettier.io/docs/en/editors.html
[next-static-files]: https://nextjs.org/docs/basic-features/static-file-serving
[next-pages]: https://nextjs.org/docs/basic-features/pages
[vercel-functions]: https://vercel.com/docs/concepts/functions/serverless-functions
[functions-readme]: https://github.com/manifoldmarkets/manifold/blob/main/functions/README.md

View File

@ -11,6 +11,11 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel' import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel' import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { FeedItems } from '../feed/feed-items'
import { ActivityItem } from '../feed/activity-items'
import { User } from '../../../common/user'
import { getOutcomeProbability } from '../../../common/calculate'
import { Answer } from '../../../common/answer'
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FullContract<DPM, FreeResponse> contract: FullContract<DPM, FreeResponse>
@ -47,6 +52,8 @@ export function AnswersPanel(props: {
const chosenTotal = _.sum(Object.values(chosenAnswers)) const chosenTotal = _.sum(Object.values(chosenAnswers))
const answerItems = getAnswers(contract, user)
const onChoose = (answerId: string, prob: number) => { const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') { if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob }) setChosenAnswers({ [answerId]: prob })
@ -102,6 +109,15 @@ export function AnswersPanel(props: {
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
{!resolveOption && sortedAnswers.length > 0 && (
<FeedItems
contract={contract}
items={answerItems}
className={''}
betRowClassName={''}
/>
)}
{tradingAllowed(contract) && {tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && ( (!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} /> <CreateAnswerPanel contract={contract} />
@ -121,3 +137,32 @@ export function AnswersPanel(props: {
</Col> </Col>
) )
} }
function getAnswers(
contract: FullContract<DPM, FreeResponse>,
user: User | undefined | null
) {
const { answers } = contract
let outcomes = _.uniq(
answers.map((answer) => answer.number.toString())
).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001)
outcomes = _.sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
).reverse()
return outcomes
.map((outcome) => {
const answer = answers.find((answer) => answer.id === outcome) as Answer
//unnecessary
return {
id: outcome,
type: 'answer' as const,
contract,
answer,
items: [] as ActivityItem[],
user,
}
})
.filter((group) => group.answer)
}

View File

@ -21,13 +21,24 @@ export function ContractTabs(props: {
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime) bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
const userBets = user && bets.filter((bet) => bet.userId === user.id) const userBets = user && bets.filter((bet) => bet.userId === user.id)
const activity = ( const betActivity = (
<ContractActivity <ContractActivity
contract={contract} contract={contract}
bets={bets} bets={bets}
comments={comments} comments={comments}
user={user} user={user}
mode="all" mode="bets"
betRowClassName="!mt-0 xl:hidden"
/>
)
const commentActivity = (
<ContractActivity
contract={contract}
bets={bets}
comments={comments}
user={user}
mode="comments"
betRowClassName="!mt-0 xl:hidden" betRowClassName="!mt-0 xl:hidden"
/> />
) )
@ -48,10 +59,11 @@ export function ContractTabs(props: {
return ( return (
<Tabs <Tabs
tabs={[ tabs={[
{ title: 'Timeline', content: activity }, { title: 'Comments', content: commentActivity },
{ title: 'Bets', content: betActivity },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your trades', content: yourTrades }]), : [{ title: 'Your bets', content: yourTrades }]),
]} ]}
/> />
) )

View File

@ -25,15 +25,16 @@ export function ContractsGrid(props: {
showCloseTime?: boolean showCloseTime?: boolean
}) { }) {
const { showCloseTime } = props const { showCloseTime } = props
const PAGE_SIZE = 100
const [page, setPage] = useState(1)
const [resolvedContracts, activeContracts] = _.partition( const [resolvedContracts, activeContracts] = _.partition(
props.contracts, props.contracts,
(c) => c.isResolved (c) => c.isResolved
) )
const contracts = [...activeContracts, ...resolvedContracts].slice( const allContracts = [...activeContracts, ...resolvedContracts]
0, const showMore = allContracts.length > PAGE_SIZE * page
MAX_CONTRACTS_DISPLAYED const contracts = allContracts.slice(0, PAGE_SIZE * page)
)
if (contracts.length === 0) { if (contracts.length === 0) {
return ( return (
@ -47,6 +48,7 @@ export function ContractsGrid(props: {
} }
return ( return (
<>
<ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2"> <ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
{contracts.map((contract) => ( {contracts.map((contract) => (
<ContractCard <ContractCard
@ -57,6 +59,16 @@ export function ContractsGrid(props: {
/> />
))} ))}
</ul> </ul>
{/* Show a link that increases the page num when clicked */}
{showMore && (
<button
className="btn btn-link float-right normal-case"
onClick={() => setPage(page + 1)}
>
Show more...
</button>
)}
</>
) )
} }

View File

@ -31,8 +31,6 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & { export type CommentInputItem = BaseActivityItem & {
type: 'commentInput' type: 'commentInput'
bets: Bet[]
commentsByBetId: Record<string, Comment>
} }
export type DescriptionItem = BaseActivityItem & { export type DescriptionItem = BaseActivityItem & {
@ -68,7 +66,7 @@ export type BetGroupItem = BaseActivityItem & {
} }
export type AnswerGroupItem = BaseActivityItem & { export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup' type: 'answergroup' | 'answer'
answer: Answer answer: Answer
items: ActivityItem[] items: ActivityItem[]
} }
@ -82,6 +80,7 @@ export type ResolveItem = BaseActivityItem & {
} }
const DAY_IN_MS = 24 * 60 * 60 * 1000 const DAY_IN_MS = 24 * 60 * 60 * 1000
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
// Group together bets that are: // Group together bets that are:
// - Within a day of the first in the group // - Within a day of the first in the group
@ -173,7 +172,9 @@ function groupBets(
if (group.length > 0) { if (group.length > 0) {
pushGroup() pushGroup()
} }
const abbrItems = abbreviated ? items.slice(-3) : items const abbrItems = abbreviated
? items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
: items
if (reversed) abbrItems.reverse() if (reversed) abbrItems.reverse()
return abbrItems return abbrItems
} }
@ -240,7 +241,8 @@ function getAnswerGroups(
reversed, reversed,
}) })
if (abbreviated) items = items.slice(-2) if (abbreviated)
items = items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
return { return {
id: outcome, id: outcome,
@ -253,6 +255,8 @@ function getAnswerGroups(
}) })
.filter((group) => group.answer) .filter((group) => group.answer)
if (reversed) answerGroups.reverse()
return answerGroups return answerGroups
} }
@ -268,6 +272,7 @@ function groupBetsAndComments(
reversed: boolean reversed: boolean
} }
) { ) {
const { smallAvatar, abbreviated, reversed } = options
const commentsWithoutBets = comments const commentsWithoutBets = comments
.filter((comment) => !comment.betId) .filter((comment) => !comment.betId)
.map((comment) => ({ .map((comment) => ({
@ -276,16 +281,16 @@ function groupBetsAndComments(
contract: contract, contract: contract,
comment, comment,
bet: undefined, bet: undefined,
truncate: false, truncate: abbreviated,
hideOutcome: true, hideOutcome: true,
smallAvatar: false, smallAvatar,
})) }))
const groupedBets = groupBets(bets, comments, contract, userId, options) const groupedBets = groupBets(bets, comments, contract, userId, options)
// iterate through the bets and comment activity items and add them to the items in order of comment creation time: // iterate through the bets and comment activity items and add them to the items in order of comment creation time:
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets] const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
const sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => { let sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => {
if (item.type === 'comment') { if (item.type === 'comment') {
return item.comment.createdTime return item.comment.createdTime
} else if (item.type === 'bet') { } else if (item.type === 'bet') {
@ -294,7 +299,13 @@ function groupBetsAndComments(
return item.bets[0].createdTime return item.bets[0].createdTime
} }
}) })
return sortedBetsAndComments
const abbrItems = abbreviated
? sortedBetsAndComments.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
: sortedBetsAndComments
if (reversed) abbrItems.reverse()
return abbrItems
} }
export function getAllContractActivityItems( export function getAllContractActivityItems(
@ -308,8 +319,7 @@ export function getAllContractActivityItems(
) { ) {
const { abbreviated } = options const { abbreviated } = options
const { outcomeType } = contract const { outcomeType } = contract
const reversed = true
const reversed = !abbreviated
bets = bets =
outcomeType === 'BINARY' outcomeType === 'BINARY'
@ -328,19 +338,30 @@ export function getAllContractActivityItems(
: [{ type: 'description', id: '0', contract }] : [{ type: 'description', id: '0', contract }]
if (outcomeType === 'FREE_RESPONSE') { if (outcomeType === 'FREE_RESPONSE') {
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
comments.some(
(comment) => comment.betId === bet.id || bet.userId === user?.id
)
)
items.push( items.push(
...getAnswerGroups( ...groupBetsAndComments(
contract as FullContract<DPM, FreeResponse>, onlyUsersBetsOrBetsWithComments,
bets,
comments, comments,
user, contract,
user?.id,
{ {
sortByProb: true, hideOutcome: false,
abbreviated, abbreviated,
smallAvatar: false,
reversed, reversed,
} }
) )
) )
items.push({
type: 'commentInput',
id: 'commentInput',
contract,
})
} else { } else {
items.push( items.push(
...groupBetsAndComments(bets, comments, contract, user?.id, { ...groupBetsAndComments(bets, comments, contract, user?.id, {
@ -359,14 +380,13 @@ export function getAllContractActivityItems(
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract }) items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
} }
const commentsByBetId = mapCommentsByBetId(comments) if (outcomeType === 'BINARY') {
items.push({ items.push({
type: 'commentInput', type: 'commentInput',
id: 'commentInput', id: 'commentInput',
bets,
commentsByBetId,
contract, contract,
}) })
}
if (reversed) items.reverse() if (reversed) items.reverse()
@ -396,9 +416,10 @@ export function getRecentContractActivityItems(
contractPath, contractPath,
} }
const items = const items = []
contract.outcomeType === 'FREE_RESPONSE' if (contract.outcomeType === 'FREE_RESPONSE') {
? getAnswerGroups( items.push(
...getAnswerGroups(
contract as FullContract<DPM, FreeResponse>, contract as FullContract<DPM, FreeResponse>,
bets, bets,
comments, comments,
@ -406,15 +427,84 @@ export function getRecentContractActivityItems(
{ {
sortByProb: false, sortByProb: false,
abbreviated: true, abbreviated: true,
reversed: false, reversed: true,
} }
) )
: groupBetsAndComments(bets, comments, contract, user?.id, { )
} else {
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
comments.some(
(comment) => comment.betId === bet.id || bet.userId === user?.id
)
)
items.push(
...groupBetsAndComments(
onlyUsersBetsOrBetsWithComments,
comments,
contract,
user?.id,
{
hideOutcome: false, hideOutcome: false,
abbreviated: true, abbreviated: true,
smallAvatar: false, smallAvatar: false,
reversed: false, reversed: true,
}) }
)
)
}
return [questionItem, ...items] return [questionItem, ...items]
} }
export function getSpecificContractActivityItems(
contract: Contract,
bets: Bet[],
comments: Comment[],
user: User | null | undefined,
options: {
mode: 'comments' | 'bets'
}
) {
const { mode } = options
let items = [] as ActivityItem[]
switch (mode) {
case 'bets':
items.push(
...groupBets(bets, comments, contract, user?.id, {
hideOutcome: false,
abbreviated: false,
smallAvatar: false,
reversed: false,
})
)
break
case 'comments':
const onlyBetsWithComments = bets.filter((bet) =>
comments.some((comment) => comment.betId === bet.id)
)
items.push(
...groupBetsAndComments(
onlyBetsWithComments,
comments,
contract,
user?.id,
{
hideOutcome: false,
abbreviated: false,
smallAvatar: false,
reversed: false,
}
)
)
items.push({
type: 'commentInput',
id: 'commentInput',
contract,
})
break
}
return items.reverse()
}

View File

@ -1,5 +1,3 @@
import _ from 'lodash'
import { Contract } from '../../lib/firebase/contracts' import { Contract } from '../../lib/firebase/contracts'
import { Comment } from '../../lib/firebase/comments' import { Comment } from '../../lib/firebase/comments'
import { Bet } from '../../../common/bet' import { Bet } from '../../../common/bet'
@ -8,6 +6,7 @@ import { useComments } from '../../hooks/use-comments'
import { import {
getAllContractActivityItems, getAllContractActivityItems,
getRecentContractActivityItems, getRecentContractActivityItems,
getSpecificContractActivityItems,
} from './activity-items' } from './activity-items'
import { FeedItems } from './feed-items' import { FeedItems } from './feed-items'
import { User } from '../../../common/user' import { User } from '../../../common/user'
@ -17,7 +16,7 @@ export function ContractActivity(props: {
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
user: User | null | undefined user: User | null | undefined
mode: 'only-recent' | 'abbreviated' | 'all' mode: 'only-recent' | 'abbreviated' | 'all' | 'comments' | 'bets'
contractPath?: string contractPath?: string
className?: string className?: string
betRowClassName?: string betRowClassName?: string
@ -39,7 +38,12 @@ export function ContractActivity(props: {
? getRecentContractActivityItems(contract, bets, comments, user, { ? getRecentContractActivityItems(contract, bets, comments, user, {
contractPath, contractPath,
}) })
: getAllContractActivityItems(contract, bets, comments, user, { : mode === 'comments' || mode === 'bets'
? getSpecificContractActivityItems(contract, bets, comments, user, {
mode,
})
: // only used in abbreviated mode with folds/communities, all mode isn't used
getAllContractActivityItems(contract, bets, comments, user, {
abbreviated: mode === 'abbreviated', abbreviated: mode === 'abbreviated',
}) })

View File

@ -1,5 +1,5 @@
// From https://tailwindui.com/components/application-ui/lists/feeds // From https://tailwindui.com/components/application-ui/lists/feeds
import { Fragment, useRef, useState } from 'react' import React, { Fragment, useRef, useState } from 'react'
import * as _ from 'lodash' import * as _ from 'lodash'
import { import {
BanIcon, BanIcon,
@ -67,7 +67,12 @@ export function FeedItems(props: {
<div className={clsx('flow-root pr-2 md:pr-0', className)} ref={ref}> <div className={clsx('flow-root pr-2 md:pr-0', className)} ref={ref}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}> <div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{items.map((item, activityItemIdx) => ( {items.map((item, activityItemIdx) => (
<div key={item.id} className="relative pb-6"> <div
key={item.id}
className={
item.type === 'answer' ? 'relative pb-2' : 'relative pb-6'
}
>
{activityItemIdx !== items.length - 1 || {activityItemIdx !== items.length - 1 ||
item.type === 'answergroup' ? ( item.type === 'answergroup' ? (
<span <span
@ -104,6 +109,8 @@ function FeedItem(props: { item: ActivityItem }) {
return <FeedBetGroup {...item} /> return <FeedBetGroup {...item} />
case 'answergroup': case 'answergroup':
return <FeedAnswerGroup {...item} /> return <FeedAnswerGroup {...item} />
case 'answer':
return <FeedAnswerGroup {...item} />
case 'close': case 'close':
return <FeedClose {...item} /> return <FeedClose {...item} />
case 'resolve': case 'resolve':
@ -184,35 +191,12 @@ function RelativeTimestamp(props: { time: number }) {
) )
} }
export function CommentInput(props: { export function CommentInput(props: { contract: Contract }) {
contract: Contract
commentsByBetId: Record<string, Comment>
bets: Bet[]
}) {
// see if we can comment input on any bet: // see if we can comment input on any bet:
const { contract, bets, commentsByBetId } = props const { contract } = props
const { outcomeType } = contract
const user = useUser() const user = useUser()
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
if (outcomeType === 'FREE_RESPONSE') {
return <div />
}
let canCommentOnABet = false
bets.some((bet) => {
// make sure there is not already a comment with a matching bet id:
const matchingComment = commentsByBetId[bet.id]
if (matchingComment) {
return false
}
const { createdTime, userId } = bet
canCommentOnABet = canCommentOnBet(userId, createdTime, user)
return canCommentOnABet
})
if (canCommentOnABet) return <div />
async function submitComment() { async function submitComment() {
if (!comment) return if (!comment) return
if (!user) { if (!user) {
@ -224,6 +208,7 @@ export function CommentInput(props: {
return ( return (
<> <>
<Row className={'flex w-full gap-2 pt-5'}>
<div> <div>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} /> <Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div> </div>
@ -244,14 +229,17 @@ export function CommentInput(props: {
}} }}
/> />
<button <button
className="btn btn-outline btn-sm mt-1" className={
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
}
onClick={submitComment} onClick={submitComment}
> >
Comment {user ? 'Comment' : 'Sign in to comment'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</Row>
</> </>
) )
} }
@ -335,7 +323,7 @@ export function FeedBet(props: {
}} }}
/> />
<button <button
className="btn btn-outline btn-sm mt-1" className="btn btn-outline btn-sm text-transform: mt-1 capitalize"
onClick={submitComment} onClick={submitComment}
disabled={!canComment} disabled={!canComment}
> >
@ -644,8 +632,9 @@ function FeedAnswerGroup(props: {
contract: FullContract<any, FreeResponse> contract: FullContract<any, FreeResponse>
answer: Answer answer: Answer
items: ActivityItem[] items: ActivityItem[]
type: string
}) { }) {
const { answer, items, contract } = props const { answer, items, contract, type } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
@ -653,7 +642,13 @@ function FeedAnswerGroup(props: {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
<Col className="flex-1 gap-2"> <Col
className={
type === 'answer'
? 'border-base-200 bg-base-200 flex-1 rounded-md p-3'
: 'flex-1 gap-2'
}
>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen}>
<AnswerBetPanel <AnswerBetPanel
answer={answer} answer={answer}

View File

@ -27,6 +27,7 @@ export function OutcomeLabel(props: {
contract={contract as FullContract<DPM, FreeResponse>} contract={contract as FullContract<DPM, FreeResponse>}
resolution={outcome} resolution={outcome}
truncate={truncate} truncate={truncate}
answerClassName={'font-bold text-base-400'}
/> />
) )
} }

View File

@ -4,8 +4,11 @@ export function ProbabilitySelector(props: {
probabilityInt: number probabilityInt: number
setProbabilityInt: (p: number) => void setProbabilityInt: (p: number) => void
isSubmitting?: boolean isSubmitting?: boolean
minProb?: number
maxProb?: number
}) { }) {
const { probabilityInt, setProbabilityInt, isSubmitting } = props const { probabilityInt, setProbabilityInt, isSubmitting, minProb, maxProb } =
props
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
@ -15,19 +18,28 @@ export function ProbabilitySelector(props: {
value={probabilityInt} value={probabilityInt}
className="input input-bordered input-md text-lg" className="input input-bordered input-md text-lg"
disabled={isSubmitting} disabled={isSubmitting}
min={1} min={minProb ?? 1}
max={99} max={maxProb ?? 99}
onChange={(e) => onChange={(e) =>
setProbabilityInt(parseInt(e.target.value.substring(0, 2))) setProbabilityInt(parseInt(e.target.value.substring(0, 2)))
} }
onBlur={() =>
setProbabilityInt(
maxProb && probabilityInt > maxProb
? maxProb
: minProb && probabilityInt < minProb
? minProb
: probabilityInt
)
}
/> />
<span>%</span> <span>%</span>
</label> </label>
<input <input
type="range" type="range"
className="range range-primary" className="range range-primary"
min={1} min={minProb ?? 1}
max={99} max={maxProb ?? 99}
value={probabilityInt} value={probabilityInt}
onChange={(e) => setProbabilityInt(parseInt(e.target.value))} onChange={(e) => setProbabilityInt(parseInt(e.target.value))}
/> />

View File

@ -151,6 +151,10 @@ function getContractsActivityScores(
const activityCountScore = const activityCountScore =
0.5 + 0.5 * logInterpolation(0, 200, activtyCount) 0.5 + 0.5 * logInterpolation(0, 200, activtyCount)
const { volume7Days, volume } = contract
const combinedVolume = Math.log(volume7Days + 1) + Math.log(volume + 1)
const volumeScore = 0.5 + 0.5 * logInterpolation(4, 25, combinedVolume)
const lastBetTime = const lastBetTime =
contractMostRecentBet[contract.id]?.createdTime ?? contract.createdTime contractMostRecentBet[contract.id]?.createdTime ?? contract.createdTime
const timeSinceLastBet = Date.now() - lastBetTime const timeSinceLastBet = Date.now() - lastBetTime
@ -169,7 +173,11 @@ function getContractsActivityScores(
const probScore = 0.5 + frac * 0.5 const probScore = 0.5 + frac * 0.5
const score = const score =
newCommentScore * activityCountScore * timeAgoScore * probScore newCommentScore *
activityCountScore *
volumeScore *
timeAgoScore *
probScore
// Map score to [0.5, 1] since no recent activty is not a deal breaker. // Map score to [0.5, 1] since no recent activty is not a deal breaker.
const mappedScore = 0.5 + score / 2 const mappedScore = 0.5 + score / 2

View File

@ -209,7 +209,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
return users && users.length > 0 ? ( return users && users.length > 0 ? (
<Leaderboard <Leaderboard
title="🏅 Top traders" title="🏅 Top bettors"
users={users || []} users={users || []}
columns={[ columns={[
{ {

View File

@ -106,11 +106,14 @@ export function NewContract(props: { question: string; tag?: string }) {
setIsSubmitting(true) setIsSubmitting(true)
const boundedProb =
initialProb > 90 ? 90 : initialProb < 10 ? 10 : initialProb
const result: any = await createContract({ const result: any = await createContract({
question, question,
outcomeType, outcomeType,
description, description,
initialProb, initialProb: boundedProb,
ante, ante,
closeTime, closeTime,
tags, tags,
@ -172,6 +175,8 @@ export function NewContract(props: { question: string; tag?: string }) {
<ProbabilitySelector <ProbabilitySelector
probabilityInt={initialProb} probabilityInt={initialProb}
setProbabilityInt={setInitialProb} setProbabilityInt={setInitialProb}
minProb={10}
maxProb={90}
/> />
</div> </div>
)} )}

View File

@ -363,7 +363,7 @@ function FoldLeaderboards(props: {
<> <>
<Leaderboard <Leaderboard
className="max-w-xl" className="max-w-xl"
title="🏅 Top traders" title="🏅 Top bettors"
users={topTraders} users={topTraders}
columns={[ columns={[
{ {

View File

@ -38,7 +38,7 @@ export default function Leaderboards(props: {
<Page margin> <Page margin>
<Col className="items-center gap-10 lg:flex-row"> <Col className="items-center gap-10 lg:flex-row">
<Leaderboard <Leaderboard
title="🏅 Top traders" title="🏅 Top bettors"
users={topTraders} users={topTraders}
columns={[ columns={[
{ {

View File

@ -1,20 +1,26 @@
import _ from 'lodash'
import { GetServerSideProps } from 'next' import { GetServerSideProps } from 'next'
import { getServerSideSitemap } from 'next-sitemap' import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
import { DOMAIN } from '../../common/envs/constants' import { DOMAIN } from '../../common/envs/constants'
import { LiteMarket } from './api/v0/_types'
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
// Fetching data from https://docs.manifold.markets/api // Fetching data from https://manifold.markets/api
const response = await fetch(`https://${DOMAIN}/api/v0/markets`) const response = await fetch(`https://${DOMAIN}/api/v0/markets`)
const liteMarkets = await response.json() const liteMarkets = (await response.json()) as LiteMarket[]
const fields = liteMarkets.map((liteMarket: any) => ({ const sortedMarkets = _.sortBy(liteMarkets, (m) => -m.volume24Hours)
const fields = sortedMarkets.map((market) => ({
// See https://www.sitemaps.org/protocol.html // See https://www.sitemaps.org/protocol.html
loc: liteMarket.url, loc: market.url,
changefreq: 'hourly', changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
priority: 0.2, // Individual markets aren't that important priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1,
// TODO: Add `lastmod` aka last modified time // TODO: Add `lastmod` aka last modified time
})) })) as ISitemapField[]
return getServerSideSitemap(ctx, fields)
return await getServerSideSitemap(ctx, fields)
} }
// Default export to prevent next.js errors // Default export to prevent next.js errors

View File

@ -6,5 +6,5 @@ Allow: /
Host: https://manifold.markets Host: https://manifold.markets
# Sitemaps # Sitemaps
Sitemap: https://manifold.markets/sitemap.xml
Sitemap: https://manifold.markets/server-sitemap.xml Sitemap: https://manifold.markets/server-sitemap.xml
Sitemap: https://manifold.markets/sitemap.xml

View File

@ -1,19 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url> <url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
<url><loc>https://manifold.markets/about</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url> <url><loc>https://manifold.markets/markets</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/account</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url> <url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
<url><loc>https://manifold.markets/add-funds</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/analytics</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/create</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/embed/analytics</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/folds</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/landing-page</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/make-predictions</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/markets</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/profile</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/simulator</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
<url><loc>https://manifold.markets/portfolio</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></url>
</urlset> </urlset>