Merge branch 'main' into CPM-ui
This commit is contained in:
commit
d55cf571c5
53
README.md
53
README.md
|
@ -1,14 +1,51 @@
|
|||
# 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
|
||||
|
||||
- `web/`: UI and biz logic. Where most of the site lives
|
||||
- `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
|
||||
- `web/`: UI and business logic for the client. Where most of the site lives. The public API endpoints are also in here.
|
||||
|
||||
## 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
|
||||
- [Monorepo](https://semaphoreci.com/blog/what-is-monorepo): Good fit for our current size
|
||||
- [Small PRs](https://google.github.io/eng-practices/review/developer/small-cls.html): Lots of little changes > one big diff
|
||||
- `common/`: Typescript library code shared between `web/` & `functions/`. If you want to look at how the market math
|
||||
works, most of that's in here (it gets called from the `placeBet` and `sellBet` endpoints in `functions/`.) Also
|
||||
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
|
||||
|
|
|
@ -39,16 +39,24 @@ service cloud.firestore {
|
|||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['description', 'closeTime', 'tags', 'lowercaseTags']);
|
||||
allow update: if isAdmin();
|
||||
allow delete: if resource.data.creatorId == request.auth.uid;
|
||||
}
|
||||
|
||||
match /{somePath=**}/bets/{betId} {
|
||||
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} {
|
||||
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} {
|
||||
|
@ -57,12 +65,16 @@ service cloud.firestore {
|
|||
|
||||
match /folds/{foldId} {
|
||||
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} {
|
||||
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} {
|
||||
|
|
|
@ -34,9 +34,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
|
||||
## Developing locally
|
||||
|
||||
0. `$ firebase use dev` if you haven't already
|
||||
1. `$ yarn serve` to spin up the emulators
|
||||
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 =(
|
||||
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 =(
|
||||
2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend
|
||||
1. Note: emulated database is cleared after every shutdown
|
||||
|
||||
|
|
|
@ -48,8 +48,8 @@ export const sendMarketResolutionEmail = async (
|
|||
creatorName: creator.name,
|
||||
question: contract.question,
|
||||
outcome,
|
||||
investment: `${Math.round(investment)}`,
|
||||
payout: `${Math.round(payout)}`,
|
||||
investment: `${Math.floor(investment)}`,
|
||||
payout: `${Math.floor(payout)}`,
|
||||
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
||||
}
|
||||
|
||||
|
@ -189,7 +189,9 @@ export const sendNewCommentEmail = async (
|
|||
let betDescription = ''
|
||||
if (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}`
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
||||
(`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
|
||||
2. `yarn dev:emulate` will point you to the emulated database
|
||||
Manifold's website uses [Next.js][nextjs], which is a [React][react]-based framework that handles concerns like routing,
|
||||
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
|
||||
|
||||
- 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
|
||||
|
|
|
@ -11,6 +11,11 @@ import { AnswerItem } from './answer-item'
|
|||
import { CreateAnswerPanel } from './create-answer-panel'
|
||||
import { AnswerResolvePanel } from './answer-resolve-panel'
|
||||
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: {
|
||||
contract: FullContract<DPM, FreeResponse>
|
||||
|
@ -47,6 +52,8 @@ export function AnswersPanel(props: {
|
|||
|
||||
const chosenTotal = _.sum(Object.values(chosenAnswers))
|
||||
|
||||
const answerItems = getAnswers(contract, user)
|
||||
|
||||
const onChoose = (answerId: string, prob: number) => {
|
||||
if (resolveOption === 'CHOOSE') {
|
||||
setChosenAnswers({ [answerId]: prob })
|
||||
|
@ -102,6 +109,15 @@ export function AnswersPanel(props: {
|
|||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||
)}
|
||||
|
||||
{!resolveOption && sortedAnswers.length > 0 && (
|
||||
<FeedItems
|
||||
contract={contract}
|
||||
items={answerItems}
|
||||
className={''}
|
||||
betRowClassName={''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tradingAllowed(contract) &&
|
||||
(!resolveOption || resolveOption === 'CANCEL') && (
|
||||
<CreateAnswerPanel contract={contract} />
|
||||
|
@ -121,3 +137,32 @@ export function AnswersPanel(props: {
|
|||
</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)
|
||||
}
|
||||
|
|
|
@ -21,13 +21,24 @@ export function ContractTabs(props: {
|
|||
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
|
||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||
|
||||
const activity = (
|
||||
const betActivity = (
|
||||
<ContractActivity
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
comments={comments}
|
||||
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"
|
||||
/>
|
||||
)
|
||||
|
@ -48,10 +59,11 @@ export function ContractTabs(props: {
|
|||
return (
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ title: 'Timeline', content: activity },
|
||||
{ title: 'Comments', content: commentActivity },
|
||||
{ title: 'Bets', content: betActivity },
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your trades', content: yourTrades }]),
|
||||
: [{ title: 'Your bets', content: yourTrades }]),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -25,15 +25,16 @@ export function ContractsGrid(props: {
|
|||
showCloseTime?: boolean
|
||||
}) {
|
||||
const { showCloseTime } = props
|
||||
const PAGE_SIZE = 100
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [resolvedContracts, activeContracts] = _.partition(
|
||||
props.contracts,
|
||||
(c) => c.isResolved
|
||||
)
|
||||
const contracts = [...activeContracts, ...resolvedContracts].slice(
|
||||
0,
|
||||
MAX_CONTRACTS_DISPLAYED
|
||||
)
|
||||
const allContracts = [...activeContracts, ...resolvedContracts]
|
||||
const showMore = allContracts.length > PAGE_SIZE * page
|
||||
const contracts = allContracts.slice(0, PAGE_SIZE * page)
|
||||
|
||||
if (contracts.length === 0) {
|
||||
return (
|
||||
|
@ -47,16 +48,27 @@ export function ContractsGrid(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{contracts.map((contract) => (
|
||||
<ContractCard
|
||||
contract={contract}
|
||||
key={contract.id}
|
||||
// showHotVolume={showHotVolume}
|
||||
showCloseTime={showCloseTime}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{contracts.map((contract) => (
|
||||
<ContractCard
|
||||
contract={contract}
|
||||
key={contract.id}
|
||||
// showHotVolume={showHotVolume}
|
||||
showCloseTime={showCloseTime}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -31,8 +31,6 @@ type BaseActivityItem = {
|
|||
|
||||
export type CommentInputItem = BaseActivityItem & {
|
||||
type: 'commentInput'
|
||||
bets: Bet[]
|
||||
commentsByBetId: Record<string, Comment>
|
||||
}
|
||||
|
||||
export type DescriptionItem = BaseActivityItem & {
|
||||
|
@ -68,7 +66,7 @@ export type BetGroupItem = BaseActivityItem & {
|
|||
}
|
||||
|
||||
export type AnswerGroupItem = BaseActivityItem & {
|
||||
type: 'answergroup'
|
||||
type: 'answergroup' | 'answer'
|
||||
answer: Answer
|
||||
items: ActivityItem[]
|
||||
}
|
||||
|
@ -82,6 +80,7 @@ export type ResolveItem = BaseActivityItem & {
|
|||
}
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
|
||||
|
||||
// Group together bets that are:
|
||||
// - Within a day of the first in the group
|
||||
|
@ -173,7 +172,9 @@ function groupBets(
|
|||
if (group.length > 0) {
|
||||
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()
|
||||
return abbrItems
|
||||
}
|
||||
|
@ -240,7 +241,8 @@ function getAnswerGroups(
|
|||
reversed,
|
||||
})
|
||||
|
||||
if (abbreviated) items = items.slice(-2)
|
||||
if (abbreviated)
|
||||
items = items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
|
||||
|
||||
return {
|
||||
id: outcome,
|
||||
|
@ -253,6 +255,8 @@ function getAnswerGroups(
|
|||
})
|
||||
.filter((group) => group.answer)
|
||||
|
||||
if (reversed) answerGroups.reverse()
|
||||
|
||||
return answerGroups
|
||||
}
|
||||
|
||||
|
@ -268,6 +272,7 @@ function groupBetsAndComments(
|
|||
reversed: boolean
|
||||
}
|
||||
) {
|
||||
const { smallAvatar, abbreviated, reversed } = options
|
||||
const commentsWithoutBets = comments
|
||||
.filter((comment) => !comment.betId)
|
||||
.map((comment) => ({
|
||||
|
@ -276,16 +281,16 @@ function groupBetsAndComments(
|
|||
contract: contract,
|
||||
comment,
|
||||
bet: undefined,
|
||||
truncate: false,
|
||||
truncate: abbreviated,
|
||||
hideOutcome: true,
|
||||
smallAvatar: false,
|
||||
smallAvatar,
|
||||
}))
|
||||
|
||||
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:
|
||||
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
|
||||
const sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => {
|
||||
let sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => {
|
||||
if (item.type === 'comment') {
|
||||
return item.comment.createdTime
|
||||
} else if (item.type === 'bet') {
|
||||
|
@ -294,7 +299,13 @@ function groupBetsAndComments(
|
|||
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(
|
||||
|
@ -308,8 +319,7 @@ export function getAllContractActivityItems(
|
|||
) {
|
||||
const { abbreviated } = options
|
||||
const { outcomeType } = contract
|
||||
|
||||
const reversed = !abbreviated
|
||||
const reversed = true
|
||||
|
||||
bets =
|
||||
outcomeType === 'BINARY'
|
||||
|
@ -328,19 +338,30 @@ export function getAllContractActivityItems(
|
|||
: [{ type: 'description', id: '0', contract }]
|
||||
|
||||
if (outcomeType === 'FREE_RESPONSE') {
|
||||
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
|
||||
comments.some(
|
||||
(comment) => comment.betId === bet.id || bet.userId === user?.id
|
||||
)
|
||||
)
|
||||
items.push(
|
||||
...getAnswerGroups(
|
||||
contract as FullContract<DPM, FreeResponse>,
|
||||
bets,
|
||||
...groupBetsAndComments(
|
||||
onlyUsersBetsOrBetsWithComments,
|
||||
comments,
|
||||
user,
|
||||
contract,
|
||||
user?.id,
|
||||
{
|
||||
sortByProb: true,
|
||||
hideOutcome: false,
|
||||
abbreviated,
|
||||
smallAvatar: false,
|
||||
reversed,
|
||||
}
|
||||
)
|
||||
)
|
||||
items.push({
|
||||
type: 'commentInput',
|
||||
id: 'commentInput',
|
||||
contract,
|
||||
})
|
||||
} else {
|
||||
items.push(
|
||||
...groupBetsAndComments(bets, comments, contract, user?.id, {
|
||||
|
@ -359,14 +380,13 @@ export function getAllContractActivityItems(
|
|||
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
|
||||
}
|
||||
|
||||
const commentsByBetId = mapCommentsByBetId(comments)
|
||||
items.push({
|
||||
type: 'commentInput',
|
||||
id: 'commentInput',
|
||||
bets,
|
||||
commentsByBetId,
|
||||
contract,
|
||||
})
|
||||
if (outcomeType === 'BINARY') {
|
||||
items.push({
|
||||
type: 'commentInput',
|
||||
id: 'commentInput',
|
||||
contract,
|
||||
})
|
||||
}
|
||||
|
||||
if (reversed) items.reverse()
|
||||
|
||||
|
@ -396,25 +416,95 @@ export function getRecentContractActivityItems(
|
|||
contractPath,
|
||||
}
|
||||
|
||||
const items =
|
||||
contract.outcomeType === 'FREE_RESPONSE'
|
||||
? getAnswerGroups(
|
||||
contract as FullContract<DPM, FreeResponse>,
|
||||
bets,
|
||||
comments,
|
||||
user,
|
||||
{
|
||||
sortByProb: false,
|
||||
abbreviated: true,
|
||||
reversed: false,
|
||||
}
|
||||
)
|
||||
: groupBetsAndComments(bets, comments, contract, user?.id, {
|
||||
const items = []
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
items.push(
|
||||
...getAnswerGroups(
|
||||
contract as FullContract<DPM, FreeResponse>,
|
||||
bets,
|
||||
comments,
|
||||
user,
|
||||
{
|
||||
sortByProb: false,
|
||||
abbreviated: true,
|
||||
reversed: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
} 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,
|
||||
abbreviated: true,
|
||||
smallAvatar: false,
|
||||
reversed: false,
|
||||
})
|
||||
reversed: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import _ from 'lodash'
|
||||
|
||||
import { Contract } from '../../lib/firebase/contracts'
|
||||
import { Comment } from '../../lib/firebase/comments'
|
||||
import { Bet } from '../../../common/bet'
|
||||
|
@ -8,6 +6,7 @@ import { useComments } from '../../hooks/use-comments'
|
|||
import {
|
||||
getAllContractActivityItems,
|
||||
getRecentContractActivityItems,
|
||||
getSpecificContractActivityItems,
|
||||
} from './activity-items'
|
||||
import { FeedItems } from './feed-items'
|
||||
import { User } from '../../../common/user'
|
||||
|
@ -17,7 +16,7 @@ export function ContractActivity(props: {
|
|||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
user: User | null | undefined
|
||||
mode: 'only-recent' | 'abbreviated' | 'all'
|
||||
mode: 'only-recent' | 'abbreviated' | 'all' | 'comments' | 'bets'
|
||||
contractPath?: string
|
||||
className?: string
|
||||
betRowClassName?: string
|
||||
|
@ -39,7 +38,12 @@ export function ContractActivity(props: {
|
|||
? getRecentContractActivityItems(contract, bets, comments, user, {
|
||||
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',
|
||||
})
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// 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 {
|
||||
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(tradingAllowed(contract) ? '' : '-mb-6')}>
|
||||
{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 ||
|
||||
item.type === 'answergroup' ? (
|
||||
<span
|
||||
|
@ -104,6 +109,8 @@ function FeedItem(props: { item: ActivityItem }) {
|
|||
return <FeedBetGroup {...item} />
|
||||
case 'answergroup':
|
||||
return <FeedAnswerGroup {...item} />
|
||||
case 'answer':
|
||||
return <FeedAnswerGroup {...item} />
|
||||
case 'close':
|
||||
return <FeedClose {...item} />
|
||||
case 'resolve':
|
||||
|
@ -184,35 +191,12 @@ function RelativeTimestamp(props: { time: number }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function CommentInput(props: {
|
||||
contract: Contract
|
||||
commentsByBetId: Record<string, Comment>
|
||||
bets: Bet[]
|
||||
}) {
|
||||
export function CommentInput(props: { contract: Contract }) {
|
||||
// see if we can comment input on any bet:
|
||||
const { contract, bets, commentsByBetId } = props
|
||||
const { outcomeType } = contract
|
||||
const { contract } = props
|
||||
const user = useUser()
|
||||
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() {
|
||||
if (!comment) return
|
||||
if (!user) {
|
||||
|
@ -224,34 +208,38 @@ export function CommentInput(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="mt-2">
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className="textarea textarea-bordered w-full resize-none"
|
||||
placeholder="Add a comment..."
|
||||
rows={3}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
submitComment()
|
||||
<Row className={'flex w-full gap-2 pt-5'}>
|
||||
<div>
|
||||
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="mt-2">
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className="textarea textarea-bordered w-full resize-none"
|
||||
placeholder="Add a comment..."
|
||||
rows={3}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
submitComment()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className={
|
||||
'btn btn-outline btn-sm text-transform: mt-1 capitalize'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline btn-sm mt-1"
|
||||
onClick={submitComment}
|
||||
>
|
||||
Comment
|
||||
</button>
|
||||
onClick={submitComment}
|
||||
>
|
||||
{user ? 'Comment' : 'Sign in to comment'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -335,7 +323,7 @@ export function FeedBet(props: {
|
|||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline btn-sm mt-1"
|
||||
className="btn btn-outline btn-sm text-transform: mt-1 capitalize"
|
||||
onClick={submitComment}
|
||||
disabled={!canComment}
|
||||
>
|
||||
|
@ -644,8 +632,9 @@ function FeedAnswerGroup(props: {
|
|||
contract: FullContract<any, FreeResponse>
|
||||
answer: Answer
|
||||
items: ActivityItem[]
|
||||
type: string
|
||||
}) {
|
||||
const { answer, items, contract } = props
|
||||
const { answer, items, contract, type } = props
|
||||
const { username, avatarUrl, name, text } = answer
|
||||
|
||||
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
||||
|
@ -653,7 +642,13 @@ function FeedAnswerGroup(props: {
|
|||
const [open, setOpen] = useState(false)
|
||||
|
||||
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}>
|
||||
<AnswerBetPanel
|
||||
answer={answer}
|
||||
|
|
|
@ -27,6 +27,7 @@ export function OutcomeLabel(props: {
|
|||
contract={contract as FullContract<DPM, FreeResponse>}
|
||||
resolution={outcome}
|
||||
truncate={truncate}
|
||||
answerClassName={'font-bold text-base-400'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,8 +4,11 @@ export function ProbabilitySelector(props: {
|
|||
probabilityInt: number
|
||||
setProbabilityInt: (p: number) => void
|
||||
isSubmitting?: boolean
|
||||
minProb?: number
|
||||
maxProb?: number
|
||||
}) {
|
||||
const { probabilityInt, setProbabilityInt, isSubmitting } = props
|
||||
const { probabilityInt, setProbabilityInt, isSubmitting, minProb, maxProb } =
|
||||
props
|
||||
|
||||
return (
|
||||
<Row className="items-center gap-2">
|
||||
|
@ -15,19 +18,28 @@ export function ProbabilitySelector(props: {
|
|||
value={probabilityInt}
|
||||
className="input input-bordered input-md text-lg"
|
||||
disabled={isSubmitting}
|
||||
min={1}
|
||||
max={99}
|
||||
min={minProb ?? 1}
|
||||
max={maxProb ?? 99}
|
||||
onChange={(e) =>
|
||||
setProbabilityInt(parseInt(e.target.value.substring(0, 2)))
|
||||
}
|
||||
onBlur={() =>
|
||||
setProbabilityInt(
|
||||
maxProb && probabilityInt > maxProb
|
||||
? maxProb
|
||||
: minProb && probabilityInt < minProb
|
||||
? minProb
|
||||
: probabilityInt
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
className="range range-primary"
|
||||
min={1}
|
||||
max={99}
|
||||
min={minProb ?? 1}
|
||||
max={maxProb ?? 99}
|
||||
value={probabilityInt}
|
||||
onChange={(e) => setProbabilityInt(parseInt(e.target.value))}
|
||||
/>
|
||||
|
|
|
@ -151,6 +151,10 @@ function getContractsActivityScores(
|
|||
const activityCountScore =
|
||||
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 =
|
||||
contractMostRecentBet[contract.id]?.createdTime ?? contract.createdTime
|
||||
const timeSinceLastBet = Date.now() - lastBetTime
|
||||
|
@ -169,7 +173,11 @@ function getContractsActivityScores(
|
|||
const probScore = 0.5 + frac * 0.5
|
||||
|
||||
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.
|
||||
const mappedScore = 0.5 + score / 2
|
||||
|
|
|
@ -209,7 +209,7 @@ function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top traders"
|
||||
title="🏅 Top bettors"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
|
|
|
@ -106,11 +106,14 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const boundedProb =
|
||||
initialProb > 90 ? 90 : initialProb < 10 ? 10 : initialProb
|
||||
|
||||
const result: any = await createContract({
|
||||
question,
|
||||
outcomeType,
|
||||
description,
|
||||
initialProb,
|
||||
initialProb: boundedProb,
|
||||
ante,
|
||||
closeTime,
|
||||
tags,
|
||||
|
@ -172,6 +175,8 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
<ProbabilitySelector
|
||||
probabilityInt={initialProb}
|
||||
setProbabilityInt={setInitialProb}
|
||||
minProb={10}
|
||||
maxProb={90}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -363,7 +363,7 @@ function FoldLeaderboards(props: {
|
|||
<>
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top traders"
|
||||
title="🏅 Top bettors"
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
|
|
|
@ -38,7 +38,7 @@ export default function Leaderboards(props: {
|
|||
<Page margin>
|
||||
<Col className="items-center gap-10 lg:flex-row">
|
||||
<Leaderboard
|
||||
title="🏅 Top traders"
|
||||
title="🏅 Top bettors"
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
import _ from 'lodash'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { getServerSideSitemap } from 'next-sitemap'
|
||||
import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
|
||||
|
||||
import { DOMAIN } from '../../common/envs/constants'
|
||||
import { LiteMarket } from './api/v0/_types'
|
||||
|
||||
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 liteMarkets = await response.json()
|
||||
const fields = liteMarkets.map((liteMarket: any) => ({
|
||||
const liteMarkets = (await response.json()) as LiteMarket[]
|
||||
const sortedMarkets = _.sortBy(liteMarkets, (m) => -m.volume24Hours)
|
||||
|
||||
const fields = sortedMarkets.map((market) => ({
|
||||
// See https://www.sitemaps.org/protocol.html
|
||||
loc: liteMarket.url,
|
||||
changefreq: 'hourly',
|
||||
priority: 0.2, // Individual markets aren't that important
|
||||
loc: market.url,
|
||||
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
|
||||
priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1,
|
||||
// TODO: Add `lastmod` aka last modified time
|
||||
}))
|
||||
return getServerSideSitemap(ctx, fields)
|
||||
})) as ISitemapField[]
|
||||
|
||||
return await getServerSideSitemap(ctx, fields)
|
||||
}
|
||||
|
||||
// Default export to prevent next.js errors
|
||||
|
|
|
@ -6,5 +6,5 @@ Allow: /
|
|||
Host: https://manifold.markets
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://manifold.markets/sitemap.xml
|
||||
Sitemap: https://manifold.markets/server-sitemap.xml
|
||||
Sitemap: https://manifold.markets/sitemap.xml
|
||||
|
|
|
@ -1,19 +1,6 @@
|
|||
<?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">
|
||||
<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/about</loc><changefreq>hourly</changefreq><priority>0.7</priority><lastmod>2022-03-24T16:51:19.526Z</lastmod></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/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>
|
||||
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://manifold.markets/markets</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
||||
</urlset>
|
Loading…
Reference in New Issue
Block a user