diff --git a/.gitignore b/.gitignore index 1426fe0c..6cb1e610 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .DS_Store - +.idea/ .vercel node_modules diff --git a/common/calculate.ts b/common/calculate.ts index d6834295..cc6451c5 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -18,7 +18,14 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Binary, Contract, CPMM, DPM, FullContract } from './contract' +import { + Binary, + Contract, + CPMM, + DPM, + FreeResponseContract, + FullContract, +} from './contract' export function getProbability(contract: FullContract) { return contract.mechanism === 'cpmm-1' @@ -170,3 +177,15 @@ export function getContractBetNullMetrics() { profitPercent: 0, } } + +export function getTopAnswer(contract: FreeResponseContract) { + const { answers } = contract + const top = _.maxBy( + answers.map((answer) => ({ + answer, + prob: getOutcomeProbability(contract, answer.id), + })), + ({ prob }) => prob + ) + return top?.answer +} diff --git a/common/tracking.ts b/common/tracking.ts index 29a1365c..bf06b6e3 100644 --- a/common/tracking.ts +++ b/common/tracking.ts @@ -10,3 +10,9 @@ export type ClickEvent = { contractId: string timestamp: number } + +export type LatencyEvent = { + type: 'feed' | 'portfolio' + latency: number + timestamp: number +} diff --git a/firestore.rules b/firestore.rules index 542bd5ec..09d65aac 100644 --- a/firestore.rules +++ b/firestore.rules @@ -30,6 +30,10 @@ service cloud.firestore { allow create: if userId == request.auth.uid; } + match /private-users/{userId}/latency/{loadTimeId} { + allow create: if userId == request.auth.uid; + } + match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() diff --git a/functions/README.md b/functions/README.md index 51db0793..2bce0c92 100644 --- a/functions/README.md +++ b/functions/README.md @@ -17,16 +17,16 @@ Adapted from https://firebase.google.com/docs/functions/get-started 0. `$ cd functions` to switch to this folder 1. `$ yarn global add firebase-tools` to install the Firebase CLI globally 2. `$ yarn` to install JS dependencies -3. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev (TODO: maybe not for Manifold) +3. `$ firebase login` to authenticate the CLI tools to Firebase 4. `$ firebase use dev` to choose the dev project +5. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev (TODO: maybe not for Manifold) ## Developing locally -1. `$ firebase login` if you aren't logged into Firebase via commandline yet. -2. `$ yarn dev` to spin up the emulators +0. `$ yarn dev` 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 =( -3. Connect to emulators by enabling `functions.useEmulator('localhost', 5001)` +1. Connect to emulators by enabling `functions.useEmulator('localhost', 5001)` ## Debugging diff --git a/functions/src/scripts/change-user-info.ts b/functions/src/scripts/change-user-info.ts index 2fdc3d1b..a6a01e59 100644 --- a/functions/src/scripts/change-user-info.ts +++ b/functions/src/scripts/change-user-info.ts @@ -1,15 +1,8 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' -// Generate your own private key, and set the path below: -// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk - -// const serviceAccount = require('../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json') -const serviceAccount = require('../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json') - -admin.initializeApp({ - credential: admin.credential.cert(serviceAccount), -}) +import { initAdmin } from './script-init' +initAdmin() import { getUserByUsername } from '../utils' import { changeUser } from '../change-user-info' diff --git a/functions/src/scripts/correct-bet-probability.ts b/functions/src/scripts/correct-bet-probability.ts index 30be7a1c..3b57dbeb 100644 --- a/functions/src/scripts/correct-bet-probability.ts +++ b/functions/src/scripts/correct-bet-probability.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('stephen') +initAdmin() import { Bet } from '../../../common/bet' import { getDpmProbability } from '../../../common/calculate-dpm' diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index 34a51a90..8051a447 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('stephen') +initAdmin() import { PrivateUser, STARTING_BALANCE, User } from '../../../common/user' diff --git a/functions/src/scripts/get-json-dump.ts b/functions/src/scripts/get-json-dump.ts index d29b32fe..b9909132 100644 --- a/functions/src/scripts/get-json-dump.ts +++ b/functions/src/scripts/get-json-dump.ts @@ -3,7 +3,7 @@ import * as _ from 'lodash' import * as fs from 'fs' import { initAdmin } from './script-init' -initAdmin('james') +initAdmin() import { Bet } from '../../../common/bet' import { Contract } from '../../../common/contract' diff --git a/functions/src/scripts/lowercase-fold-tags.ts b/functions/src/scripts/lowercase-fold-tags.ts index bf7335ee..80b79a33 100644 --- a/functions/src/scripts/lowercase-fold-tags.ts +++ b/functions/src/scripts/lowercase-fold-tags.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('james') +initAdmin() import { getValues } from '../utils' import { Fold } from '../../../common/fold' diff --git a/functions/src/scripts/make-contracts-public.ts b/functions/src/scripts/make-contracts-public.ts index a801f387..19d2e196 100644 --- a/functions/src/scripts/make-contracts-public.ts +++ b/functions/src/scripts/make-contracts-public.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('james') +initAdmin() import { Contract } from '../../../common/contract' diff --git a/functions/src/scripts/migrate-contract.ts b/functions/src/scripts/migrate-contract.ts index c64981b1..718cf62e 100644 --- a/functions/src/scripts/migrate-contract.ts +++ b/functions/src/scripts/migrate-contract.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('james') +initAdmin() import { Bet } from '../../../common/bet' import { Contract } from '../../../common/contract' diff --git a/functions/src/scripts/migrate-to-cfmm.ts b/functions/src/scripts/migrate-to-cfmm.ts index c9ad8bfa..cd9177a3 100644 --- a/functions/src/scripts/migrate-to-cfmm.ts +++ b/functions/src/scripts/migrate-to-cfmm.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('stephenDev') +initAdmin() import { Binary, diff --git a/functions/src/scripts/migrate-to-dpm-2.ts b/functions/src/scripts/migrate-to-dpm-2.ts index a7e611b6..2c6f066f 100644 --- a/functions/src/scripts/migrate-to-dpm-2.ts +++ b/functions/src/scripts/migrate-to-dpm-2.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('stephenDev') +initAdmin() import { Binary, Contract, DPM, FullContract } from '../../../common/contract' import { Bet } from '../../../common/bet' diff --git a/functions/src/scripts/pay-out-contract-again.ts b/functions/src/scripts/pay-out-contract-again.ts index 2696ddcb..7672bf7b 100644 --- a/functions/src/scripts/pay-out-contract-again.ts +++ b/functions/src/scripts/pay-out-contract-again.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('james') +initAdmin() import { Bet } from '../../../common/bet' import { Contract } from '../../../common/contract' diff --git a/functions/src/scripts/recalculate-contract-totals.ts b/functions/src/scripts/recalculate-contract-totals.ts index 6521425b..39942542 100644 --- a/functions/src/scripts/recalculate-contract-totals.ts +++ b/functions/src/scripts/recalculate-contract-totals.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('james') +initAdmin() import { Bet } from '../../../common/bet' import { Contract } from '../../../common/contract' diff --git a/functions/src/scripts/remove-answer-ante.ts b/functions/src/scripts/remove-answer-ante.ts index 555b5fc0..eb49af6c 100644 --- a/functions/src/scripts/remove-answer-ante.ts +++ b/functions/src/scripts/remove-answer-ante.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('james') +initAdmin() import { Bet } from '../../../common/bet' import { Contract } from '../../../common/contract' diff --git a/functions/src/scripts/rename-user-contracts.ts b/functions/src/scripts/rename-user-contracts.ts index fabb5acd..9b0f569b 100644 --- a/functions/src/scripts/rename-user-contracts.ts +++ b/functions/src/scripts/rename-user-contracts.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('stephenDev') +initAdmin() import { Contract } from '../../../common/contract' import { getValues } from '../utils' diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index 9a7c1b5d..d3acbc66 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -1,32 +1,71 @@ +import * as path from 'path' +import * as fs from 'fs' +import * as os from 'os' import * as admin from 'firebase-admin' -// Generate your own private key, and set the path below: -// Prod: -// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk -// Dev: -// https://console.firebase.google.com/u/0/project/dev-mantic-markets/settings/serviceaccounts/adminsdk +// First, generate a private key from the Google service account management page: +// Prod: https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk +// Dev: https://console.firebase.google.com/u/0/project/dev-mantic-markets/settings/serviceaccounts/adminsdk +// Then set GOOGLE_APPLICATION_CREDENTIALS_PROD or GOOGLE_APPLICATION_CREDENTIALS_DEV to the path of the key. -const pathsToPrivateKey = { - james: - '/Users/jahooma/mantic-markets-firebase-adminsdk-1ep46-820891bb87.json', - jamesDev: - '/Users/jahooma/dev-mantic-markets-firebase-adminsdk-sir5m-f38cdbee37.json', - stephen: - '../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json', - stephenDev: - '../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json', +// Then, to run a script, make sure you are pointing at the Firebase you intend to: +// $ firebase use dev (or prod) +// +// Followed by, if you have https://github.com/TypeStrong/ts-node installed (recommended): +// $ ts-node my-script.ts +// +// Or compile it and run the compiled version: +// $ yarn build && ../../lib/functions/scripts/src/my-script.js + +const getFirebaseProjectRoot = (cwd: string) => { + // see https://github.com/firebase/firebase-tools/blob/master/src/detectProjectRoot.ts + let dir = cwd + while (!fs.existsSync(path.resolve(dir, './firebase.json'))) { + const parentDir = path.dirname(dir) + if (parentDir === dir) { + return null + } + dir = parentDir + } + return dir } -export const initAdmin = (who: keyof typeof pathsToPrivateKey) => { - const serviceAccount = require(pathsToPrivateKey[who]) +const getFirebaseActiveProject = (cwd: string) => { + // firebase uses this configstore package https://github.com/yeoman/configstore/blob/main/index.js#L9 + const projectRoot = getFirebaseProjectRoot(cwd) + if (projectRoot == null) { + return null + } + const xdgConfig = + process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config') + const configPath = path.join(xdgConfig, 'configstore', 'firebase-tools.json') + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) + return config['activeProjects'][projectRoot] + } catch (e) { + return null + } +} +export const initAdmin = (env?: string) => { + env = env || getFirebaseActiveProject(process.cwd()) + if (env == null) { + console.error( + "Couldn't find active Firebase project; did you do `firebase use ?`" + ) + return + } + const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` + const keyPath = process.env[envVar] + if (keyPath == null) { + console.error( + `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` + ) + return + } + console.log(`Initializing connection to ${env} Firebase...`) + const serviceAccount = require(keyPath) admin.initializeApp({ credential: admin.credential.cert(serviceAccount), }) } - -// Then: -// yarn watch (or yarn build) -// firebase use dev (or firebase use prod) -// Run script: -// node lib/functions/src/scripts/update-contract-tags.js diff --git a/functions/src/scripts/update-contract-tags.ts b/functions/src/scripts/update-contract-tags.ts index 29b68685..1dda5615 100644 --- a/functions/src/scripts/update-contract-tags.ts +++ b/functions/src/scripts/update-contract-tags.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' import * as _ from 'lodash' import { initAdmin } from './script-init' -initAdmin('jamesDev') +initAdmin() import { Contract } from '../../../common/contract' import { parseTags } from '../../../common/util/parse' diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index e1ecfab4..7010de9f 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -64,6 +64,7 @@ export function AnswerBetPanel(props: { if (result?.status === 'success') { setIsSubmitting(false) setBetAmount(undefined) + props.closePanel() } else { setError(result?.error || 'Error placing bet') setIsSubmitting(false) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 31d4b688..492a1b21 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' -import _ from 'lodash' import { useUser } from '../hooks/use-user' import { Binary, CPMM, DPM, FullContract } from '../../common/contract' @@ -19,7 +18,7 @@ import { Bet } from '../../common/bet' import { placeBet, sellShares } from '../lib/firebase/api-call' import { BuyAmountInput, SellAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel, OutcomeLabel } from './outcome-label' +import { BinaryOutcomeLabel } from './outcome-label' import { calculatePayoutAfterCorrectBet, calculateShares, @@ -32,61 +31,30 @@ import { calculateCpmmSale, getCpmmProbability, } from '../../common/calculate-cpmm' -import { Modal } from './layout/modal' +import { SellRow } from './sell-row' +import { useSaveShares } from './use-save-shares' export function BetPanel(props: { contract: FullContract className?: string }) { const { contract, className } = props - const { mechanism } = contract - const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - - const [showSellModal, setShowSellModal] = useState(false) - - const { yesShares, noShares } = useSaveShares(contract, userBets) - - const shares = yesShares || noShares - const sharesOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined + const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined return ( - {sharesOutcome && user && mechanism === 'cpmm-1' && ( - - -
- You have {formatWithCommas(Math.floor(shares))}{' '} - shares -
- - - - {showSellModal && ( - } - user={user} - userBets={userBets ?? []} - shares={shares} - sharesOutcome={sharesOutcome} - setOpen={setShowSellModal} - /> - )} -
- - )} - + ('BUY') - const { yesShares, noShares } = useSaveShares(contract, userBets) + const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( + contract, + userBets + ) - const shares = yesShares || noShares - const sharesOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined + const floorShares = yesFloorShares || noFloorShares + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined useEffect(() => { // Switch back to BUY if the user has sold all their shares. @@ -146,7 +121,7 @@ export function BetPanelSwitcher(props: {
- You have {formatWithCommas(Math.floor(shares))}{' '} + You have {formatWithCommas(floorShares)}{' '} shares
@@ -394,7 +369,7 @@ function BuyPanel(props: { ) } -function SellPanel(props: { +export function SellPanel(props: { contract: FullContract userBets: Bet[] shares: number @@ -493,78 +468,3 @@ function SellPanel(props: { ) } - -const useSaveShares = ( - contract: FullContract, - userBets: Bet[] | undefined -) => { - const [savedShares, setSavedShares] = useState< - { yesShares: number; noShares: number } | undefined - >() - - const [yesBets, noBets] = _.partition( - userBets ?? [], - (bet) => bet.outcome === 'YES' - ) - const [yesShares, noShares] = [ - _.sumBy(yesBets, (bet) => bet.shares), - _.sumBy(noBets, (bet) => bet.shares), - ] - - useEffect(() => { - // Save yes and no shares to local storage. - const savedShares = localStorage.getItem(`${contract.id}-shares`) - if (!userBets && savedShares) { - setSavedShares(JSON.parse(savedShares)) - } - - if (userBets) { - const updatedShares = { yesShares, noShares } - localStorage.setItem( - `${contract.id}-shares`, - JSON.stringify(updatedShares) - ) - } - }, [contract.id, userBets, noShares, yesShares]) - - if (userBets) return { yesShares, noShares } - return savedShares ?? { yesShares: 0, noShares: 0 } -} - -function SellSharesModal(props: { - contract: FullContract - userBets: Bet[] - shares: number - sharesOutcome: 'YES' | 'NO' - user: User - setOpen: (open: boolean) => void -}) { - const { contract, shares, sharesOutcome, userBets, user, setOpen } = props - - return ( - - - - - <div className="mb-6"> - You have {formatWithCommas(Math.floor(shares))}{' '} - <OutcomeLabel - outcome={sharesOutcome} - contract={contract} - truncate="long" - />{' '} - shares - </div> - - <SellPanel - contract={contract} - shares={shares} - sharesOutcome={sharesOutcome} - user={user} - userBets={userBets ?? []} - onSellSuccess={() => setOpen(false)} - /> - </Col> - </Modal> - ) -} diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index e1b99c3a..4285fe98 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx' import { useState } from 'react' import { BetPanelSwitcher } from './bet-panel' @@ -6,6 +5,10 @@ import { Row } from './layout/row' import { YesNoSelector } from './yes-no-selector' import { Binary, CPMM, DPM, FullContract } from '../../common/contract' import { Modal } from './layout/modal' +import { SellButton } from './sell-button' +import { useUser } from '../hooks/use-user' +import { useUserContractBets } from '../hooks/use-user-bets' +import { useSaveShares } from './use-save-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { @@ -13,16 +16,19 @@ export default function BetRow(props: { className?: string labelClassName?: string }) { - const { className, labelClassName } = props + const { className, labelClassName, contract } = props const [open, setOpen] = useState(false) const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>( undefined ) + const user = useUser() + const userBets = useUserContractBets(user?.id, contract.id) + const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) return ( <> <div className={className}> - <Row className="items-center justify-end gap-2"> + <Row className="mt-2 justify-end space-x-3"> {/* <div className={clsx('mr-2 text-gray-400', labelClassName)}> Place a trade </div> */} @@ -32,12 +38,22 @@ export default function BetRow(props: { setOpen(true) setBetChoice(choice) }} + replaceNoButton={ + yesFloorShares > noFloorShares && yesFloorShares > 0 ? ( + <SellButton contract={contract} user={user} /> + ) : undefined + } + replaceYesButton={ + noFloorShares > yesFloorShares && noFloorShares > 0 ? ( + <SellButton contract={contract} user={user} /> + ) : undefined + } /> </Row> <Modal open={open} setOpen={setOpen}> <BetPanelSwitcher - contract={props.contract} - title={props.contract.question} + contract={contract} + title={contract.question} selected={betChoice} onBetSuccess={() => setOpen(false)} /> diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 35e0b3d9..63cbfd57 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -37,6 +37,8 @@ import { resolvedPayout, getContractBetNullMetrics, } from '../../common/calculate' +import { useTimeSinceFirstRender } from '../hooks/use-time-since-first-render' +import { trackLatency } from '../lib/firebase/tracking' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'closed' | 'resolved' | 'all' @@ -67,6 +69,14 @@ export function BetsList(props: { user: User }) { } }, [bets]) + const getTime = useTimeSinceFirstRender() + useEffect(() => { + if (bets && contracts) { + trackLatency('portfolio', getTime()) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [!!bets, !!contracts]) + if (!bets || !contracts) { return <LoadingIndicator /> } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index c88464bb..41373679 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -1,27 +1,15 @@ import clsx from 'clsx' import Link from 'next/link' import _ from 'lodash' -import { ClockIcon, DatabaseIcon, PencilIcon } from '@heroicons/react/outline' -import { TrendingUpIcon } from '@heroicons/react/solid' import { Row } from '../layout/row' -import { formatMoney, formatPercent } from '../../../common/util/format' -import { UserLink } from '../user-page' +import { formatPercent } from '../../../common/util/format' import { Contract, - contractMetrics, contractPath, getBinaryProbPercent, - updateContract, } from '../../lib/firebase/contracts' import { Col } from '../layout/col' -import dayjs from 'dayjs' -import { DateTimeTooltip } from '../datetime-tooltip' -import { fromNow } from '../../lib/util/time' -import { Avatar } from '../avatar' import { Spacer } from '../layout/spacer' -import { useState } from 'react' -import { ContractInfoDialog } from './contract-info-dialog' -import { Bet } from '../../../common/bet' import { Binary, CPMM, @@ -35,7 +23,8 @@ import { BinaryContractOutcomeLabel, FreeResponseOutcomeLabel, } from '../outcome-label' -import { getOutcomeProbability } from '../../../common/calculate' +import { getOutcomeProbability, getTopAnswer } from '../../../common/calculate' +import { AbbrContractDetails } from './contract-details' export function ContractCard(props: { contract: Contract @@ -68,7 +57,7 @@ export function ContractCard(props: { <Row className={clsx( 'justify-between gap-4', - outcomeType === 'FREE_RESPONSE' && 'flex-col items-start' + outcomeType === 'FREE_RESPONSE' && 'flex-col items-start !gap-2' )} > <p @@ -85,6 +74,7 @@ export function ContractCard(props: { )} {outcomeType === 'FREE_RESPONSE' && ( <FreeResponseResolutionOrChance + className="self-end text-gray-600" contract={contract as FullContract<DPM, FreeResponse>} truncate="long" /> @@ -132,29 +122,18 @@ export function BinaryResolutionOrChance(props: { ) } -function getTopAnswer(contract: FreeResponseContract) { - const { answers } = contract - const top = _.maxBy( - answers.map((answer) => ({ - answer, - prob: getOutcomeProbability(contract, answer.id), - })), - ({ prob }) => prob - ) - return top?.answer -} - export function FreeResponseResolutionOrChance(props: { contract: FreeResponseContract truncate: 'short' | 'long' | 'none' + className?: string }) { - const { contract, truncate } = props + const { contract, truncate, className } = props const { resolution } = contract const topAnswer = getTopAnswer(contract) return ( - <Col className="text-xl"> + <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> {resolution ? ( <> <div className={clsx('text-base text-gray-500')}>Resolved</div> @@ -162,13 +141,18 @@ export function FreeResponseResolutionOrChance(props: { contract={contract} resolution={resolution} truncate={truncate} + answerClassName="text-xl" /> </> ) : ( topAnswer && ( - <Row className="flex-1 items-center justify-between gap-6"> - <AnswerLabel answer={topAnswer} truncate={truncate} /> - <Col className="text-primary"> + <Row className="items-center gap-6"> + <AnswerLabel + className="!text-gray-600" + answer={topAnswer} + truncate={truncate} + /> + <Col className="text-primary text-3xl"> <div> {formatPercent(getOutcomeProbability(contract, topAnswer.id))} </div> @@ -180,209 +164,3 @@ export function FreeResponseResolutionOrChance(props: { </Col> ) } - -function AbbrContractDetails(props: { - contract: Contract - showHotVolume?: boolean - showCloseTime?: boolean -}) { - const { contract, showHotVolume, showCloseTime } = props - const { volume24Hours, creatorName, creatorUsername, closeTime } = contract - const { volumeLabel } = contractMetrics(contract) - - return ( - <Col className={clsx('gap-2 text-sm text-gray-500')}> - <Row className="items-center justify-between"> - <Row className="items-center gap-2"> - <Avatar - username={creatorUsername} - avatarUrl={contract.creatorAvatarUrl} - size={6} - /> - <UserLink - className="whitespace-nowrap" - name={creatorName} - username={creatorUsername} - /> - </Row> - - {showHotVolume ? ( - <Row className="gap-1"> - <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)} - </Row> - ) : showCloseTime ? ( - <Row className="gap-1"> - <ClockIcon className="h-5 w-5" /> - {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} - {fromNow(closeTime || 0)} - </Row> - ) : ( - <Row className="gap-1"> - {/* <DatabaseIcon className="h-5 w-5" /> */} - {volumeLabel} - </Row> - )} - </Row> - </Col> - ) -} - -export function ContractDetails(props: { - contract: Contract - bets: Bet[] - isCreator?: boolean - hideShareButtons?: boolean -}) { - const { contract, bets, isCreator, hideShareButtons } = props - const { closeTime, creatorName, creatorUsername } = contract - const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract) - - return ( - <Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap"> - <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-3"> - <Row className="items-center gap-2"> - <Avatar - username={creatorUsername} - avatarUrl={contract.creatorAvatarUrl} - size={6} - /> - <UserLink - className="whitespace-nowrap" - name={creatorName} - username={creatorUsername} - /> - </Row> - - {(!!closeTime || !!resolvedDate) && ( - <Row className="items-center gap-1"> - <ClockIcon className="h-5 w-5" /> - - {/* <DateTimeTooltip text="Market created:" time={contract.createdTime}> - {createdDate} - </DateTimeTooltip> */} - - {resolvedDate && contract.resolutionTime ? ( - <> - {/* {' - '} */} - <DateTimeTooltip - text="Market resolved:" - time={contract.resolutionTime} - > - {resolvedDate} - </DateTimeTooltip> - </> - ) : null} - - {!resolvedDate && closeTime && ( - <> - {/* {' - '}{' '} */} - <EditableCloseDate - closeTime={closeTime} - contract={contract} - isCreator={isCreator ?? false} - /> - </> - )} - </Row> - )} - - <Row className="items-center gap-1"> - <DatabaseIcon className="h-5 w-5" /> - - <div className="whitespace-nowrap">{volumeLabel}</div> - </Row> - - {!hideShareButtons && ( - <ContractInfoDialog contract={contract} bets={bets} /> - )} - </Row> - </Col> - ) -} - -// String version of the above, to send to the OpenGraph image generator -export function contractTextDetails(contract: Contract) { - const { closeTime, tags } = contract - const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - - const hashtags = tags.map((tag) => `#${tag}`) - - return ( - `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + - (closeTime - ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( - closeTime - ).format('MMM D, h:mma')}` - : '') + - ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') - ) -} - -function EditableCloseDate(props: { - closeTime: number - contract: Contract - isCreator: boolean -}) { - const { closeTime, contract, isCreator } = props - - const [isEditingCloseTime, setIsEditingCloseTime] = useState(false) - const [closeDate, setCloseDate] = useState( - closeTime && dayjs(closeTime).format('YYYY-MM-DDT23:59') - ) - - const onSave = () => { - const newCloseTime = dayjs(closeDate).valueOf() - if (newCloseTime === closeTime) setIsEditingCloseTime(false) - else if (newCloseTime > Date.now()) { - const { description } = contract - const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') - const newDescription = `${description}\n\nClose date updated to ${formattedCloseDate}` - - updateContract(contract.id, { - closeTime: newCloseTime, - description: newDescription, - }) - - setIsEditingCloseTime(false) - } - } - - return ( - <> - {isEditingCloseTime ? ( - <div className="form-control mr-1 items-start"> - <input - type="datetime-local" - className="input input-bordered" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value || '')} - min={Date.now()} - value={closeDate} - /> - </div> - ) : ( - <DateTimeTooltip - text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'} - time={closeTime} - > - {dayjs(closeTime).format('MMM D')} ({fromNow(closeTime)}) - </DateTimeTooltip> - )} - - {isCreator && - (isEditingCloseTime ? ( - <button className="btn btn-xs" onClick={onSave}> - Done - </button> - ) : ( - <button - className="btn btn-xs btn-ghost" - onClick={() => setIsEditingCloseTime(true)} - > - <PencilIcon className="mr-2 inline h-4 w-4" /> Edit - </button> - ))} - </> - ) -} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx new file mode 100644 index 00000000..8cc27496 --- /dev/null +++ b/web/components/contract/contract-details.tsx @@ -0,0 +1,233 @@ +import clsx from 'clsx' +import _ from 'lodash' +import { ClockIcon, DatabaseIcon, PencilIcon } from '@heroicons/react/outline' +import { TrendingUpIcon } from '@heroicons/react/solid' +import { Row } from '../layout/row' +import { formatMoney } from '../../../common/util/format' +import { UserLink } from '../user-page' +import { + Contract, + contractMetrics, + updateContract, +} from '../../lib/firebase/contracts' +import { Col } from '../layout/col' +import dayjs from 'dayjs' +import { DateTimeTooltip } from '../datetime-tooltip' +import { fromNow } from '../../lib/util/time' +import { Avatar } from '../avatar' +import { useState } from 'react' +import { ContractInfoDialog } from './contract-info-dialog' +import { Bet } from '../../../common/bet' +import NewContractBadge from '../new-contract-badge' + +export function AbbrContractDetails(props: { + contract: Contract + showHotVolume?: boolean + showCloseTime?: boolean +}) { + const { contract, showHotVolume, showCloseTime } = props + const { volume, volume24Hours, creatorName, creatorUsername, closeTime } = + contract + const { volumeLabel } = contractMetrics(contract) + + return ( + <Col className={clsx('gap-2 text-sm text-gray-500')}> + <Row className="items-center justify-between"> + <Row className="items-center gap-2"> + <Avatar + username={creatorUsername} + avatarUrl={contract.creatorAvatarUrl} + size={6} + /> + <UserLink + className="whitespace-nowrap" + name={creatorName} + username={creatorUsername} + /> + </Row> + + {showHotVolume ? ( + <Row className="gap-1"> + <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)} + </Row> + ) : showCloseTime ? ( + <Row className="gap-1"> + <ClockIcon className="h-5 w-5" /> + {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} + {fromNow(closeTime || 0)} + </Row> + ) : volume > 0 ? ( + <Row>{volumeLabel}</Row> + ) : ( + <NewContractBadge /> + )} + </Row> + </Col> + ) +} + +export function ContractDetails(props: { + contract: Contract + bets: Bet[] + isCreator?: boolean + hideShareButtons?: boolean +}) { + const { contract, bets, isCreator, hideShareButtons } = props + const { closeTime, creatorName, creatorUsername } = contract + const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract) + + return ( + <Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap"> + <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-3"> + <Row className="items-center gap-2"> + <Avatar + username={creatorUsername} + avatarUrl={contract.creatorAvatarUrl} + size={6} + /> + <UserLink + className="whitespace-nowrap" + name={creatorName} + username={creatorUsername} + /> + </Row> + + {(!!closeTime || !!resolvedDate) && ( + <Row className="items-center gap-1"> + <ClockIcon className="h-5 w-5" /> + + {/* <DateTimeTooltip text="Market created:" time={contract.createdTime}> + {createdDate} + </DateTimeTooltip> */} + + {resolvedDate && contract.resolutionTime ? ( + <> + {/* {' - '} */} + <DateTimeTooltip + text="Market resolved:" + time={contract.resolutionTime} + > + {resolvedDate} + </DateTimeTooltip> + </> + ) : null} + + {!resolvedDate && closeTime && ( + <> + {/* {' - '}{' '} */} + <EditableCloseDate + closeTime={closeTime} + contract={contract} + isCreator={isCreator ?? false} + /> + </> + )} + </Row> + )} + + <Row className="items-center gap-1"> + <DatabaseIcon className="h-5 w-5" /> + + <div className="whitespace-nowrap">{volumeLabel}</div> + </Row> + + {!hideShareButtons && ( + <ContractInfoDialog contract={contract} bets={bets} /> + )} + </Row> + </Col> + ) +} + +// String version of the above, to send to the OpenGraph image generator +export function contractTextDetails(contract: Contract) { + const { closeTime, tags } = contract + const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) + + const hashtags = tags.map((tag) => `#${tag}`) + + return ( + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + + (closeTime + ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( + closeTime + ).format('MMM D, h:mma')}` + : '') + + ` • ${volumeLabel}` + + (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + ) +} + +function EditableCloseDate(props: { + closeTime: number + contract: Contract + isCreator: boolean +}) { + const { closeTime, contract, isCreator } = props + + const [isEditingCloseTime, setIsEditingCloseTime] = useState(false) + const [closeDate, setCloseDate] = useState( + closeTime && dayjs(closeTime).format('YYYY-MM-DDT23:59') + ) + + const isSameYear = dayjs(closeTime).isSame(dayjs(), 'year') + const isSameDay = dayjs(closeTime).isSame(dayjs(), 'day') + + const onSave = () => { + const newCloseTime = dayjs(closeDate).valueOf() + if (newCloseTime === closeTime) setIsEditingCloseTime(false) + else if (newCloseTime > Date.now()) { + const { description } = contract + const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a') + const newDescription = `${description}\n\nClose date updated to ${formattedCloseDate}` + + updateContract(contract.id, { + closeTime: newCloseTime, + description: newDescription, + }) + + setIsEditingCloseTime(false) + } + } + + return ( + <> + {isEditingCloseTime ? ( + <div className="form-control mr-1 items-start"> + <input + type="datetime-local" + className="input input-bordered" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setCloseDate(e.target.value || '')} + min={Date.now()} + value={closeDate} + /> + </div> + ) : ( + <DateTimeTooltip + text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'} + time={closeTime} + > + {isSameYear + ? dayjs(closeTime).format('MMM D') + : dayjs(closeTime).format('MMM D, YYYY')} + {isSameDay && <> ({fromNow(closeTime)})</>} + </DateTimeTooltip> + )} + + {isCreator && + (isEditingCloseTime ? ( + <button className="btn btn-xs" onClick={onSave}> + Done + </button> + ) : ( + <button + className="btn btn-xs btn-ghost" + onClick={() => setIsEditingCloseTime(true)} + > + <PencilIcon className="mr-2 inline h-4 w-4" /> Edit + </button> + ))} + </> + ) +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index c928c1d7..88dc053f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -8,7 +8,6 @@ import { Linkify } from '../linkify' import clsx from 'clsx' import { FreeResponseResolutionOrChance, - ContractDetails, BinaryResolutionOrChance, } from './contract-card' import { Bet } from '../../../common/bet' @@ -17,6 +16,7 @@ import BetRow from '../bet-row' import { AnswersGraph } from '../answers/answers-graph' import { DPM, FreeResponse, FullContract } from '../../../common/contract' import { ContractDescription } from './contract-description' +import { ContractDetails } from './contract-details' export const ContractOverview = (props: { contract: Contract diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index e2def4d4..33fb0912 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -9,6 +9,7 @@ import { UserIcon, UsersIcon, XIcon, + SparklesIcon, } from '@heroicons/react/solid' import clsx from 'clsx' import Textarea from 'react-expanding-textarea' @@ -46,6 +47,8 @@ import { useSaveSeenContract } from '../../hooks/use-seen-contracts' import { User } from '../../../common/user' import { Modal } from '../layout/modal' import { trackClick } from '../../lib/firebase/tracking' +import { DAY_MS } from '../../../common/util/time' +import NewContractBadge from '../new-contract-badge' export function FeedItems(props: { contract: Contract @@ -307,19 +310,17 @@ export function FeedQuestion(props: { contractPath?: string }) { const { contract, showDescription } = props - const { creatorName, creatorUsername, question, resolution, outcomeType } = - contract + const { + creatorName, + creatorUsername, + question, + outcomeType, + volume, + createdTime, + } = contract const { volumeLabel } = contractMetrics(contract) const isBinary = outcomeType === 'BINARY' - - // const closeMessage = - // contract.isResolved || !contract.closeTime ? null : ( - // <> - // <span className="mx-2">•</span> - // {contract.closeTime > Date.now() ? 'Closes' : 'Closed'} - // <RelativeTimestamp time={contract.closeTime || 0} /> - // </> - // ) + const isNew = createdTime > Date.now() - DAY_MS return ( <> @@ -336,10 +337,15 @@ export function FeedQuestion(props: { />{' '} asked {/* Currently hidden on mobile; ideally we'd fit this in somewhere. */} - <span className="float-right hidden text-gray-400 sm:inline"> - {volumeLabel} - {/* {closeMessage} */} - </span> + <div className="relative -top-2 float-right "> + {isNew || volume === 0 ? ( + <NewContractBadge /> + ) : ( + <span className="hidden text-gray-400 sm:inline"> + {volumeLabel} + </span> + )} + </div> </div> <Col className="items-start justify-between gap-2 sm:flex-row sm:gap-4"> <Col> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index a86f0d79..67e8ae29 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -158,6 +158,12 @@ export default function Sidebar() { buttonContent={<MoreButton />} /> </div> + + {user && ( + <Link href={'/create'}> + <button className="btn btn-primary btn-md mt-4">Create Market</button> + </Link> + )} </nav> ) } diff --git a/web/components/new-contract-badge.tsx b/web/components/new-contract-badge.tsx new file mode 100644 index 00000000..3cd47eab --- /dev/null +++ b/web/components/new-contract-badge.tsx @@ -0,0 +1,9 @@ +import { SparklesIcon } from '@heroicons/react/solid' + +export default function NewContractBadge() { + return ( + <span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800"> + <SparklesIcon className="h-4 w-4" aria-hidden="true" /> New + </span> + ) +} diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index db286277..a2193684 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { Answer } from '../../common/answer' import { getProbability } from '../../common/calculate' import { @@ -59,8 +60,9 @@ export function FreeResponseOutcomeLabel(props: { contract: FreeResponseContract resolution: string | 'CANCEL' | 'MKT' truncate: 'short' | 'long' | 'none' + answerClassName?: string }) { - const { contract, resolution, truncate } = props + const { contract, resolution, truncate, answerClassName } = props if (resolution === 'CANCEL') return <CancelLabel /> if (resolution === 'MKT') return <MultiLabel /> @@ -68,7 +70,13 @@ export function FreeResponseOutcomeLabel(props: { const { answers } = contract const chosen = answers?.find((answer) => answer.id === resolution) if (!chosen) return <AnswerNumberLabel number={resolution} /> - return <AnswerLabel answer={chosen} truncate={truncate} /> + return ( + <AnswerLabel + answer={chosen} + truncate={truncate} + className={answerClassName} + /> + ) } export function YesLabel() { @@ -103,8 +111,9 @@ export function AnswerNumberLabel(props: { number: string }) { export function AnswerLabel(props: { answer: Answer truncate: 'short' | 'long' | 'none' + className?: string }) { - const { answer, truncate } = props + const { answer, truncate, className } = props const { text } = answer let truncated = text @@ -114,5 +123,5 @@ export function AnswerLabel(props: { truncated = text.slice(0, 75) + '...' } - return <span className="text-primary">{truncated}</span> + return <span className={className}>{truncated}</span> } diff --git a/web/components/sell-button.tsx b/web/components/sell-button.tsx new file mode 100644 index 00000000..3b66d46a --- /dev/null +++ b/web/components/sell-button.tsx @@ -0,0 +1,64 @@ +import { Binary, CPMM, DPM, FullContract } from '../../common/contract' +import { User } from '../../common/user' +import { useUserContractBets } from '../hooks/use-user-bets' +import { useState } from 'react' +import { useSaveShares } from './use-save-shares' +import { Col } from './layout/col' +import clsx from 'clsx' +import { SellSharesModal } from './sell-modal' + +export function SellButton(props: { + contract: FullContract<DPM | CPMM, Binary> + user: User | null | undefined +}) { + const { contract, user } = props + + const userBets = useUserContractBets(user?.id, contract.id) + const [showSellModal, setShowSellModal] = useState(false) + + const { mechanism } = contract + const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( + contract, + userBets + ) + const floorShares = yesFloorShares || noFloorShares + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined + + if (sharesOutcome && user && mechanism === 'cpmm-1') { + return ( + <Col className={'items-center'}> + <button + className={clsx( + 'btn-sm w-24 gap-1', + // from the yes-no-selector: + 'flex inline-flex flex-row items-center justify-center rounded-3xl border-2 p-2', + sharesOutcome === 'NO' + ? 'hover:bg-primary-focus border-primary hover:border-primary-focus text-primary hover:text-white' + : 'border-red-400 text-red-500 hover:border-red-500 hover:bg-red-500 hover:text-white' + )} + onClick={() => setShowSellModal(true)} + > + {'Sell ' + sharesOutcome} + </button> + <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> + {'(' + floorShares + ' shares)'} + </div> + {showSellModal && ( + <SellSharesModal + contract={contract as FullContract<CPMM, Binary>} + user={user} + userBets={userBets ?? []} + shares={yesShares || noShares} + sharesOutcome={sharesOutcome} + setOpen={setShowSellModal} + /> + )} + </Col> + ) + } + return <div /> +} diff --git a/web/components/sell-modal.tsx b/web/components/sell-modal.tsx new file mode 100644 index 00000000..19954d7f --- /dev/null +++ b/web/components/sell-modal.tsx @@ -0,0 +1,47 @@ +import { Binary, CPMM, FullContract } from '../../common/contract' +import { Bet } from '../../common/bet' +import { User } from '../../common/user' +import { Modal } from './layout/modal' +import { Col } from './layout/col' +import { Title } from './title' +import { formatWithCommas } from '../../common/util/format' +import { OutcomeLabel } from './outcome-label' +import { SellPanel } from './bet-panel' + +export function SellSharesModal(props: { + contract: FullContract<CPMM, Binary> + userBets: Bet[] + shares: number + sharesOutcome: 'YES' | 'NO' + user: User + setOpen: (open: boolean) => void +}) { + const { contract, shares, sharesOutcome, userBets, user, setOpen } = props + + return ( + <Modal open={true} setOpen={setOpen}> + <Col className="rounded-md bg-white px-8 py-6"> + <Title className="!mt-0" text={'Sell shares'} /> + + <div className="mb-6"> + You have {formatWithCommas(Math.floor(shares))}{' '} + <OutcomeLabel + outcome={sharesOutcome} + contract={contract} + truncate={'short'} + />{' '} + shares + </div> + + <SellPanel + contract={contract} + shares={shares} + sharesOutcome={sharesOutcome} + user={user} + userBets={userBets ?? []} + onSellSuccess={() => setOpen(false)} + /> + </Col> + </Modal> + ) +} diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx new file mode 100644 index 00000000..c30f799e --- /dev/null +++ b/web/components/sell-row.tsx @@ -0,0 +1,77 @@ +import { Binary, CPMM, DPM, FullContract } from '../../common/contract' +import { User } from '../../common/user' +import { useState } from 'react' +import { Col } from './layout/col' +import { Row } from './layout/row' +import { formatWithCommas } from '../../common/util/format' +import { OutcomeLabel } from './outcome-label' +import { useUserContractBets } from '../hooks/use-user-bets' +import { useSaveShares } from './use-save-shares' +import { SellSharesModal } from './sell-modal' + +export function SellRow(props: { + contract: FullContract<DPM | CPMM, Binary> + user: User | null | undefined + className?: string +}) { + const { className, contract, user } = props + + const userBets = useUserContractBets(user?.id, contract.id) + const [showSellModal, setShowSellModal] = useState(false) + + const { mechanism } = contract + const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( + contract, + userBets + ) + const floorShares = yesFloorShares || noFloorShares + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined + + if (sharesOutcome && user && mechanism === 'cpmm-1') { + return ( + <div> + <Col className={className}> + <Row className="items-center justify-between gap-2 "> + <div> + You have {formatWithCommas(floorShares)}{' '} + <OutcomeLabel + outcome={sharesOutcome} + contract={contract} + truncate={'short'} + />{' '} + shares + </div> + + <button + className="btn btn-sm" + style={{ + backgroundColor: 'white', + border: '2px solid', + color: '#3D4451', + }} + onClick={() => setShowSellModal(true)} + > + Sell + </button> + </Row> + </Col> + {showSellModal && ( + <SellSharesModal + contract={contract as FullContract<CPMM, Binary>} + user={user} + userBets={userBets ?? []} + shares={yesShares || noShares} + sharesOutcome={sharesOutcome} + setOpen={setShowSellModal} + /> + )} + </div> + ) + } + + return <div /> +} diff --git a/web/components/use-save-shares.ts b/web/components/use-save-shares.ts new file mode 100644 index 00000000..467e19ae --- /dev/null +++ b/web/components/use-save-shares.ts @@ -0,0 +1,59 @@ +import { Binary, CPMM, DPM, FullContract } from '../../common/contract' +import { Bet } from '../../common/bet' +import { useEffect, useState } from 'react' +import _ from 'lodash' + +export const useSaveShares = ( + contract: FullContract<CPMM | DPM, Binary>, + userBets: Bet[] | undefined +) => { + const [savedShares, setSavedShares] = useState< + | { + yesShares: number + noShares: number + yesFloorShares: number + noFloorShares: number + } + | undefined + >() + + const [yesBets, noBets] = _.partition( + userBets ?? [], + (bet) => bet.outcome === 'YES' + ) + const [yesShares, noShares] = [ + _.sumBy(yesBets, (bet) => bet.shares), + _.sumBy(noBets, (bet) => bet.shares), + ] + + const [yesFloorShares, noFloorShares] = [ + Math.floor(yesShares), + Math.floor(noShares), + ] + + useEffect(() => { + // Save yes and no shares to local storage. + const savedShares = localStorage.getItem(`${contract.id}-shares`) + if (!userBets && savedShares) { + setSavedShares(JSON.parse(savedShares)) + } + + if (userBets) { + const updatedShares = { yesShares, noShares } + localStorage.setItem( + `${contract.id}-shares`, + JSON.stringify(updatedShares) + ) + } + }, [contract.id, userBets, noShares, yesShares]) + + if (userBets) return { yesShares, noShares, yesFloorShares, noFloorShares } + return ( + savedShares ?? { + yesShares: 0, + noShares: 0, + yesFloorShares: 0, + noFloorShares: 0, + } + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 8cf45260..b73cffc4 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -87,7 +87,12 @@ export function UserPage(props: { user: User; currentUser?: User }) { <Col className="sm:flex-row sm:gap-4"> {user.website && ( - <SiteLink href={user.website}> + <SiteLink + href={ + 'https://' + + user.website.replace('http://', '').replace('https://', '') + } + > <Row className="items-center gap-1"> <LinkIcon className="h-4 w-4" /> <span className="text-sm text-gray-500">{user.website}</span> @@ -96,7 +101,13 @@ export function UserPage(props: { user: User; currentUser?: User }) { )} {user.twitterHandle && ( - <SiteLink href={`https://twitter.com/${user.twitterHandle}`}> + <SiteLink + href={`https://twitter.com/${user.twitterHandle + .replace('https://www.twitter.com/', '') + .replace('https://twitter.com/', '') + .replace('www.twitter.com/', '') + .replace('twitter.com/', '')}`} + > <Row className="items-center gap-1"> <img src="/twitter-logo.svg" diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index d2e7ae41..4bb7949f 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -9,40 +9,57 @@ export function YesNoSelector(props: { onSelect: (selected: 'YES' | 'NO') => void className?: string btnClassName?: string + replaceYesButton?: React.ReactNode + replaceNoButton?: React.ReactNode }) { - const { selected, onSelect, className, btnClassName } = props + const { + selected, + onSelect, + className, + btnClassName, + replaceNoButton, + replaceYesButton, + } = props const commonClassNames = 'inline-flex flex-1 items-center justify-center rounded-3xl border-2 p-2' return ( <Row className={clsx('space-x-3', className)}> - <button - className={clsx( - commonClassNames, - 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', - selected == 'YES' - ? 'bg-primary text-white' - : 'text-primary bg-transparent', - btnClassName - )} - onClick={() => onSelect('YES')} - > - Bet YES - </button> - <button - className={clsx( - commonClassNames, - 'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white', - selected == 'NO' - ? 'bg-red-400 text-white' - : 'bg-transparent text-red-400', - btnClassName - )} - onClick={() => onSelect('NO')} - > - Bet NO - </button> + {replaceYesButton ? ( + replaceYesButton + ) : ( + <button + className={clsx( + commonClassNames, + 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', + selected == 'YES' + ? 'bg-primary text-white' + : 'text-primary bg-transparent', + btnClassName + )} + onClick={() => onSelect('YES')} + > + Bet YES + </button> + )} + {replaceNoButton ? ( + replaceNoButton + ) : ( + <button + className={clsx( + commonClassNames, + 'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white', + selected == 'NO' + ? 'bg-red-400 text-white' + : 'bg-transparent text-red-400', + btnClassName + )} + onClick={() => onSelect('NO')} + > + Bet NO + </button> + )} </Row> ) } diff --git a/web/hooks/use-algo-feed.ts b/web/hooks/use-algo-feed.ts index 68958821..543f484b 100644 --- a/web/hooks/use-algo-feed.ts +++ b/web/hooks/use-algo-feed.ts @@ -8,6 +8,14 @@ import { logInterpolation } from '../../common/util/math' import { getRecommendedContracts } from '../../common/recommended-contracts' import { useSeenContracts } from './use-seen-contracts' import { useGetUserBetContractIds, useUserBetContracts } from './use-user-bets' +import { DAY_MS } from '../../common/util/time' +import { + getProbability, + getOutcomeProbability, + getTopAnswer, +} from '../../common/calculate' +import { useTimeSinceFirstRender } from './use-time-since-first-render' +import { trackLatency } from '../lib/firebase/tracking' const MAX_FEED_CONTRACTS = 75 @@ -29,8 +37,15 @@ export const useAlgoFeed = ( const [algoFeed, setAlgoFeed] = useState<Contract[]>([]) + const getTime = useTimeSinceFirstRender() + useEffect(() => { - if (initialContracts && initialBets && initialComments) { + if ( + initialContracts && + initialBets && + initialComments && + yourBetContractIds + ) { const eligibleContracts = initialContracts.filter( (c) => !c.isResolved && (c.closeTime ?? Infinity) > Date.now() ) @@ -42,6 +57,7 @@ export const useAlgoFeed = ( seenContracts ) setAlgoFeed(contracts) + trackLatency('feed', getTime()) } }, [ initialBets, @@ -49,6 +65,7 @@ export const useAlgoFeed = ( initialContracts, seenContracts, yourBetContractIds, + getTime, ]) return algoFeed @@ -120,11 +137,13 @@ function getContractsActivityScores( ) const scoredContracts = contracts.map((contract) => { + const { outcomeType } = contract + const seenTime = seenContracts[contract.id] const lastCommentTime = contractMostRecentComment[contract.id]?.createdTime const hasNewComments = !seenTime || (lastCommentTime && lastCommentTime > seenTime) - const newCommentScore = hasNewComments ? 1 : 0.75 + const newCommentScore = hasNewComments ? 1 : 0.5 const commentCount = contractComments[contract.id]?.length ?? 0 const betCount = contractBets[contract.id]?.length ?? 0 @@ -132,25 +151,39 @@ function getContractsActivityScores( const activityCountScore = 0.5 + 0.5 * logInterpolation(0, 200, activtyCount) - const lastBetTime = contractMostRecentBet[contract.id]?.createdTime - const timeSinceLastBet = !lastBetTime - ? contract.createdTime - : Date.now() - lastBetTime - const daysAgo = timeSinceLastBet / oneDayMs + const lastBetTime = + contractMostRecentBet[contract.id]?.createdTime ?? contract.createdTime + const timeSinceLastBet = Date.now() - lastBetTime + const daysAgo = timeSinceLastBet / DAY_MS const timeAgoScore = 1 - logInterpolation(0, 3, daysAgo) - const score = newCommentScore * activityCountScore * timeAgoScore + let prob = 0.5 + if (outcomeType === 'BINARY') { + prob = getProbability(contract) + } else if (outcomeType === 'FREE_RESPONSE') { + const topAnswer = getTopAnswer(contract) + if (topAnswer) + prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) + } + const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 + const probScore = 0.5 + frac * 0.5 + + const score = + newCommentScore * activityCountScore * timeAgoScore * probScore // Map score to [0.5, 1] since no recent activty is not a deal breaker. const mappedScore = 0.5 + score / 2 - return [contract.id, mappedScore] as [string, number] + const newMappedScore = 0.75 + score / 4 + + const isNew = Date.now() < contract.createdTime + DAY_MS + const activityScore = isNew ? newMappedScore : mappedScore + + return [contract.id, activityScore] as [string, number] }) return _.fromPairs(scoredContracts) } -const oneDayMs = 24 * 60 * 60 * 1000 - function getSeenContractsScore( contract: Contract, seenContracts: { [contractId: string]: number } @@ -160,7 +193,7 @@ function getSeenContractsScore( return 1 } - const daysAgo = (Date.now() - lastSeen) / oneDayMs + const daysAgo = (Date.now() - lastSeen) / DAY_MS if (daysAgo < 0.5) { const frac = logInterpolation(0, 0.5, daysAgo) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 26c43ed1..c6d2be0e 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -6,6 +6,7 @@ import { listenForContracts, listenForHotContracts, listenForInactiveContracts, + listenForNewContracts, } from '../lib/firebase/contracts' import { listenForTaggedContracts } from '../lib/firebase/folds' @@ -20,13 +21,22 @@ export const useContracts = () => { } export const useActiveContracts = () => { - const [contracts, setContracts] = useState<Contract[] | undefined>() + const [activeContracts, setActiveContracts] = useState< + Contract[] | undefined + >() + const [newContracts, setNewContracts] = useState<Contract[] | undefined>() useEffect(() => { - return listenForActiveContracts(setContracts) + return listenForActiveContracts(setActiveContracts) }, []) - return contracts + useEffect(() => { + return listenForNewContracts(setNewContracts) + }, []) + + if (!activeContracts || !newContracts) return undefined + + return [...activeContracts, ...newContracts] } export const useInactiveContracts = () => { diff --git a/web/hooks/use-time-since-first-render.ts b/web/hooks/use-time-since-first-render.ts new file mode 100644 index 00000000..da132146 --- /dev/null +++ b/web/hooks/use-time-since-first-render.ts @@ -0,0 +1,13 @@ +import { useCallback, useEffect, useRef } from 'react' + +export function useTimeSinceFirstRender() { + const startTimeRef = useRef(0) + useEffect(() => { + startTimeRef.current = Date.now() + }, []) + + return useCallback(() => { + if (!startTimeRef.current) return 0 + return Date.now() - startTimeRef.current + }, []) +} diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index 82606933..c11af097 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -54,13 +54,15 @@ export const useUserBetContracts = (userId: string | undefined) => { } export const useGetUserBetContractIds = (userId: string | undefined) => { - const [contractIds, setContractIds] = useState<string[]>([]) + const [contractIds, setContractIds] = useState<string[] | undefined>() useEffect(() => { - const key = `user-bet-contractIds-${userId}` - const userBetContractJson = localStorage.getItem(key) - if (userBetContractJson) { - setContractIds(JSON.parse(userBetContractJson)) + if (userId) { + const key = `user-bet-contractIds-${userId}` + const userBetContractJson = localStorage.getItem(key) + if (userBetContractJson) { + setContractIds(JSON.parse(userBetContractJson)) + } } }, [userId]) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index cc9118f9..1646bd09 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -22,6 +22,7 @@ import { getDpmProbability } from '../../../common/calculate-dpm' import { createRNG, shuffle } from '../../../common/util/random' import { getCpmmProbability } from '../../../common/calculate-cpmm' import { formatMoney, formatPercent } from '../../../common/util/format' +import { DAY_MS } from '../../../common/util/time' export type { Contract } export function contractPath(contract: Contract) { @@ -162,6 +163,19 @@ export function listenForInactiveContracts( return listenForValues<Contract>(inactiveContractsQuery, setContracts) } +const newContractsQuery = query( + contractCollection, + where('isResolved', '==', false), + where('volume7Days', '==', 0), + where('createdTime', '>', Date.now() - 7 * DAY_MS) +) + +export function listenForNewContracts( + setContracts: (contracts: Contract[]) => void +) { + return listenForValues<Contract>(newContractsQuery, setContracts) +} + export function listenForContract( contractId: string, setContract: (contract: Contract | null) => void diff --git a/web/lib/firebase/tracking.ts b/web/lib/firebase/tracking.ts index 4d609f68..034ad09d 100644 --- a/web/lib/firebase/tracking.ts +++ b/web/lib/firebase/tracking.ts @@ -2,7 +2,7 @@ import { doc, collection, setDoc } from 'firebase/firestore' import _ from 'lodash' import { db } from './init' -import { ClickEvent, View } from '../../../common/tracking' +import { ClickEvent, LatencyEvent, View } from '../../../common/tracking' import { listenForLogin, User } from './users' let user: User | null = null @@ -34,3 +34,19 @@ export async function trackClick(contractId: string) { return await setDoc(ref, clickEvent) } + +export async function trackLatency( + type: 'feed' | 'portfolio', + latency: number +) { + if (!user) return + const ref = doc(collection(db, 'private-users', user.id, 'latency')) + + const latencyEvent: LatencyEvent = { + type, + latency, + timestamp: Date.now(), + } + + return await setDoc(ref, latencyEvent) +} diff --git a/web/package.json b/web/package.json index 60ee1c77..2b042142 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"", - "devdev": "NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=DEV next dev -p 3000\" \"FIREBASE_ENV=DEV yarn ts --watch\"", + "devdev": "NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=DEV next dev -p 3000\" \"FIREBASE_ENV=DEV yarn ts --watch\" # see https://github.com/vercel/next.js/discussions/33634", "dev:dev": "yarn devdev", "dev:the": "NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"FIREBASE_ENV=THEOREMONE yarn ts --watch\"", "ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty", diff --git a/web/pages/404.tsx b/web/pages/404.tsx index f710263d..3d646902 100644 --- a/web/pages/404.tsx +++ b/web/pages/404.tsx @@ -19,7 +19,6 @@ export default function Custom404() { src="https://discord.com/widget?id=915138780216823849&theme=dark" width="350" height="500" - allowTransparency={true} frameBorder="0" sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts" ></iframe> diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 8208e652..5ec48571 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -18,7 +18,6 @@ import { } from '../../lib/firebase/contracts' import { SEO } from '../../components/SEO' import { Page } from '../../components/page' -import { contractTextDetails } from '../../components/contract/contract-card' import { Bet, listAllBets } from '../../lib/firebase/bets' import { Comment, listAllComments } from '../../lib/firebase/comments' import Custom404 from '../404' @@ -33,6 +32,7 @@ import { useUserById } from '../../hooks/use-users' 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' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { diff --git a/web/pages/create.tsx b/web/pages/create.tsx index e5a295af..b668fffb 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -8,10 +8,8 @@ import { Spacer } from '../components/layout/spacer' import { useUser } from '../hooks/use-user' import { Contract, contractPath } from '../lib/firebase/contracts' import { createContract } from '../lib/firebase/api-call' -import { BuyAmountInput } from '../components/amount-input' import { FIXED_ANTE, MINIMUM_ANTE } from '../../common/antes' import { InfoTooltip } from '../components/info-tooltip' -import { CREATOR_FEE } from '../../common/fees' import { Page } from '../components/page' import { Title } from '../components/title' import { ProbabilitySelector } from '../components/probability-selector' @@ -39,6 +37,7 @@ export default function Create() { <Textarea placeholder="e.g. Will the Democrats win the 2024 US presidential election?" className="input input-bordered resize-none" + autoFocus value={question} onChange={(e) => setQuestion(e.target.value || '')} /> diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index fe363146..11b47149 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -9,9 +9,9 @@ import { DOMAIN } from '../../../../common/envs/constants' import { AnswersGraph } from '../../../components/answers/answers-graph' import { BinaryResolutionOrChance, - ContractDetails, FreeResponseResolutionOrChance, } from '../../../components/contract/contract-card' +import { ContractDetails } from '../../../components/contract/contract-details' import { ContractProbGraph } from '../../../components/contract/contract-prob-graph' import { Col } from '../../../components/layout/col' import { Row } from '../../../components/layout/row' diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 199f39d4..31c0c533 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,9 +1,10 @@ const defaultTheme = require('tailwindcss/defaultTheme') module.exports = { - mode: 'jit', - purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], - darkMode: false, // or 'media' or 'class' + content: [ + './pages/**/*.{js,ts,jsx,tsx}', + './components/**/*.{js,ts,jsx,tsx}', + ], theme: { fontFamily: Object.assign( { ...defaultTheme.fontFamily }, @@ -18,9 +19,6 @@ module.exports = { }, }, }, - variants: { - extend: {}, - }, plugins: [ require('@tailwindcss/forms'), require('@tailwindcss/typography'), diff --git a/web/tsconfig.json b/web/tsconfig.json index dee57196..643de6e5 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -16,5 +16,5 @@ "incremental": true }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../common/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".next"] }