Merge branch 'main' into server-feed

This commit is contained in:
James Grugett 2022-04-29 17:19:51 -04:00
commit 10e5916dc6
26 changed files with 581 additions and 272 deletions

27
.github/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1,27 @@
# Manifold CLA
**Manifold Markets Contributor License Agreement**
(Thanks to [Beeminder](http://bmndr.co/cla) and [Discourse.org](https://cla-assistant.io/discourse/discourse) whose CLAs we modeled this on!)
## Unofficial Summary
- Manifold can use your contributions
- Manifold can sell things involving your contributions
- Youre legally able to agree to the above
- Youre the one who created these contributions
- Manifold decides what gets included in Manifold
- Manifold does not promise any support
## Official Agreement
The document below clarifies the terms under which You (the copyright owner or legal entity authorized by the copyright owner), may make "The Contributions" (software, bug fixes, configuration changes, documentation, or any other materials) to "The Work" (Manifold Markets). This license protects You, "The Company" (Manifold Markets, Inc.) and licensees; it does not change your rights to use your own contributions for any other purpose.
You and "The Company" (Manifold Markets, Inc.) agree:
- You grant to "The Company" (Manifold Markets, Inc.) a non-exclusive, irrevocable, worldwide, royalty-free, sublicenseable, relicenseable, transferable license under all of Your relevant intellectual property rights, to use, copy, prepare derivative works of, distribute and publicly perform and display "The Contributions" on any licensing terms, including without limitation: (a) open source licenses like the GNU General Public (v2.0) license; and (b) binary, proprietary, or commercial licenses. Except for the licenses granted herein, You reserve all right, title, and interest in and to "The Contributions".
- You grant to "The Company" a non-exclusive, irrevocable (except as stated in this section), worldwide, royalty-free, sublicenseable, transferable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer "The Work", where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with "The Work" to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or "The Work" to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
- You are able to grant us these rights. You represent that You are legally entitled to grant the above license(s). If Your employer has rights to intellectual property that You create, You represent that You have received permission to make "The Contributions" on behalf of that employer, or that Your employer has waived such rights for "The Contributions".
- "The Contributions" are your original work. You represent that "The Contributions" are Your original works of authorship, and to Your knowledge, no other person claims, or has the right to claim, any right in any invention or patent related to "The Contributions". You also represent that You are not legally obligated, whether by entering into an agreement or otherwise, in any way that conflicts with the terms of this license. For example, if you have signed an agreement requiring you to assign the intellectual property rights in "The Contributions" to an employer or customer, that would conflict with the terms of this license.
- We, as authoritative representatives of "The Company" determine the code that is in "The Work". You understand that the decision to include "The Contribution(s)" in any project or source repository is entirely that of "The Company", and this agreement does not guarantee that "The Contributions" will be included in any product.
- No Implied Warranties. "The Company" acknowledges that, except as explicitly described in this Agreement, the Contribution is provided on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.

View File

@ -25,8 +25,9 @@ Operations with complicated contracts (e.g. buying shares) are provided in a sep
- `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.
- `common/`: Typescript library code shared between `web/` & `functions/`. Also contains in `common/envs` configuration for
the different environments (i.e. prod, dev, Manifold for Teams instances.)
- `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.
@ -34,7 +35,11 @@ Also: Our docs are currently in [a separate repo](https://github.com/manifoldmar
## 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. 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.
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.
By contributing to this codebase, you are agreeing to the terms of the [Manifold CLA](https://github.com/manifoldmarkets/manifold/blob/main/.github/CONTRIBUTING.md).
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/
@ -45,3 +50,4 @@ Since we are just now open-sourcing things, we will see how things go. Feel free
[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

@ -5,14 +5,16 @@ import { User } from './user'
import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees'
export const FIXED_ANTE = 50
export const FIXED_ANTE = 100
// deprecated
export const PHANTOM_ANTE = 0.001
export const MINIMUM_ANTE = 50
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
export function getCpmmInitialLiquidity(
creator: User,
providerId: string,
contract: FullContract<CPMM, Binary>,
anteId: string,
amount: number
@ -21,7 +23,7 @@ export function getCpmmInitialLiquidity(
const lp: LiquidityProvision = {
id: anteId,
userId: creator.id,
userId: providerId,
contractId: contract.id,
createdTime,
isAnte: true,

View File

@ -34,8 +34,9 @@ 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.
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

View File

@ -1,7 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { chargeUser, getUser } from './utils'
import {
Binary,
@ -23,6 +22,7 @@ import {
getAnteBets,
getCpmmInitialLiquidity,
getFreeAnswerAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
MINIMUM_ANTE,
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
@ -73,11 +73,19 @@ export const createContract = functions
return { status: 'error', message: 'Invalid initial probability' }
const ante = FIXED_ANTE // data.ante
// uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', userId)
.where('createdTime', '>=', today)
.get()
const isFree = userContractsCreatedTodaySnapshot.size === 0
if (
ante === undefined ||
ante < MINIMUM_ANTE ||
ante > creator.balance ||
(ante > creator.balance && !isFree) ||
isNaN(ante) ||
!isFinite(ante)
)
@ -109,7 +117,7 @@ export const createContract = functions
tags ?? []
)
if (ante) await chargeUser(creator.id, ante)
if (!isFree && ante) await chargeUser(creator.id, ante)
await contractRef.create(contract)
@ -137,8 +145,10 @@ export const createContract = functions
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id
const lp = getCpmmInitialLiquidity(
creator,
providerId,
contract as FullContract<CPMM, Binary>,
liquidityDoc.id,
ante

View File

@ -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}`

View File

@ -106,7 +106,7 @@ async function recalculateContract(contractRef: DocRef, isCommit = false) {
const liquidityDocRef = contractRef.collection('liquidity').doc()
const lp = getCpmmInitialLiquidity(
{ id: 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' } as User, // use @ManifoldMarkets' id
'IPTOzEqrpkWmEzh6hwvAyY9PqFb2', // use @ManifoldMarkets' id
{
...contract,
...contractUpdate,

View File

@ -2,13 +2,15 @@
## Getting started
To run the development server, install [Yarn][yarn], and then in this directory:
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` starts a development web server, pointing at the development database
3. Your site will be available on http://localhost:3000
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].)
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].)
## Tech stack
@ -24,16 +26,17 @@ branch (to production) and PR branches (to ephemeral staging servers that can be
Parts of the file structure that directly map to HTTP endpoints are organized specially per Next.js's prescriptions:
### /public
### public/
These are static files that will be [served by Next verbatim][next-static-files].
### /pages
### 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.
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
### 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.
@ -52,7 +55,7 @@ integration][prettier-integrations] to format it in your editor.
[nextjs]: https://nextjs.org
[vercel]: https://vercel.com
[tailwind]: https://tailwindcss.com
[yarn]: https://yarnpkg.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

View File

@ -18,6 +18,7 @@ import {
useQueryAndSortParams,
} from '../../hooks/use-sort-and-query-params'
import { Answer } from '../../../common/answer'
import { LoadingIndicator } from '../loading-indicator'
export function ContractsGrid(props: {
contracts: Contract[]
@ -25,15 +26,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,6 +49,7 @@ export function ContractsGrid(props: {
}
return (
<>
<ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
{contracts.map((contract) => (
<ContractCard
@ -57,6 +60,16 @@ export function ContractsGrid(props: {
/>
))}
</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>
)}
</>
)
}
@ -201,7 +214,7 @@ function TagContractsGrid(props: { contracts: Contract[] }) {
const MAX_CONTRACTS_DISPLAYED = 99
export function SearchableGrid(props: {
contracts: Contract[]
contracts: Contract[] | undefined
byOneCreator?: boolean
querySortOptions?: {
defaultSort: Sort
@ -218,7 +231,7 @@ export function SearchableGrid(props: {
return queryWords.every((word) => corpus.toLowerCase().includes(word))
}
let matches = contracts.filter(
let matches = (contracts ?? []).filter(
(c) =>
check(c.question) ||
check(c.description) ||
@ -312,7 +325,9 @@ export function SearchableGrid(props: {
</select>
</div>
{sort === 'tag' ? (
{contracts === undefined ? (
<LoadingIndicator />
) : sort === 'tag' ? (
<TagContractsGrid contracts={matches} />
) : !byOneCreator && sort === 'creator' ? (
<CreatorContractsGrid contracts={matches} />

View File

@ -141,7 +141,7 @@ export default function FeedCreate(props: {
{/* Show a fake "Create Market" button, which gets replaced with the NewContract one*/}
{!isExpanded && (
<div className="flex justify-end sm:-mt-4">
<button className="btn btn-sm" disabled>
<button className="btn btn-sm capitalize" disabled>
Create Market
</button>
</div>

View File

@ -31,6 +31,8 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & {
type: 'commentInput'
betsByCurrentUser: Bet[]
comments: Comment[]
}
export type DescriptionItem = BaseActivityItem & {
@ -48,12 +50,13 @@ export type BetItem = BaseActivityItem & {
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
hideComment?: boolean
}
export type CommentItem = BaseActivityItem & {
type: 'comment'
comment: Comment
bet: Bet | undefined
betsBySameUser: Bet[]
hideOutcome: boolean
truncate: boolean
smallAvatar: boolean
@ -129,7 +132,7 @@ function groupBets(
type: 'comment' as const,
id: bet.id,
comment,
bet,
betsBySameUser: [bet],
contract,
hideOutcome,
truncate: abbreviated,
@ -280,7 +283,7 @@ function groupBetsAndComments(
id: comment.id,
contract: contract,
comment,
bet: undefined,
betsBySameUser: [],
truncate: abbreviated,
hideOutcome: true,
smallAvatar,
@ -308,6 +311,27 @@ function groupBetsAndComments(
return abbrItems
}
function getCommentsWithPositions(
bets: Bet[],
comments: Comment[],
contract: Contract
) {
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
const items = comments.map((comment) => ({
type: 'comment' as const,
id: comment.id,
contract: contract,
comment,
betsBySameUser: bets.length === 0 ? [] : betsByUserId[comment.userId] ?? [],
truncate: true,
hideOutcome: false,
smallAvatar: false,
}))
return items
}
export function getAllContractActivityItems(
contract: Contract,
bets: Bet[],
@ -361,6 +385,8 @@ export function getAllContractActivityItems(
type: 'commentInput',
id: 'commentInput',
contract,
betsByCurrentUser: [],
comments: [],
})
} else {
items.push(
@ -385,6 +411,8 @@ export function getAllContractActivityItems(
type: 'commentInput',
id: 'commentInput',
contract,
betsByCurrentUser: [],
comments: [],
})
}
@ -432,24 +460,13 @@ export function getRecentContractActivityItems(
)
)
} 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,
{
...groupBetsAndComments(bets, comments, contract, user?.id, {
hideOutcome: false,
abbreviated: true,
smallAvatar: false,
reversed: true,
}
)
})
)
}
@ -471,37 +488,29 @@ export function getSpecificContractActivityItems(
switch (mode) {
case 'bets':
items.push(
...groupBets(bets, comments, contract, user?.id, {
...bets.map((bet) => ({
type: 'bet' as const,
id: bet.id,
bet,
contract,
hideOutcome: false,
abbreviated: false,
smallAvatar: false,
reversed: false,
})
hideComment: true,
}))
)
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(...getCommentsWithPositions(bets, comments, contract))
items.push({
type: 'commentInput',
id: 'commentInput',
contract,
betsByCurrentUser: user
? bets.filter((bet) => bet.userId === user.id)
: [],
comments: comments,
})
break
}

View File

@ -39,7 +39,13 @@ import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { Answer } from '../../../common/answer'
import { ActivityItem } from './activity-items'
import { FreeResponse, FullContract } from '../../../common/contract'
import {
Binary,
CPMM,
DPM,
FreeResponse,
FullContract,
} from '../../../common/contract'
import { BuyButton } from '../yes-no-selector'
import { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
import { AnswerBetPanel } from '../answers/answer-bet-panel'
@ -50,6 +56,7 @@ import { trackClick } from '../../lib/firebase/tracking'
import { firebaseLogin } from '../../lib/firebase/users'
import { DAY_MS } from '../../../common/util/time'
import NewContractBadge from '../new-contract-badge'
import { calculateCpmmSale } from '../../../common/calculate-cpmm'
export function FeedItems(props: {
contract: Contract
@ -123,21 +130,38 @@ function FeedItem(props: { item: ActivityItem }) {
export function FeedComment(props: {
contract: Contract
comment: Comment
bet: Bet | undefined
betsBySameUser: Bet[]
hideOutcome: boolean
truncate: boolean
smallAvatar: boolean
}) {
const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props
let money: string | undefined
let outcome: string | undefined
let bought: string | undefined
if (bet) {
outcome = bet.outcome
bought = bet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(bet.amount))
}
const {
contract,
comment,
betsBySameUser,
hideOutcome,
truncate,
smallAvatar,
} = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
let outcome: string | undefined,
bought: string | undefined,
money: string | undefined
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
if (matchedBet) {
outcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
}
// Only calculated if they don't have a matching bet
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
getBettorsPosition(
contract,
comment.createdTime,
matchedBet ? [] : betsBySameUser
)
return (
<>
@ -155,8 +179,22 @@ export function FeedComment(props: {
username={userUsername}
name={userName}
/>{' '}
{!matchedBet && userPosition > 0 && (
<>
{'with ' + userPositionMoney + ' '}
<>
{' of '}
<OutcomeLabel
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
contract={contract}
truncate="short"
/>
</>
</>
)}
<>
{bought} {money}
{!hideOutcome && (
{outcome && !hideOutcome && (
<>
{' '}
of{' '}
@ -167,6 +205,7 @@ export function FeedComment(props: {
/>
</>
)}
</>
<RelativeTimestamp time={createdTime} />
</p>
</div>
@ -180,20 +219,12 @@ export function FeedComment(props: {
)
}
function RelativeTimestamp(props: { time: number }) {
const { time } = props
return (
<DateTimeTooltip time={time}>
<span className="ml-1 whitespace-nowrap text-gray-400">
{fromNow(time)}
</span>
</DateTimeTooltip>
)
}
export function CommentInput(props: { contract: Contract }) {
// see if we can comment input on any bet:
const { contract } = props
export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
comments: Comment[]
}) {
const { contract, betsByCurrentUser, comments } = props
const user = useUser()
const [comment, setComment] = useState('')
@ -206,14 +237,50 @@ export function CommentInput(props: { contract: Contract }) {
setComment('')
}
// Should this be oldest bet or most recent bet?
const mostRecentCommentableBet = betsByCurrentUser
.filter(
(bet) =>
canCommentOnBet(bet.userId, bet.createdTime, user) &&
!comments.some((comment) => comment.betId == bet.id)
)
.sort((b1, b2) => b1.createdTime - b2.createdTime)
.pop()
if (mostRecentCommentableBet) {
return (
<FeedBet
contract={contract}
bet={mostRecentCommentableBet}
hideOutcome={false}
smallAvatar={false}
/>
)
}
const { userPosition, userPositionMoney, yesFloorShares, noFloorShares } =
getBettorsPosition(contract, Date.now(), betsByCurrentUser)
return (
<>
<Row className={'flex w-full gap-2 pt-5'}>
<Row className={'flex w-full gap-2 pt-3'}>
<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">
{user && userPosition > 0 && (
<>
{'You with ' + userPositionMoney + ' '}
<>
{' of '}
<OutcomeLabel
outcome={yesFloorShares > noFloorShares ? 'YES' : 'NO'}
contract={contract}
truncate="short"
/>
</>
</>
)}
<div className="mt-2">
<Textarea
value={comment}
@ -244,18 +311,76 @@ export function CommentInput(props: { contract: Contract }) {
)
}
function RelativeTimestamp(props: { time: number }) {
const { time } = props
return (
<DateTimeTooltip time={time}>
<span className="ml-1 whitespace-nowrap text-gray-400">
{fromNow(time)}
</span>
</DateTimeTooltip>
)
}
function getBettorsPosition(
contract: Contract,
createdTime: number,
bets: Bet[]
) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
const emptyReturn = {
userPosition: 0,
userPositionMoney: 0,
yesFloorShares,
noFloorShares,
}
// TODO: show which of the answers was their majority stake at time of comment for FR?
if (contract.outcomeType != 'BINARY') {
return emptyReturn
}
if (bets.length === 0) {
return emptyReturn
}
// Calculate the majority shares they had when they made the comment
const betsBefore = bets.filter((prevBet) => prevBet.createdTime < createdTime)
const [yesBets, noBets] = _.partition(
betsBefore ?? [],
(bet) => bet.outcome === 'YES'
)
yesShares = _.sumBy(yesBets, (bet) => bet.shares)
noShares = _.sumBy(noBets, (bet) => bet.shares)
yesFloorShares = Math.floor(yesShares)
noFloorShares = Math.floor(noShares)
const userPosition = yesFloorShares || noFloorShares
const { saleValue } = calculateCpmmSale(
contract as FullContract<CPMM, Binary>,
yesShares || noShares,
yesFloorShares > noFloorShares ? 'YES' : 'NO'
)
const userPositionMoney = formatMoney(Math.abs(saleValue))
return { userPosition, userPositionMoney, yesFloorShares, noFloorShares }
}
export function FeedBet(props: {
contract: Contract
bet: Bet
hideOutcome: boolean
smallAvatar: boolean
hideComment?: boolean
bettor?: User // If set: reveal bettor identity
}) {
const { contract, bet, hideOutcome, smallAvatar, bettor } = props
const { contract, bet, hideOutcome, smallAvatar, bettor, hideComment } = props
const { id, amount, outcome, createdTime, userId } = bet
const user = useUser()
const isSelf = user?.id === userId
const canComment = canCommentOnBet(userId, createdTime, user)
const canComment = canCommentOnBet(userId, createdTime, user) && !hideComment
const [comment, setComment] = useState('')
async function submitComment() {
@ -268,6 +393,7 @@ export function FeedBet(props: {
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
<div>
{isSelf ? (
<Avatar
@ -286,7 +412,10 @@ export function FeedBet(props: {
) : (
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
<UserIcon
className="h-5 w-5 text-gray-500"
aria-hidden="true"
/>
</div>
</div>
)}
@ -333,6 +462,7 @@ export function FeedBet(props: {
)}
</div>
</div>
</Row>
</>
)
}

View File

@ -19,6 +19,7 @@ import { firebaseLogin, firebaseLogout } from '../../lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { getNavigationOptions, ProfileSummary } from './profile-menu'
import { useHasCreatedContractToday } from '../../hooks/use-has-created-contract-today'
const navigation = [
{ name: 'Home', href: '/home', icon: HomeIcon },
@ -96,6 +97,7 @@ export default function Sidebar() {
const user = useUser()
let folds = useFollowedFolds(user) || []
folds = _.sortBy(folds, 'followCount').reverse()
const deservesDailyFreeMarket = !useHasCreatedContractToday(user)
const navigationOptions = user === null ? signedOutNavigation : navigation
const mobileNavigationOptions =
@ -159,10 +161,22 @@ export default function Sidebar() {
/>
</div>
{deservesDailyFreeMarket ? (
<div className=" text-primary mt-4 text-center">
Use your daily free market! 🎉
</div>
) : (
<div />
)}
{user && (
<div className={'aligncenter flex justify-center'}>
<Link href={'/create'}>
<button className="btn btn-primary btn-md mt-4">Create Market</button>
<button className="btn btn-primary btn-md mt-4 capitalize">
Create Market
</button>
</Link>
</div>
)}
</nav>
)

View File

@ -1,4 +1,3 @@
import clsx from 'clsx'
import { Answer } from '../../common/answer'
import { getProbability } from '../../common/calculate'
import {
@ -11,6 +10,7 @@ import {
FullContract,
} from '../../common/contract'
import { formatPercent } from '../../common/util/format'
import { ClientRender } from './client-render'
export function OutcomeLabel(props: {
contract: Contract
@ -72,11 +72,13 @@ export function FreeResponseOutcomeLabel(props: {
const chosen = answers?.find((answer) => answer.id === resolution)
if (!chosen) return <AnswerNumberLabel number={resolution} />
return (
<FreeResponseAnswerToolTip text={chosen.text}>
<AnswerLabel
answer={chosen}
truncate={truncate}
className={answerClassName}
/>
</FreeResponseAnswerToolTip>
)
}
@ -126,3 +128,23 @@ export function AnswerLabel(props: {
return <span className={className}>{truncated}</span>
}
function FreeResponseAnswerToolTip(props: {
text: string
children?: React.ReactNode
}) {
const { text } = props
return (
<>
<ClientRender>
<span
className="tooltip hidden cursor-default sm:inline-block"
data-tip={text}
>
{props.children}
</span>
</ClientRender>
<span className="whitespace-nowrap sm:hidden">{props.children}</span>
</>
)
}

View File

@ -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))}
/>

View File

@ -0,0 +1,27 @@
import { listContracts } from '../lib/firebase/contracts'
import { useEffect, useState } from 'react'
import { User } from '../../common/user'
export const useHasCreatedContractToday = (user: User | null | undefined) => {
const [hasCreatedContractToday, setHasCreatedContractToday] = useState(true)
useEffect(() => {
// Uses utc time like the server.
const utcTimeString = new Date().toISOString()
const todayAtMidnight = new Date(utcTimeString).setUTCHours(0, 0, 0, 0)
async function listUserContractsForToday() {
if (!user) return
const contracts = await listContracts(user.id)
const todayContracts = contracts.filter(
(contract) => contract.createdTime > todayAtMidnight
)
setHasCreatedContractToday(todayContracts.length > 0)
}
listUserContractsForToday()
}, [user])
return hasCreatedContractToday
}

View File

@ -115,6 +115,18 @@ export async function listContracts(creatorId: string): Promise<Contract[]> {
return snapshot.docs.map((doc) => doc.data() as Contract)
}
export async function listTaggedContractsCaseInsensitive(
tag: string
): Promise<Contract[]> {
const q = query(
contractCollection,
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
orderBy('createdTime', 'desc')
)
const snapshot = await getDocs(q)
return snapshot.docs.map((doc) => doc.data() as Contract)
}
export async function listAllContracts(): Promise<Contract[]> {
const q = query(contractCollection, orderBy('createdTime', 'desc'))
const snapshot = await getDocs(q)

View File

@ -30,6 +30,7 @@
"lodash": "4.17.21",
"next": "12.1.2",
"react": "17.0.2",
"react-confetti": "^6.0.1",
"react-dom": "17.0.2",
"react-expanding-textarea": "2.3.5"
},

View File

@ -33,6 +33,8 @@ import { ContractTabs } from '../../components/contract/contract-tabs'
import { FirstArgument } from '../../../common/util/types'
import { DPM, FreeResponse, FullContract } from '../../../common/contract'
import { contractTextDetails } from '../../components/contract/contract-details'
import { useWindowSize } from '../../hooks/use-window-size'
import Confetti from 'react-confetti'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -86,9 +88,21 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
const { backToHome } = props
const user = useUser()
const { width, height } = useWindowSize()
const contract = useContractWithPreload(props.contract)
const { bets, comments } = props
const [showConfetti, setShowConfetti] = useState(false)
useEffect(() => {
const shouldSeeConfetti = !!(
user &&
contract &&
contract.creatorId === user.id &&
Date.now() - contract.createdTime < 10 * 1000
)
setShowConfetti(shouldSeeConfetti)
}, [contract, user])
// Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -119,6 +133,15 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
return (
<Page rightSidebar={rightSidebar}>
{showConfetti && (
<Confetti
width={width ? width : 500}
height={height ? height : 500}
recycle={false}
numberOfPieces={300}
/>
)}
{ogCardProps && (
<SEO
title={question}
@ -264,7 +287,7 @@ function ContractTopTrades(props: {
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
bet={betsById[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
hideOutcome={false}
truncate={false}
smallAvatar={false}

View File

@ -18,6 +18,7 @@ import { TagsList } from '../components/tags-list'
import { Row } from '../components/layout/row'
import { MAX_DESCRIPTION_LENGTH, outcomeType } from '../../common/contract'
import { formatMoney } from '../../common/util/format'
import { useHasCreatedContractToday } from '../hooks/use-has-created-contract-today'
export default function Create() {
const [question, setQuestion] = useState('')
@ -70,6 +71,9 @@ export function NewContract(props: { question: string; tag?: string }) {
const tags = parseWordsAsTags(tagText)
const [ante, setAnte] = useState(FIXED_ANTE)
const deservesDailyFreeMarket = !useHasCreatedContractToday(creator)
// useEffect(() => {
// if (ante === null && creator) {
// const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100
@ -95,7 +99,7 @@ export function NewContract(props: { question: string; tag?: string }) {
ante !== undefined &&
ante !== null &&
ante >= MINIMUM_ANTE &&
ante <= balance &&
(ante <= balance || deservesDailyFreeMarket) &&
// closeTime must be in the future
closeTime &&
closeTime > Date.now()
@ -106,11 +110,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 +179,8 @@ export function NewContract(props: { question: string; tag?: string }) {
<ProbabilitySelector
probabilityInt={initialProb}
setProbabilityInt={setInitialProb}
minProb={10}
maxProb={90}
/>
</div>
)}
@ -241,10 +250,14 @@ export function NewContract(props: { question: string; tag?: string }) {
text={`Cost to create your market. This amount is used to subsidize trading.`}
/>
</label>
<div className="label-text text-neutral pl-1">{formatMoney(ante)}</div>
{ante > balance && (
{deservesDailyFreeMarket ? (
<div className="label-text text-primary pl-1">FREE</div>
) : (
<div className="label-text text-neutral pl-1">
{formatMoney(ante)}
</div>
)}
{!deservesDailyFreeMarket && ante > balance && (
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
<span className="mr-2 text-red-500">Insufficient balance</span>
<button
@ -273,7 +286,7 @@ export function NewContract(props: { question: string; tag?: string }) {
<button
type="submit"
className={clsx(
'btn btn-primary',
'btn btn-primary capitalize',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid}

View File

@ -6,22 +6,11 @@ import { Page } from '../components/page'
import { SEO } from '../components/SEO'
import { Title } from '../components/title'
import { useContracts } from '../hooks/use-contracts'
import { Contract, listAllContracts } from '../lib/firebase/contracts'
export async function getStaticProps() {
const contracts = await listAllContracts().catch((_) => [])
return {
props: {
contracts,
},
revalidate: 60, // regenerate after a minute
}
}
import { Contract } from '../lib/firebase/contracts'
// TODO: Rename endpoint to "Explore"
export default function Markets(props: { contracts: Contract[] }) {
const contracts = useContracts() ?? props.contracts ?? []
export default function Markets() {
const contracts = useContracts()
return (
<Page>
@ -30,11 +19,6 @@ export default function Markets(props: { contracts: Contract[] }) {
description="Discover what's new, trending, or soon-to-close. Or search among our hundreds of markets."
url="/markets"
/>
{/* <HotMarkets contracts={hotContracts} />
<Spacer h={10} />
<ClosingSoonMarkets contracts={closingSoonContracts} />
<Spacer h={10} /> */}
<SearchableGrid contracts={contracts} />
</Page>
)

View File

@ -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

View File

@ -1,39 +1,30 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { SearchableGrid } from '../../components/contract/contracts-list'
import { Page } from '../../components/page'
import { Title } from '../../components/title'
import { useContracts } from '../../hooks/use-contracts'
import { Contract, listAllContracts } from '../../lib/firebase/contracts'
import {
Contract,
listTaggedContractsCaseInsensitive,
} from '../../lib/firebase/contracts'
export async function getStaticProps() {
const contracts = await listAllContracts().catch((_) => [])
return {
props: {
contracts,
},
revalidate: 60, // regenerate after a minute
}
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
export default function TagPage(props: { contracts: Contract[] }) {
export default function TagPage() {
const router = useRouter()
const { tag } = router.query as { tag: string }
const contracts = useContracts()
const taggedContracts = (contracts ?? props.contracts).filter((contract) =>
contract.lowercaseTags.includes(tag.toLowerCase())
)
// mqp: i wrote this in a panic to make the page literally work at all so if you
// want to e.g. listen for new contracts you may want to fix it up
const [contracts, setContracts] = useState<Contract[] | undefined>()
useEffect(() => {
if (tag != null) {
listTaggedContractsCaseInsensitive(tag).then(setContracts)
}
}, [tag])
return (
<Page>
<Title text={`#${tag}`} />
<SearchableGrid contracts={taggedContracts} />
<SearchableGrid contracts={contracts} />
</Page>
)
}

View File

@ -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

View File

@ -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>

View File

@ -4441,6 +4441,13 @@ raw-body@2.4.2, raw-body@^2.2.0:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-confetti@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/react-confetti/-/react-confetti-6.0.1.tgz#d4f57b5a021dd908a6243b8f63b6009b00818d10"
integrity sha512-ZpOTBrqSNhWE4rRXCZ6E6U+wGd7iYHF5MGrqwikoiBpgBq9Akdu0DcLW+FdFnLjyZYC+VfAiV2KeFgYRMyMrkA==
dependencies:
tween-functions "^1.2.0"
react-dom@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
@ -5154,6 +5161,11 @@ tsutils@^3.21.0:
dependencies:
tslib "^1.8.1"
tween-functions@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/tween-functions/-/tween-functions-1.2.0.tgz#1ae3a50e7c60bb3def774eac707acbca73bbc3ff"
integrity sha1-GuOlDnxguz3vd06scHrLynO7w/8=
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"