From c0383bcf26832fbd2498358f457d69ee3cfaae67 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sat, 3 Sep 2022 09:55:10 -0700 Subject: [PATCH 01/62] Make tournament page efficient (#832) * Make tournament page efficient * Fix URL to Salem contract * Use totalMembers instead of deprecated field * Increase page size to 12 Co-authored-by: Austin Chen --- web/components/carousel.tsx | 2 +- web/hooks/use-pagination.ts | 1 + web/lib/firebase/contracts.ts | 11 +- web/pages/tournaments/index.tsx | 243 +++++++++++++++++--------------- 4 files changed, 137 insertions(+), 120 deletions(-) diff --git a/web/components/carousel.tsx b/web/components/carousel.tsx index 9719ba06..79baa451 100644 --- a/web/components/carousel.tsx +++ b/web/components/carousel.tsx @@ -33,7 +33,7 @@ export function Carousel(props: { }, 500) // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(onScroll, []) + useEffect(onScroll, [children]) return (
diff --git a/web/hooks/use-pagination.ts b/web/hooks/use-pagination.ts index 485afca8..ab991d1f 100644 --- a/web/hooks/use-pagination.ts +++ b/web/hooks/use-pagination.ts @@ -103,6 +103,7 @@ export const usePagination = (opts: PaginationOptions) => { isEnd: state.isComplete && state.pageEnd >= state.docs.length, getPrev: () => dispatch({ type: 'PREV' }), getNext: () => dispatch({ type: 'NEXT' }), + allItems: () => state.docs.map((d) => d.data()), getItems: () => state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()), } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index c7e32f71..5c65b23f 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -104,11 +104,18 @@ export async function listContracts(creatorId: string): Promise { return snapshot.docs.map((doc) => doc.data()) } +export const contractsByGroupSlugQuery = (slug: string) => + query( + contracts, + where('groupSlugs', 'array-contains', slug), + where('isResolved', '==', false), + orderBy('popularityScore', 'desc') + ) + export async function listContractsByGroupSlug( slug: string ): Promise { - const q = query(contracts, where('groupSlugs', 'array-contains', slug)) - const snapshot = await getDocs(q) + const snapshot = await getDocs(contractsByGroupSlugQuery(slug)) return snapshot.docs.map((doc) => doc.data()) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 9bfdfb89..c9827f72 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -1,16 +1,10 @@ import { ClockIcon } from '@heroicons/react/outline' import { UsersIcon } from '@heroicons/react/solid' -import { - BinaryContract, - Contract, - PseudoNumericContract, -} from 'common/contract' -import { Group } from 'common/group' -import dayjs, { Dayjs } from 'dayjs' +import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { keyBy, mapValues, sortBy } from 'lodash' +import { zip } from 'lodash' import Image, { ImageProps, StaticImageData } from 'next/image' import Link from 'next/link' import { useState } from 'react' @@ -20,27 +14,33 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' -import { listContractsByGroupSlug } from 'web/lib/firebase/contracts' +import { contractsByGroupSlugQuery } from 'web/lib/firebase/contracts' import { getGroup, groupPath } from 'web/lib/firebase/groups' import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png' import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png' import mpox_pic from './_cspi/Monkeypox_Cases.png' import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png' import { SiteLink } from 'web/components/site-link' -import { getProbability } from 'common/calculate' import { Carousel } from 'web/components/carousel' +import { usePagination } from 'web/hooks/use-pagination' +import { LoadingIndicator } from 'web/components/loading-indicator' dayjs.extend(utc) dayjs.extend(timezone) dayjs.extend(customParseFormat) -const toDate = (d: string) => dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles') +const toDate = (d: string) => + dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles').valueOf() + +type MarketImage = { + marketUrl: string + image: StaticImageData +} type Tourney = { title: string - url?: string blurb: string // actual description in the click-through award?: string - endTime?: Dayjs + endTime?: number groupId: string } @@ -50,7 +50,7 @@ const Salem = { url: 'https://salemcenter.manifold.markets/', award: '$25,000', endTime: toDate('Jul 31, 2023'), - markets: [], + contractIds: [], images: [ { marketUrl: @@ -107,33 +107,27 @@ const tourneys: Tourney[] = [ // }, ] -export async function getStaticProps() { - const groupIds = tourneys - .map((data) => data.groupId) - .filter((id) => id != undefined) as string[] - const groups = (await Promise.all(groupIds.map(getGroup))) - // Then remove undefined groups - .filter(Boolean) as Group[] - - const contracts = await Promise.all( - groups.map((g) => listContractsByGroupSlug(g?.slug ?? '')) - ) - - const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]])) - - const groupMap = keyBy(groups, 'id') - const numPeople = mapValues(groupMap, (g) => g?.totalMembers) - const slugs = mapValues(groupMap, 'slug') - - return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 } +type SectionInfo = { + tourney: Tourney + slug: string + numPeople: number } -export default function TournamentPage(props: { - markets: { [groupId: string]: Contract[] } - numPeople: { [groupId: string]: number } - slugs: { [groupId: string]: string } -}) { - const { markets = {}, numPeople = {}, slugs = {} } = props +export async function getStaticProps() { + const groupIds = tourneys.map((data) => data.groupId) + const groups = await Promise.all(groupIds.map(getGroup)) + const sections = zip(tourneys, groups) + .filter(([_tourney, group]) => group != null) + .map(([tourney, group]) => ({ + tourney, + slug: group!.slug, // eslint-disable-line + numPeople: group!.totalMembers, // eslint-disable-line + })) + return { props: { sections } } +} + +export default function TournamentPage(props: { sections: SectionInfo[] }) { + const { sections } = props return ( @@ -141,96 +135,111 @@ export default function TournamentPage(props: { title="Tournaments" description="Win money by betting in forecasting touraments on current events, sports, science, and more" /> - - {tourneys.map(({ groupId, ...data }) => ( -
+ + {sections.map(({ tourney, slug, numPeople }) => ( +
+ + {tourney.blurb} + +
))} -
+
+ + {Salem.blurb} + +
) } -function Section(props: { - title: string +const SectionHeader = (props: { url: string - blurb: string - award?: string + title: string ppl?: number - endTime?: Dayjs - markets: Contract[] - images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi -}) { - const { title, url, blurb, award, ppl, endTime, images } = props - // Sort markets by probability, highest % first - const markets = sortBy(props.markets, (c) => - getProbability(c as BinaryContract | PseudoNumericContract) - ) - .reverse() - .filter((c) => !c.isResolved) - + award?: string + endTime?: number +}) => { + const { url, title, ppl, award, endTime } = props return ( -
- - -

- {title} -

- - {!!award && 🏆 {award}} - {!!ppl && ( + +
+

+ {title} +

+ + {!!award && 🏆 {award}} + {!!ppl && ( + + + {ppl} + + )} + {endTime && ( + - - {ppl} + + {dayjs(endTime).format('MMM D')} - )} - {endTime && ( - - - - {endTime.format('MMM D')} - - - )} - + + )} + +
+ + ) +} + +const ImageCarousel = (props: { images: MarketImage[]; url: string }) => { + const { images, url } = props + return ( + +
+ {images.map(({ marketUrl, image }) => ( + + - - {blurb} - -
- {markets.length ? ( - markets.map((m) => ( - - )) - ) : ( - <> - {images?.map(({ marketUrl, image }) => ( - - - - ))} - - See more - - - )} - -
+ ))} + + See more + +
+ ) +} + +const MarketCarousel = (props: { slug: string }) => { + const { slug } = props + const q = contractsByGroupSlugQuery(slug) + const { allItems, getNext, isLoading } = usePagination({ q, pageSize: 12 }) + return isLoading ? ( + + ) : ( + +
+ {allItems().map((m) => ( + + ))} + ) } From 085b9aeb2a7d50f70dd8842def8bcf41d388e450 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 3 Sep 2022 14:55:37 -0500 Subject: [PATCH 02/62] remove simulator --- web/lib/simulator/entries.ts | 73 ------- web/lib/simulator/sample-bids.ts | 58 ------ web/pages/simulator.tsx | 332 ------------------------------- 3 files changed, 463 deletions(-) delete mode 100644 web/lib/simulator/entries.ts delete mode 100644 web/lib/simulator/sample-bids.ts delete mode 100644 web/pages/simulator.tsx diff --git a/web/lib/simulator/entries.ts b/web/lib/simulator/entries.ts deleted file mode 100644 index 535a59ad..00000000 --- a/web/lib/simulator/entries.ts +++ /dev/null @@ -1,73 +0,0 @@ -type Bid = { yesBid: number; noBid: number } - -// An entry has a yes/no for bid, weight, payout, return. Also a current probability -export type Entry = { - yesBid: number - noBid: number - yesWeight: number - noWeight: number - yesPayout: number - noPayout: number - yesReturn: number - noReturn: number - prob: number -} - -function makeWeights(bids: Bid[]) { - const weights = [] - let yesPot = 0 - let noPot = 0 - - // First pass: calculate all the weights - for (const { yesBid, noBid } of bids) { - const yesWeight = - yesBid + - (yesBid * Math.pow(noPot, 2)) / - (Math.pow(yesPot, 2) + yesBid * yesPot) || 0 - const noWeight = - noBid + - (noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) || - 0 - - // Note: Need to calculate weights BEFORE updating pot - yesPot += yesBid - noPot += noBid - const prob = - Math.pow(yesPot, 2) / (Math.pow(yesPot, 2) + Math.pow(noPot, 2)) - - weights.push({ - yesBid, - noBid, - yesWeight, - noWeight, - prob, - }) - } - return weights -} - -export function makeEntries(bids: Bid[]): Entry[] { - const YES_SEED = bids[0].yesBid - const NO_SEED = bids[0].noBid - - const weights = makeWeights(bids) - const yesPot = weights.reduce((sum, { yesBid }) => sum + yesBid, 0) - const noPot = weights.reduce((sum, { noBid }) => sum + noBid, 0) - const yesWeightsSum = weights.reduce((sum, entry) => sum + entry.yesWeight, 0) - const noWeightsSum = weights.reduce((sum, entry) => sum + entry.noWeight, 0) - - const potSize = yesPot + noPot - YES_SEED - NO_SEED - - // Second pass: calculate all the payouts - const entries: Entry[] = [] - - for (const weight of weights) { - const { yesBid, noBid, yesWeight, noWeight } = weight - const yesPayout = (yesWeight / yesWeightsSum) * potSize - const noPayout = (noWeight / noWeightsSum) * potSize - const yesReturn = (yesPayout - yesBid) / yesBid - const noReturn = (noPayout - noBid) / noBid - entries.push({ ...weight, yesPayout, noPayout, yesReturn, noReturn }) - } - return entries -} diff --git a/web/lib/simulator/sample-bids.ts b/web/lib/simulator/sample-bids.ts deleted file mode 100644 index 547e6dce..00000000 --- a/web/lib/simulator/sample-bids.ts +++ /dev/null @@ -1,58 +0,0 @@ -const data = `1,9 -8, -,1 -1, -,1 -1, -,5 -5, -,5 -5, -,1 -1, -100, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10, -,10 -10,` - -// Parse data into Yes/No orders -// E.g. `8,\n,1\n1,` => -// [{yesBid: 8, noBid: 0}, {yesBid: 0, noBid: 1}, {yesBid: 1, noBid: 0}] -export const bids = data.split('\n').map((line) => { - const [yesBid, noBid] = line.split(',') - return { - yesBid: parseInt(yesBid || '0'), - noBid: parseInt(noBid || '0'), - } -}) diff --git a/web/pages/simulator.tsx b/web/pages/simulator.tsx deleted file mode 100644 index 756e483b..00000000 --- a/web/pages/simulator.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import React, { useMemo, useState } from 'react' -import { DatumValue } from '@nivo/core' -import { ResponsiveLine } from '@nivo/line' - -import { Entry, makeEntries } from 'web/lib/simulator/entries' -import { Col } from 'web/components/layout/col' - -function TableBody(props: { entries: Entry[] }) { - return ( - - {props.entries.map((entry, i) => ( - - {props.entries.length - i} - - - - ))} - - ) -} - -function TableRowStart(props: { entry: Entry }) { - const { entry } = props - if (entry.yesBid && entry.noBid) { - return ( - <> - -
ANTE
- - - ${entry.yesBid} / ${entry.noBid} - - - ) - } else if (entry.yesBid) { - return ( - <> - -
YES
- - ${entry.yesBid} - - ) - } else { - return ( - <> - -
NO
- - ${entry.noBid} - - ) - } -} - -function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { - const { entry } = props - if (!entry) { - return ( - <> - 0 - 0 - {!props.isNew && ( - <> - N/A - N/A - - )} - - ) - } else if (entry.yesBid && entry.noBid) { - return ( - <> - {(entry.prob * 100).toFixed(1)}% - N/A - {!props.isNew && ( - <> - N/A - N/A - - )} - - ) - } else if (entry.yesBid) { - return ( - <> - {(entry.prob * 100).toFixed(1)}% - ${entry.yesWeight.toFixed(0)} - {!props.isNew && ( - <> - ${entry.yesPayout.toFixed(0)} - {(entry.yesReturn * 100).toFixed(0)}% - - )} - - ) - } else { - return ( - <> - {(entry.prob * 100).toFixed(1)}% - ${entry.noWeight.toFixed(0)} - {!props.isNew && ( - <> - ${entry.noPayout.toFixed(0)} - {(entry.noReturn * 100).toFixed(0)}% - - )} - - ) - } -} - -type Bid = { yesBid: number; noBid: number } - -function NewBidTable(props: { - steps: number - bids: Array - setSteps: (steps: number) => void - setBids: (bids: Array) => void -}) { - const { steps, bids, setSteps, setBids } = props - // Prepare for new bids - const [newBid, setNewBid] = useState(0) - const [newBidType, setNewBidType] = useState('YES') - - function makeBid(type: string, bid: number) { - return { - yesBid: type == 'YES' ? bid : 0, - noBid: type == 'YES' ? 0 : bid, - } - } - - function submitBid() { - if (newBid <= 0) return - const bid = makeBid(newBidType, newBid) - bids.splice(steps, 0, bid) - setBids(bids) - setSteps(steps + 1) - setNewBid(0) - } - - function toggleBidType() { - setNewBidType(newBidType === 'YES' ? 'NO' : 'YES') - } - - const nextBid = makeBid(newBidType, newBid) - const fakeBids = [...bids.slice(0, steps), nextBid] - const entries = makeEntries(fakeBids) - const nextEntry = entries[entries.length - 1] - - function randomBid() { - const bidType = Math.random() < 0.5 ? 'YES' : 'NO' - // const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob - - const amount = Math.floor(Math.random() * 300) + 1 - const bid = makeBid(bidType, amount) - - bids.splice(steps, 0, bid) - setBids(bids) - setSteps(steps + 1) - setNewBid(0) - } - - return ( - <> - - - - - - - - - - - - - - - - - - - - - - -
Order #TypeBetProbEst Payout
{steps + 1} -
- YES -
-
-
- NO -
-
- {/* Note: Would love to make this input smaller... */} - setNewBid(parseInt(e.target.value) || 0)} - onKeyUp={(e) => { - if (e.key === 'Enter') { - submitBid() - } - }} - onFocus={(e) => e.target.select()} - /> -
- - - - ) -} - -// Show a hello world React page -export default function Simulator() { - const [steps, setSteps] = useState(1) - const [bids, setBids] = useState([{ yesBid: 100, noBid: 100 }]) - - const entries = useMemo( - () => makeEntries(bids.slice(0, steps)), - [bids, steps] - ) - - const reversedEntries = [...entries].reverse() - - const probs = entries.map((entry) => entry.prob) - const points = probs.map((prob, i) => ({ x: i + 1, y: prob * 100 })) - const data = [{ id: 'Yes', data: points, color: '#11b981' }] - const tickValues = [0, 25, 50, 75, 100] - - return ( - -
- {/* Left column */} -
-

- Dynamic Parimutuel Market Simulator -

- - - - {/* History of bids */} -
- - - - - - - - - - - - - - -
Order #TypeBetProbEst PayoutPayoutReturn
-
-
- - {/* Right column */} - -

- Probability of -
- YES -
-

-
- -
- {/* Range slider that sets the current step */} - - setSteps(parseInt(e.target.value))} - /> - -
- - ) -} - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} From 9060abde8ec41a6cd37faf71c7b9328928b94558 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 3 Sep 2022 15:06:41 -0500 Subject: [PATCH 03/62] Cache prob and prob changes on cpmm contracts --- common/calculate-metrics.ts | 29 ++++++++++++++++++++++++++++- common/contract.ts | 6 ++++++ common/new-contract.ts | 2 ++ functions/src/update-metrics.ts | 24 ++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index e3b8ea39..3aad1a9c 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,4 +1,4 @@ -import { sortBy, sum, sumBy } from 'lodash' +import { last, sortBy, sum, sumBy } from 'lodash' import { calculatePayout } from './calculate' import { Bet } from './bet' import { Contract } from './contract' @@ -36,6 +36,33 @@ export const computeVolume = (contractBets: Bet[], since: number) => { ) } +const calculateProbChangeSince = (descendingBets: Bet[], since: number) => { + const newestBet = descendingBets[0] + if (!newestBet) return 0 + + const betBeforeSince = descendingBets.find((b) => b.createdTime < since) + + if (!betBeforeSince) { + const oldestBet = last(descendingBets) ?? newestBet + return newestBet.probAfter - oldestBet.probBefore + } + + return newestBet.probAfter - betBeforeSince.probAfter +} + +export const calculateProbChanges = (descendingBets: Bet[]) => { + const now = Date.now() + const yesterday = now - DAY_MS + const weekAgo = now - 7 * DAY_MS + const monthAgo = now - 30 * DAY_MS + + return { + day: calculateProbChangeSince(descendingBets, yesterday), + week: calculateProbChangeSince(descendingBets, weekAgo), + month: calculateProbChangeSince(descendingBets, monthAgo), + } +} + export const calculateCreatorVolume = (userContracts: Contract[]) => { const allTimeCreatorVolume = computeTotalPool(userContracts, 0) const monthlyCreatorVolume = computeTotalPool( diff --git a/common/contract.ts b/common/contract.ts index 5dc4b696..0d2a38ca 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -87,6 +87,12 @@ export type CPMM = { pool: { [outcome: string]: number } p: number // probability constant in y^p * n^(1-p) = k totalLiquidity: number // in M$ + prob: number + probChanges: { + day: number + week: number + month: number + } } export type Binary = { diff --git a/common/new-contract.ts b/common/new-contract.ts index 17b872ab..431f435e 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { initialProbability: p, p, pool: pool, + prob: initialProb, + probChanges: { day: 0, week: 0, month: 0 }, } return system diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index c6673969..430f3d33 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,9 +1,9 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last } from 'lodash' +import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' -import { Contract } from '../../common/contract' +import { Contract, CPMM } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' @@ -11,8 +11,10 @@ import { calculateCreatorVolume, calculateNewPortfolioMetrics, calculateNewProfit, + calculateProbChanges, computeVolume, } from '../../common/calculate-metrics' +import { getProbability } from '../../common/calculate' const firestore = admin.firestore() @@ -43,11 +45,29 @@ export async function updateMetricsCore() { .filter((contract) => contract.id) .map((contract) => { const contractBets = betsByContract[contract.id] ?? [] + const descendingBets = sortBy( + contractBets, + (bet) => bet.createdTime + ).reverse() + + let cpmmFields: Partial = {} + if (contract.mechanism === 'cpmm-1') { + const prob = descendingBets[0] + ? descendingBets[0].probAfter + : getProbability(contract) + + cpmmFields = { + prob, + probChanges: calculateProbChanges(descendingBets), + } + } + return { doc: firestore.collection('contracts').doc(contract.id), fields: { volume24Hours: computeVolume(contractBets, now - DAY_MS), volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + ...cpmmFields, }, } }) From 89b30fc50d5b4dcf92c850dcc2c295b1679ae819 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sat, 3 Sep 2022 14:07:34 -0700 Subject: [PATCH 04/62] Fix tournaments page loading indicator and turn page size back down --- web/pages/tournaments/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index c9827f72..1a74e8ea 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -223,13 +223,16 @@ const ImageCarousel = (props: { images: MarketImage[]; url: string }) => { const MarketCarousel = (props: { slug: string }) => { const { slug } = props const q = contractsByGroupSlugQuery(slug) - const { allItems, getNext, isLoading } = usePagination({ q, pageSize: 12 }) - return isLoading ? ( + const { allItems, getNext } = usePagination({ q, pageSize: 6 }) + const items = allItems() + + // todo: would be nice to have indicator somewhere when it loads next page + return items.length === 0 ? ( ) : (
- {allItems().map((m) => ( + {items.map((m) => ( Date: Sat, 3 Sep 2022 16:20:56 -0500 Subject: [PATCH 05/62] Add sort for 24 hour change in probability --- web/components/contract-search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index a0396d2e..8ace85eb 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -43,6 +43,7 @@ export const SORTS = [ { label: 'Trending', value: 'score' }, { label: 'Most traded', value: 'most-traded' }, { label: '24h volume', value: '24-hour-vol' }, + { label: '24h change', value: 'prob-change-day' }, { label: 'Last updated', value: 'last-updated' }, { label: 'Subsidy', value: 'liquidity' }, { label: 'Close date', value: 'close-date' }, From a15230e7ab77b7b89a23132896fa6be64f5742ce Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 4 Sep 2022 14:06:29 -0500 Subject: [PATCH 06/62] Smartest money => Best bet. Don't show amount made for comment. --- web/components/contract/contract-leaderboard.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index cc253433..ce5c7da6 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -109,10 +109,6 @@ export function ContractTopTrades(props: { betsBySameUser={[betsById[topCommentId]]} />
-
- {commentsById[topCommentId].userName} made{' '} - {formatMoney(profitById[topCommentId] || 0)}! -
)} @@ -120,11 +116,11 @@ export function ContractTopTrades(props: { {/* If they're the same, only show the comment; otherwise show both */} {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( <> - + <Title text="💸 Best bet" className="!mt-0" /> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> <FeedBet contract={contract} bet={betsById[topBetId]} /> </div> - <div className="mt-2 text-sm text-gray-500"> + <div className="mt-2 ml-2 text-sm text-gray-500"> {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! </div> </> From 6ef2beed8f0d9f1805ed71c6ddcff87819de05a6 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 4 Sep 2022 14:28:45 -0700 Subject: [PATCH 07/62] Denormalize `betAmount` and `betOutcome` fields on comments (#838) * Create and use `betAmount` and `betOutcome` fields on comments * Be robust to ridiculous bet IDs on dev --- common/comment.ts | 10 ++- .../src/on-create-comment-on-contract.ts | 6 +- .../scripts/denormalize-comment-bet-data.ts | 69 +++++++++++++++++++ web/components/feed/feed-comments.tsx | 20 +++--- 4 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 functions/src/scripts/denormalize-comment-bet-data.ts diff --git a/common/comment.ts b/common/comment.ts index c7f9b855..3a4bd9ac 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -23,10 +23,16 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = { type OnContract = { commentType: 'contract' contractId: string - contractSlug: string - contractQuestion: string answerOutcome?: string betId?: string + + // denormalized from contract + contractSlug: string + contractQuestion: string + + // denormalized from bet + betAmount?: number + betOutcome?: string } type OnGroup = { diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 663a7977..a36a8bca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions .doc(comment.betId) .get() bet = betSnapshot.data() as Bet - answer = contract.outcomeType === 'FREE_RESPONSE' && contract.answers ? contract.answers.find((answer) => answer.id === bet?.outcome) : undefined + + await change.ref.update({ + betOutcome: bet.outcome, + betAmount: bet.amount, + }) } const comments = await getValues<ContractComment>( diff --git a/functions/src/scripts/denormalize-comment-bet-data.ts b/functions/src/scripts/denormalize-comment-bet-data.ts new file mode 100644 index 00000000..929626c3 --- /dev/null +++ b/functions/src/scripts/denormalize-comment-bet-data.ts @@ -0,0 +1,69 @@ +// Filling in the bet-based fields on comments. + +import * as admin from 'firebase-admin' +import { zip } from 'lodash' +import { initAdmin } from './script-init' +import { + DocumentCorrespondence, + findDiffs, + describeDiff, + applyDiff, +} from './denormalize' +import { log } from '../utils' +import { Transaction } from 'firebase-admin/firestore' + +initAdmin() +const firestore = admin.firestore() + +async function getBetComments(transaction: Transaction) { + const allComments = await transaction.get( + firestore.collectionGroup('comments') + ) + const betComments = allComments.docs.filter((d) => d.get('betId')) + log(`Found ${betComments.length} comments associated with bets.`) + return betComments +} + +async function denormalize() { + let hasMore = true + while (hasMore) { + hasMore = await admin.firestore().runTransaction(async (trans) => { + const betComments = await getBetComments(trans) + const bets = await Promise.all( + betComments.map((doc) => + trans.get( + firestore + .collection('contracts') + .doc(doc.get('contractId')) + .collection('bets') + .doc(doc.get('betId')) + ) + ) + ) + log(`Found ${bets.length} bets associated with comments.`) + const mapping = zip(bets, betComments) + .map(([bet, comment]): DocumentCorrespondence => { + return [bet!, [comment!]] // eslint-disable-line + }) + .filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs + + const amountDiffs = findDiffs(mapping, 'amount', 'betAmount') + const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome') + log(`Found ${amountDiffs.length} comments with mismatched amounts.`) + log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`) + const diffs = amountDiffs.concat(outcomeDiffs) + diffs.slice(0, 500).forEach((d) => { + log(describeDiff(d)) + applyDiff(trans, d) + }) + if (diffs.length > 500) { + console.log(`Applying first 500 because of Firestore limit...`) + } + return diffs.length > 500 + }) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 1aebb27b..fa2cc6f5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -125,15 +125,12 @@ export function FeedComment(props: { } = props const { text, content, userUsername, userName, userAvatarUrl, createdTime } = comment - let betOutcome: string | undefined, - bought: string | undefined, - money: string | undefined - - const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId) - if (matchedBet) { - betOutcome = matchedBet.outcome - bought = matchedBet.amount >= 0 ? 'bought' : 'sold' - money = formatMoney(Math.abs(matchedBet.amount)) + const betOutcome = comment.betOutcome + let bought: string | undefined + let money: string | undefined + if (comment.betAmount != null) { + bought = comment.betAmount >= 0 ? 'bought' : 'sold' + money = formatMoney(Math.abs(comment.betAmount)) } const [highlighted, setHighlighted] = useState(false) @@ -148,7 +145,7 @@ export function FeedComment(props: { const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( contract, comment.createdTime, - matchedBet ? [] : betsBySameUser + comment.betId ? [] : betsBySameUser ) return ( @@ -175,7 +172,7 @@ export function FeedComment(props: { username={userUsername} name={userName} />{' '} - {!matchedBet && + {!comment.betId != null && userPosition > 0 && contract.outcomeType !== 'NUMERIC' && ( <> @@ -194,7 +191,6 @@ export function FeedComment(props: { of{' '} <OutcomeLabel outcome={betOutcome ? betOutcome : ''} - value={(matchedBet as any).value} contract={contract} truncate="short" /> From 70eec6353367e1057cf69cc50e5469c5d26ce4b3 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 5 Sep 2022 10:07:33 -0700 Subject: [PATCH 08/62] Adding in "Highest %" and "Lowest %" sort options Quick alternative to https://github.com/manifoldmarkets/manifold/pull/850/files courtesy of James. One downside of this approach is that the % only update every 15 minutes; but maybe users won't notice? --- web/components/contract-search.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8ace85eb..0beedc1b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -48,6 +48,8 @@ export const SORTS = [ { label: 'Subsidy', value: 'liquidity' }, { label: 'Close date', value: 'close-date' }, { label: 'Resolve date', value: 'resolve-date' }, + { label: 'Highest %', value: 'prob-descending' }, + { label: 'Lowest %', value: 'prob-ascending' }, ] as const export type Sort = typeof SORTS[number]['value'] From 9a49c0b8fe99435023a16873aea3b6211e42018b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 13:33:58 -0500 Subject: [PATCH 09/62] remove numeric, multiple choice markets from create market page --- web/pages/create.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index b5892ccf..7e1ead90 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -290,9 +290,9 @@ export function NewContract(props: { }} choicesMap={{ 'Yes / No': 'BINARY', - 'Multiple choice': 'MULTIPLE_CHOICE', + // 'Multiple choice': 'MULTIPLE_CHOICE', 'Free response': 'FREE_RESPONSE', - Numeric: 'PSEUDO_NUMERIC', + // Numeric: 'PSEUDO_NUMERIC', }} isSubmitting={isSubmitting} className={'col-span-4'} From d812776357203442628470dc54626309a92aa51e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 5 Sep 2022 16:25:46 -0500 Subject: [PATCH 10/62] Remove show hot volume param --- web/components/contract/contract-card.tsx | 3 --- web/components/contract/contract-details.tsx | 22 +++++--------------- web/pages/experimental/home/index.tsx | 2 +- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index e7c26fe0..dab92a7a 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -35,7 +35,6 @@ import { Tooltip } from '../tooltip' export function ContractCard(props: { contract: Contract - showHotVolume?: boolean showTime?: ShowTime className?: string questionClass?: string @@ -45,7 +44,6 @@ export function ContractCard(props: { trackingPostfix?: string }) { const { - showHotVolume, showTime, className, questionClass, @@ -147,7 +145,6 @@ export function ContractCard(props: { <AvatarDetails contract={contract} short={true} className="md:hidden" /> <MiscDetails contract={contract} - showHotVolume={showHotVolume} showTime={showTime} hideGroupLink={hideGroupLink} /> diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index e0eda8d6..48528029 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -2,7 +2,6 @@ import { ClockIcon, DatabaseIcon, PencilIcon, - TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' import clsx from 'clsx' @@ -40,30 +39,19 @@ export type ShowTime = 'resolve-date' | 'close-date' export function MiscDetails(props: { contract: Contract - showHotVolume?: boolean showTime?: ShowTime hideGroupLink?: boolean }) { - const { contract, showHotVolume, showTime, hideGroupLink } = props - const { - volume, - volume24Hours, - closeTime, - isResolved, - createdTime, - resolutionTime, - } = contract + const { contract, showTime, hideGroupLink } = props + const { volume, closeTime, isResolved, createdTime, resolutionTime } = + contract const isNew = createdTime > Date.now() - DAY_MS && !isResolved const groupToDisplay = getGroupLinkToDisplay(contract) return ( <Row className="items-center gap-3 truncate text-sm text-gray-400"> - {showHotVolume ? ( - <Row className="gap-0.5"> - <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)} - </Row> - ) : showTime === 'close-date' ? ( + {showTime === 'close-date' ? ( <Row className="gap-0.5 whitespace-nowrap"> <ClockIcon className="h-5 w-5" /> {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} @@ -369,7 +357,7 @@ function EditableCloseDate(props: { return ( <> {isEditingCloseTime ? ( - <Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1"> + <Row className="z-10 mr-2 w-full shrink-0 items-center gap-1"> <input type="date" className="input input-bordered shrink-0" diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 7adc9ef1..2164e280 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -82,7 +82,7 @@ const Home = (props: { auth: { user: User } | null }) => { <SearchSection key={id} label={'Your bets'} - sort={'newest'} + sort={'prob-change-day'} user={user} yourBets /> From 97e0a7880643b3295fa5c7a7722d2ea733df525f Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 16:51:09 -0500 Subject: [PATCH 11/62] "join group" => "follow" --- web/components/groups/groups-button.tsx | 6 +++--- web/pages/group/[...slugs]/index.tsx | 24 ++++++++++++++---------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index f60ed0af..e6271466 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -107,7 +107,7 @@ export function JoinOrLeaveGroupButton(props: { onClick={firebaseLogin} className={clsx('btn btn-sm', small && smallStyle, className)} > - Login to Join + Login to follow </button> ) } @@ -132,7 +132,7 @@ export function JoinOrLeaveGroupButton(props: { )} onClick={withTracking(onLeaveGroup, 'leave group')} > - Leave + Unfollow </button> ) } @@ -144,7 +144,7 @@ export function JoinOrLeaveGroupButton(props: { className={clsx('btn btn-sm', small && smallStyle, className)} onClick={withTracking(onJoinGroup, 'join group')} > - Join + Follow </button> ) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index b4046c4c..4df21faf 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -52,6 +52,7 @@ import { Post } from 'common/post' import { Spacer } from 'web/components/layout/spacer' import { usePost } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' +import { track } from '@amplitude/analytics-browser' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -659,22 +660,25 @@ function JoinGroupButton(props: { user: User | null | undefined }) { const { group, user } = props - function addUserToGroup() { - if (user) { - toast.promise(joinGroup(group, user.id), { - loading: 'Joining group...', - success: 'Joined group!', - error: "Couldn't join group, try again?", - }) - } + + const follow = async () => { + track('join group') + const userId = user ? user.id : (await firebaseLogin()).user.uid + + toast.promise(joinGroup(group, userId), { + loading: 'Following group...', + success: 'Followed', + error: "Couldn't follow group, try again?", + }) } + return ( <div> <button - onClick={user ? addUserToGroup : firebaseLogin} + onClick={follow} className={'btn-md btn-outline btn whitespace-nowrap normal-case'} > - {user ? 'Join group' : 'Login to join group'} + Follow </button> </div> ) From 30d73d6362818694c9cbff9ab5deb82823f84c68 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 16:59:35 -0500 Subject: [PATCH 12/62] remove parantheses from balance text --- web/components/answers/answer-bet-panel.tsx | 2 +- web/components/answers/create-answer-panel.tsx | 2 +- web/components/bet-panel.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index c5897056..8a29148e 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -134,7 +134,7 @@ export function AnswerBetPanel(props: { </Row> <Row className="my-3 justify-between text-left text-sm text-gray-500"> Amount - <span>(balance: {formatMoney(user?.balance ?? 0)})</span> + <span>Balance: {formatMoney(user?.balance ?? 0)}</span> </Row> <BuyAmountInput inputClassName="w-full max-w-none" diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 38aeac0e..cd962454 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -152,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { <Row className="my-3 justify-between text-left text-sm text-gray-500"> Bet Amount <span className={'sm:hidden'}> - (balance: {formatMoney(user?.balance ?? 0)}) + Balance: {formatMoney(user?.balance ?? 0)} </span> </Row>{' '} <BuyAmountInput diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 311a6182..ab3d8958 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -310,7 +310,7 @@ function BuyPanel(props: { <Row className="my-3 justify-between text-left text-sm text-gray-500"> Amount <span className={'xl:hidden'}> - (balance: {formatMoney(user?.balance ?? 0)}) + Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> <BuyAmountInput @@ -606,7 +606,7 @@ function LimitOrderPanel(props: { Max amount<span className="ml-1 text-red-500">*</span> </span> <span className={'xl:hidden'}> - (balance: {formatMoney(user?.balance ?? 0)}) + Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> <BuyAmountInput From ae40999700bd376d5a08197e3f72f20b8aa3f38d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:11:32 -0500 Subject: [PATCH 13/62] mobile bet slider --- web/components/amount-input.tsx | 35 +++++++++++++++------ web/components/answers/answer-bet-panel.tsx | 2 ++ web/components/bet-panel.tsx | 4 +++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 971a5496..f1eedc88 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -84,6 +84,7 @@ export function BuyAmountInput(props: { setError: (error: string | undefined) => void minimumAmount?: number disabled?: boolean + showSliderOnMobile?: boolean className?: string inputClassName?: string // Needed to focus the amount input @@ -94,6 +95,7 @@ export function BuyAmountInput(props: { onChange, error, setError, + showSliderOnMobile: showSlider, disabled, className, inputClassName, @@ -121,15 +123,28 @@ export function BuyAmountInput(props: { } return ( - <AmountInput - amount={amount} - onChange={onAmountChange} - label={ENV_CONFIG.moneyMoniker} - error={error} - disabled={disabled} - className={className} - inputClassName={inputClassName} - inputRef={inputRef} - /> + <> + <AmountInput + amount={amount} + onChange={onAmountChange} + label={ENV_CONFIG.moneyMoniker} + error={error} + disabled={disabled} + className={className} + inputClassName={inputClassName} + inputRef={inputRef} + /> + {showSlider && ( + <input + type="range" + min="0" + max="250" + value={amount ?? 0} + onChange={(e) => onAmountChange(parseInt(e.target.value))} + className="xl:hidden" + step="25" + /> + )} + </> ) } diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 8a29148e..ace06b6c 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -136,6 +136,7 @@ export function AnswerBetPanel(props: { Amount <span>Balance: {formatMoney(user?.balance ?? 0)}</span> </Row> + <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} @@ -144,6 +145,7 @@ export function AnswerBetPanel(props: { setError={setError} disabled={isSubmitting} inputRef={inputRef} + showSliderOnMobile /> {(betAmount ?? 0) > 10 && diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index ab3d8958..c48e92a9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -313,6 +313,7 @@ function BuyPanel(props: { Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> + <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} @@ -321,6 +322,7 @@ function BuyPanel(props: { setError={setError} disabled={isSubmitting} inputRef={inputRef} + showSliderOnMobile /> {warning} @@ -609,6 +611,7 @@ function LimitOrderPanel(props: { Balance: {formatMoney(user?.balance ?? 0)} </span> </Row> + <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} @@ -616,6 +619,7 @@ function LimitOrderPanel(props: { error={error} setError={setError} disabled={isSubmitting} + showSliderOnMobile /> <Col className="mt-3 w-full gap-3"> From 96cf1a5f7fc3824ceb8c0e2d7fc102687386b4c1 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:39:59 -0500 Subject: [PATCH 14/62] mobile slider styling --- web/components/amount-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index f1eedc88..eb834b51 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="xl:hidden" + className="range range-lg range-primary mb-2 z-40 xl:hidden " step="25" /> )} From 374c25ffb34273d5d8f61bad8032e6bc5b4e5ca4 Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Mon, 5 Sep 2022 22:40:48 +0000 Subject: [PATCH 15/62] Auto-prettification --- web/components/amount-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index eb834b51..bd94a5d1 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="range range-lg range-primary mb-2 z-40 xl:hidden " + className="range range-lg range-primary z-40 mb-2 xl:hidden " step="25" /> )} From 2d724bf2c8a43472ec131c1a846901766fa9f9b3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:43:46 -0500 Subject: [PATCH 16/62] make slider black --- web/components/amount-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index bd94a5d1..459cfe5a 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="range range-lg range-primary z-40 mb-2 xl:hidden " + className="range range-lg z-40 mb-2 xl:hidden " step="25" /> )} From 8952b100adbff6713c125eb7a756a46ee093e6b8 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 17:59:19 -0500 Subject: [PATCH 17/62] add answer panel mobile formatting, slider --- web/components/amount-input.tsx | 2 +- web/components/answers/create-answer-panel.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 459cfe5a..08a9720a 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -141,7 +141,7 @@ export function BuyAmountInput(props: { max="250" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} - className="range range-lg z-40 mb-2 xl:hidden " + className="range range-lg z-40 mb-2 xl:hidden" step="25" /> )} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index cd962454..7e20e92e 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { return ( <Col className="gap-4 rounded"> - <Col className="flex-1 gap-2"> + <Col className="flex-1 gap-2 px-4 xl:px-0"> <div className="mb-1">Add your answer</div> <Textarea value={text} @@ -162,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { setError={setAmountError} minimumAmount={1} disabled={isSubmitting} + showSliderOnMobile /> </Col> <Col className="gap-3"> @@ -205,7 +206,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { disabled={!canSubmit} onClick={withTracking(submitAnswer, 'submit answer')} > - Submit answer & buy + Submit </button> ) : ( text && ( From 837a4d8949a77b000e415a566d92755609273f85 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 18:07:44 -0500 Subject: [PATCH 18/62] Revert "Show challenge on desktop, simplify modal" This reverts commit 8922b370cc2e562e796ae3c58a2eb5e7f7609af1. --- .../challenges/create-challenge-modal.tsx | 111 +++++++++++------- web/components/contract/share-modal.tsx | 40 +------ 2 files changed, 74 insertions(+), 77 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index 72a8fd7b..6f91a6d4 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -18,6 +18,7 @@ import { NoLabel, YesLabel } from '../outcome-label' import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' import { AmountInput } from '../amount-input' +import { getProbability } from 'common/calculate' import { createMarket } from 'web/lib/firebase/api' import { removeUndefinedProps } from 'common/util/object' import { FIXED_ANTE } from 'common/economy' @@ -25,7 +26,6 @@ import Textarea from 'react-expanding-textarea' import { useTextEditor } from 'web/components/editor' import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' -import { useWindowSize } from 'web/hooks/use-window-size' type challengeInfo = { amount: number @@ -110,9 +110,8 @@ function CreateChallengeForm(props: { const [isCreating, setIsCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false) const [error, setError] = useState<string>('') + const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) const defaultExpire = 'week' - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({ expiresTime: dayjs().add(2, defaultExpire).valueOf(), @@ -148,7 +147,7 @@ function CreateChallengeForm(props: { setFinishedCreating(true) }} > - <Title className="!mt-2 hidden sm:block" text="Challenge bet " /> + <Title className="!mt-2" text="Challenge bet " /> <div className="mb-8"> Challenge a friend to bet on{' '} @@ -158,7 +157,7 @@ function CreateChallengeForm(props: { <Textarea placeholder="e.g. Will a Democrat be the next president?" className="input input-bordered mt-1 w-full resize-none" - autoFocus={!isMobile} + autoFocus={true} maxLength={MAX_QUESTION_LENGTH} value={challengeInfo.question} onChange={(e) => @@ -171,59 +170,89 @@ function CreateChallengeForm(props: { )} </div> - <Col className="mt-2 flex-wrap justify-center gap-x-5 gap-y-0 sm:gap-y-2"> - <Col> - <div>You'll bet:</div> - <Row - className={ - 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' + <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> + <div>You'll bet:</div> + <Row + className={ + 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' + } + > + <AmountInput + amount={challengeInfo.amount || undefined} + onChange={(newAmount) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + amount: newAmount ?? 0, + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : newAmount ?? 0, + } + }) + } + error={undefined} + label={'M$'} + inputClassName="w-24" + /> + <span className={''}>on</span> + {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} + </Row> + <Row className={'mt-3 max-w-xs justify-end'}> + <Button + color={'gray-white'} + onClick={() => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: m.outcome === 'YES' ? 'NO' : 'YES', + } + }) } > + <SwitchVerticalIcon className={'h-6 w-6'} /> + </Button> + </Row> + <Row className={'items-center'}>If they bet:</Row> + <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> + <div className={'w-32 sm:mr-1'}> <AmountInput - amount={challengeInfo.amount || undefined} - onChange={(newAmount) => + amount={challengeInfo.acceptorAmount || undefined} + onChange={(newAmount) => { + setEditingAcceptorAmount(true) + setChallengeInfo((m: challengeInfo) => { return { ...m, - amount: newAmount ?? 0, acceptorAmount: newAmount ?? 0, } }) - } + }} error={undefined} label={'M$'} inputClassName="w-24" /> - <span className={''}>on</span> - {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} - </Row> - <Row className={'max-w-xs justify-end'}> - <Button - color={'gray-white'} - onClick={() => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - outcome: m.outcome === 'YES' ? 'NO' : 'YES', - } - }) - } - > - <SwitchVerticalIcon className={'h-6 w-6'} /> - </Button> - </Row> - </Col> - <Row className={'items-center'}>If they bet:</Row> - <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> - <div className={'mt-1 w-32 sm:mr-1'}> - <span className={'ml-2 font-bold'}> - {formatMoney(challengeInfo.acceptorAmount)} - </span> </div> <span>on</span> {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} </Row> - </Col> + </div> + {contract && ( + <Button + size="2xs" + color="gray" + onClick={() => { + setEditingAcceptorAmount(true) + + const p = getProbability(contract) + const prob = challengeInfo.outcome === 'YES' ? p : 1 - p + const { amount } = challengeInfo + const acceptorAmount = Math.round(amount / prob - amount) + setChallengeInfo({ ...challengeInfo, acceptorAmount }) + }} + > + Use market odds + </Button> + )} <div className="mt-8"> If the challenge is accepted, whoever is right will earn{' '} <span className="font-semibold"> diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index ff3f41ae..2cf8b484 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -12,15 +12,13 @@ import { TweetButton } from '../tweet-button' import { DuplicateContractButton } from '../copy-contract-button' import { Button } from '../button' import { copyToClipboard } from 'web/lib/util/copy' -import { track, withTracking } from 'web/lib/service/analytics' +import { track } from 'web/lib/service/analytics' import { ENV_CONFIG } from 'common/envs/constants' import { User } from 'common/user' import { SiteLink } from '../site-link' import { formatMoney } from 'common/util/format' import { REFERRAL_AMOUNT } from 'common/economy' -import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { useState } from 'react' -import { CHALLENGES_ENABLED } from 'common/challenge' export function ShareModal(props: { contract: Contract @@ -29,14 +27,9 @@ export function ShareModal(props: { setOpen: (open: boolean) => void }) { const { contract, user, isOpen, setOpen } = props - const { outcomeType, resolution } = contract - const [openCreateChallengeModal, setOpenCreateChallengeModal] = - useState(false) + useState(false) const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED - const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ user?.username && contract.creatorUsername !== user?.username ? '?referrer=' + user?.username @@ -45,7 +38,7 @@ export function ShareModal(props: { return ( <Modal open={isOpen} setOpen={setOpen} size="md"> - <Col className="gap-2.5 rounded bg-white p-4 sm:gap-4"> + <Col className="gap-4 rounded bg-white p-4"> <Title className="!mt-0 !mb-2" text="Share this market" /> <p> Earn{' '} @@ -57,7 +50,7 @@ export function ShareModal(props: { <Button size="2xl" color="gradient" - className={'flex max-w-xs self-center'} + className={'mb-2 flex max-w-xs self-center'} onClick={() => { copyToClipboard(shareUrl) toast.success('Link copied!', { @@ -68,31 +61,6 @@ export function ShareModal(props: { > {linkIcon} Copy link </Button> - <Row className={'justify-center'}>or</Row> - {showChallenge && ( - <Button - size="2xl" - color="gradient" - className={'mb-2 flex max-w-xs self-center'} - onClick={withTracking( - () => setOpenCreateChallengeModal(true), - 'click challenge button' - )} - > - <span>⚔️ Challenge</span> - <CreateChallengeModal - isOpen={openCreateChallengeModal} - setOpen={(open) => { - if (!open) { - setOpenCreateChallengeModal(false) - setOpen(false) - } else setOpenCreateChallengeModal(open) - }} - user={user} - contract={contract} - /> - </Button> - )} <Row className="z-0 flex-wrap justify-center gap-4 self-center"> <TweetButton className="self-start" From cd8bb72f9443c957bc4be0b5b9dc3db2fddc9c75 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 5 Sep 2022 18:09:01 -0500 Subject: [PATCH 19/62] Daily movers table in experimental/home --- web/components/contract/prob-change-table.tsx | 72 +++++++++++++++++++ web/hooks/use-prob-changes.tsx | 22 ++++++ web/lib/firebase/contracts.ts | 20 +++++- web/pages/experimental/home/index.tsx | 62 ++++++++-------- 4 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 web/components/contract/prob-change-table.tsx create mode 100644 web/hooks/use-prob-changes.tsx diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx new file mode 100644 index 00000000..9f1f171d --- /dev/null +++ b/web/components/contract/prob-change-table.tsx @@ -0,0 +1,72 @@ +import clsx from 'clsx' +import { contractPath } from 'web/lib/firebase/contracts' +import { CPMMContract } from 'common/contract' +import { formatPercent } from 'common/util/format' +import { useProbChanges } from 'web/hooks/use-prob-changes' +import { SiteLink } from '../site-link' + +export function ProbChangeTable(props: { userId: string | undefined }) { + const { userId } = props + + const changes = useProbChanges(userId ?? '') + console.log('changes', changes) + + if (!changes) { + return null + } + + const { positiveChanges, negativeChanges } = changes + + const count = 3 + + return ( + <div className="grid max-w-xl gap-x-2 gap-y-2 rounded bg-white p-4 text-gray-700"> + <div className="text-xl text-gray-800">Daily movers</div> + <div className="text-right">% pts</div> + {positiveChanges.slice(0, count).map((contract) => ( + <> + <div className="line-clamp-2"> + <SiteLink href={contractPath(contract)}> + {contract.question} + </SiteLink> + </div> + <ProbChange className="text-right" contract={contract} /> + </> + ))} + <div className="col-span-2 my-2" /> + {negativeChanges.slice(0, count).map((contract) => ( + <> + <div className="line-clamp-2"> + <SiteLink href={contractPath(contract)}> + {contract.question} + </SiteLink> + </div> + <ProbChange className="text-right" contract={contract} /> + </> + ))} + </div> + ) +} + +export function ProbChange(props: { + contract: CPMMContract + className?: string +}) { + const { contract, className } = props + const { + probChanges: { day: change }, + } = contract + + const color = + change > 0 + ? 'text-green-500' + : change < 0 + ? 'text-red-500' + : 'text-gray-500' + + const str = + change === 0 + ? '+0%' + : `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}` + return <div className={clsx(className, color)}>{str}</div> +} diff --git a/web/hooks/use-prob-changes.tsx b/web/hooks/use-prob-changes.tsx new file mode 100644 index 00000000..c5e2c9bd --- /dev/null +++ b/web/hooks/use-prob-changes.tsx @@ -0,0 +1,22 @@ +import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { + getProbChangesNegative, + getProbChangesPositive, +} from 'web/lib/firebase/contracts' + +export const useProbChanges = (userId: string) => { + const { data: positiveChanges } = useFirestoreQueryData( + ['prob-changes-day-positive', userId], + getProbChangesPositive(userId) + ) + const { data: negativeChanges } = useFirestoreQueryData( + ['prob-changes-day-negative', userId], + getProbChangesNegative(userId) + ) + + if (!positiveChanges || !negativeChanges) { + return undefined + } + + return { positiveChanges, negativeChanges } +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 5c65b23f..702f1c99 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -16,7 +16,7 @@ import { import { partition, sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' -import { BinaryContract, Contract } from 'common/contract' +import { BinaryContract, Contract, CPMMContract } from 'common/contract' import { createRNG, shuffle } from 'common/util/random' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' @@ -402,3 +402,21 @@ export async function getRecentBetsAndComments(contract: Contract) { recentComments, } } + +export const getProbChangesPositive = (userId: string) => + query( + contracts, + where('uniqueBettorIds', 'array-contains', userId), + where('probChanges.day', '>', 0), + orderBy('probChanges.day', 'desc'), + limit(10) + ) as Query<CPMMContract> + +export const getProbChangesNegative = (userId: string) => + query( + contracts, + where('uniqueBettorIds', 'array-contains', userId), + where('probChanges.day', '<', 0), + orderBy('probChanges.day', 'asc'), + limit(10) + ) as Query<CPMMContract> diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 2164e280..9e393d4f 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -25,6 +25,7 @@ import { Button } from 'web/components/button' import { ArrangeHome, getHomeItems } from '../../../components/arrange-home' import { Title } from 'web/components/title' import { Row } from 'web/components/layout/row' +import { ProbChangeTable } from 'web/components/contract/prob-change-table' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -75,36 +76,40 @@ const Home = (props: { auth: { user: User } | null }) => { /> </> ) : ( - visibleItems.map((item) => { - const { id } = item - if (id === 'your-bets') { - return ( - <SearchSection - key={id} - label={'Your bets'} - sort={'prob-change-day'} - user={user} - yourBets - /> - ) - } - const sort = SORTS.find((sort) => sort.value === id) - if (sort) - return ( - <SearchSection - key={id} - label={sort.label} - sort={sort.value} - user={user} - /> - ) + <> + <ProbChangeTable userId={user?.id} /> - const group = groups.find((g) => g.id === id) - if (group) - return <GroupSection key={id} group={group} user={user} /> + {visibleItems.map((item) => { + const { id } = item + if (id === 'your-bets') { + return ( + <SearchSection + key={id} + label={'Your bets'} + sort={'prob-change-day'} + user={user} + yourBets + /> + ) + } + const sort = SORTS.find((sort) => sort.value === id) + if (sort) + return ( + <SearchSection + key={id} + label={sort.label} + sort={sort.value} + user={user} + /> + ) - return null - }) + const group = groups.find((g) => g.id === id) + if (group) + return <GroupSection key={id} group={group} user={user} /> + + return null + })} + </> )} </Col> <button @@ -151,6 +156,7 @@ function SearchSection(props: { ? sort : undefined } + showProbChange={sort === 'prob-change-day'} loadMore={loadMore} /> ) : ( From f21711f3dc4b52b8228da38c3dd677bf09428133 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 5 Sep 2022 18:13:01 -0500 Subject: [PATCH 20/62] Fix type error --- web/pages/experimental/home/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 9e393d4f..606b66c4 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -156,7 +156,6 @@ function SearchSection(props: { ? sort : undefined } - showProbChange={sort === 'prob-change-day'} loadMore={loadMore} /> ) : ( From 450b140f5f10a4005480c716a037fffd04b4f33e Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 5 Sep 2022 18:19:06 -0500 Subject: [PATCH 21/62] show challenge button on mobile --- .../contract/extra-contract-actions-row.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index f84655ec..d4918783 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -42,7 +42,6 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { /> <span>Share</span> </Col> - <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -50,17 +49,23 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { user={user} /> </Button> + {showChallenge && ( <Button size="lg" color="gray-white" - className={'flex hidden max-w-xs self-center sm:inline-block'} + className="max-w-xs self-center" onClick={withTracking( () => setOpenCreateChallengeModal(true), 'click challenge button' )} > - <span>⚔️ Challenge</span> + <Col className="items-center sm:flex-row"> + <span className="h-[24px] w-5 sm:mr-2" aria-hidden="true"> + ⚔️ + </span> + <span>Challenge</span> + </Col> <CreateChallengeModal isOpen={openCreateChallengeModal} setOpen={setOpenCreateChallengeModal} From 59f3936dad81ed4686685dfb187aadd5b5a29ec5 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Tue, 6 Sep 2022 14:17:21 +0100 Subject: [PATCH 22/62] Fix bug (#854) --- web/lib/firebase/contracts.ts | 5 +++-- web/pages/tournaments/index.tsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 702f1c99..51ec3108 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -104,7 +104,7 @@ export async function listContracts(creatorId: string): Promise<Contract[]> { return snapshot.docs.map((doc) => doc.data()) } -export const contractsByGroupSlugQuery = (slug: string) => +export const tournamentContractsByGroupSlugQuery = (slug: string) => query( contracts, where('groupSlugs', 'array-contains', slug), @@ -115,7 +115,8 @@ export const contractsByGroupSlugQuery = (slug: string) => export async function listContractsByGroupSlug( slug: string ): Promise<Contract[]> { - const snapshot = await getDocs(contractsByGroupSlugQuery(slug)) + const q = query(contracts, where('groupSlugs', 'array-contains', slug)) + const snapshot = await getDocs(q) return snapshot.docs.map((doc) => doc.data()) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 1a74e8ea..4b573e3f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -14,7 +14,7 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' -import { contractsByGroupSlugQuery } from 'web/lib/firebase/contracts' +import { tournamentContractsByGroupSlugQuery } from 'web/lib/firebase/contracts' import { getGroup, groupPath } from 'web/lib/firebase/groups' import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png' import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png' @@ -222,7 +222,7 @@ const ImageCarousel = (props: { images: MarketImage[]; url: string }) => { const MarketCarousel = (props: { slug: string }) => { const { slug } = props - const q = contractsByGroupSlugQuery(slug) + const q = tournamentContractsByGroupSlugQuery(slug) const { allItems, getNext } = usePagination({ q, pageSize: 6 }) const items = allItems() From a3b18e5beac9f3b6c6612a773e0ce1f13df41daf Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 07:57:52 -0600 Subject: [PATCH 23/62] Add challenge back to share modal --- web/components/contract/share-modal.tsx | 40 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index 2cf8b484..ff3f41ae 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -12,13 +12,15 @@ import { TweetButton } from '../tweet-button' import { DuplicateContractButton } from '../copy-contract-button' import { Button } from '../button' import { copyToClipboard } from 'web/lib/util/copy' -import { track } from 'web/lib/service/analytics' +import { track, withTracking } from 'web/lib/service/analytics' import { ENV_CONFIG } from 'common/envs/constants' import { User } from 'common/user' import { SiteLink } from '../site-link' import { formatMoney } from 'common/util/format' import { REFERRAL_AMOUNT } from 'common/economy' +import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { useState } from 'react' +import { CHALLENGES_ENABLED } from 'common/challenge' export function ShareModal(props: { contract: Contract @@ -27,9 +29,14 @@ export function ShareModal(props: { setOpen: (open: boolean) => void }) { const { contract, user, isOpen, setOpen } = props + const { outcomeType, resolution } = contract - useState(false) + const [openCreateChallengeModal, setOpenCreateChallengeModal] = + useState(false) const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + const showChallenge = + user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED + const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ user?.username && contract.creatorUsername !== user?.username ? '?referrer=' + user?.username @@ -38,7 +45,7 @@ export function ShareModal(props: { return ( <Modal open={isOpen} setOpen={setOpen} size="md"> - <Col className="gap-4 rounded bg-white p-4"> + <Col className="gap-2.5 rounded bg-white p-4 sm:gap-4"> <Title className="!mt-0 !mb-2" text="Share this market" /> <p> Earn{' '} @@ -50,7 +57,7 @@ export function ShareModal(props: { <Button size="2xl" color="gradient" - className={'mb-2 flex max-w-xs self-center'} + className={'flex max-w-xs self-center'} onClick={() => { copyToClipboard(shareUrl) toast.success('Link copied!', { @@ -61,6 +68,31 @@ export function ShareModal(props: { > {linkIcon} Copy link </Button> + <Row className={'justify-center'}>or</Row> + {showChallenge && ( + <Button + size="2xl" + color="gradient" + className={'mb-2 flex max-w-xs self-center'} + onClick={withTracking( + () => setOpenCreateChallengeModal(true), + 'click challenge button' + )} + > + <span>⚔️ Challenge</span> + <CreateChallengeModal + isOpen={openCreateChallengeModal} + setOpen={(open) => { + if (!open) { + setOpenCreateChallengeModal(false) + setOpen(false) + } else setOpenCreateChallengeModal(open) + }} + user={user} + contract={contract} + /> + </Button> + )} <Row className="z-0 flex-wrap justify-center gap-4 self-center"> <TweetButton className="self-start" From 39d7f1055bfb682a8f4e81c977eefebe6360c4cd Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 07:58:00 -0600 Subject: [PATCH 24/62] Fix spacing on challenge modal --- .../challenges/create-challenge-modal.tsx | 114 +++++++++--------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index 6f91a6d4..6c810a44 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -147,7 +147,7 @@ function CreateChallengeForm(props: { setFinishedCreating(true) }} > - <Title className="!mt-2" text="Challenge bet " /> + <Title className="!mt-2 hidden sm:block" text="Challenge bet " /> <div className="mb-8"> Challenge a friend to bet on{' '} @@ -170,72 +170,76 @@ function CreateChallengeForm(props: { )} </div> - <div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> - <div>You'll bet:</div> - <Row - className={ - 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' - } - > - <AmountInput - amount={challengeInfo.amount || undefined} - onChange={(newAmount) => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - amount: newAmount ?? 0, - acceptorAmount: editingAcceptorAmount - ? m.acceptorAmount - : newAmount ?? 0, - } - }) - } - error={undefined} - label={'M$'} - inputClassName="w-24" - /> - <span className={''}>on</span> - {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} - </Row> - <Row className={'mt-3 max-w-xs justify-end'}> - <Button - color={'gray-white'} - onClick={() => - setChallengeInfo((m: challengeInfo) => { - return { - ...m, - outcome: m.outcome === 'YES' ? 'NO' : 'YES', - } - }) + <Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2"> + <Col> + <div>You'll bet:</div> + <Row + className={ + 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' } > - <SwitchVerticalIcon className={'h-6 w-6'} /> - </Button> - </Row> - <Row className={'items-center'}>If they bet:</Row> - <Row className={'max-w-xs items-center justify-between gap-4 pr-3'}> - <div className={'w-32 sm:mr-1'}> <AmountInput - amount={challengeInfo.acceptorAmount || undefined} - onChange={(newAmount) => { - setEditingAcceptorAmount(true) - + amount={challengeInfo.amount || undefined} + onChange={(newAmount) => setChallengeInfo((m: challengeInfo) => { return { ...m, - acceptorAmount: newAmount ?? 0, + amount: newAmount ?? 0, + acceptorAmount: editingAcceptorAmount + ? m.acceptorAmount + : newAmount ?? 0, } }) - }} + } error={undefined} label={'M$'} inputClassName="w-24" /> - </div> - <span>on</span> - {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} - </Row> - </div> + <span className={''}>on</span> + {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />} + </Row> + <Row className={'mt-3 max-w-xs justify-end'}> + <Button + color={'gray-white'} + onClick={() => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: m.outcome === 'YES' ? 'NO' : 'YES', + } + }) + } + > + <SwitchVerticalIcon className={'h-6 w-6'} /> + </Button> + </Row> + <Row className={'items-center'}>If they bet:</Row> + <Row + className={'max-w-xs items-center justify-between gap-4 pr-3'} + > + <div className={'w-32 sm:mr-1'}> + <AmountInput + amount={challengeInfo.acceptorAmount || undefined} + onChange={(newAmount) => { + setEditingAcceptorAmount(true) + + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + acceptorAmount: newAmount ?? 0, + } + }) + }} + error={undefined} + label={'M$'} + inputClassName="w-24" + /> + </div> + <span>on</span> + {challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} + </Row> + </Col> + </Col> {contract && ( <Button size="2xs" From 2ee067c072f629e5d5eede8f2ca3d654e5a33095 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 08:14:13 -0600 Subject: [PATCH 25/62] Remove member and contract ids from group doc --- common/group.ts | 4 ---- functions/src/scripts/update-groups.ts | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/common/group.ts b/common/group.ts index 5c716dba..19f3b7b8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,10 +12,6 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number - /** @deprecated - members and contracts now stored as subcollections*/ - memberIds?: string[] // Deprecated - /** @deprecated - members and contracts now stored as subcollections*/ - contractIds?: string[] // Deprecated } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts index 952a0d55..05666ab5 100644 --- a/functions/src/scripts/update-groups.ts +++ b/functions/src/scripts/update-groups.ts @@ -86,7 +86,7 @@ async function convertGroupFieldsToGroupDocuments() { } } } - +// eslint-disable-next-line @typescript-eslint/no-unused-vars async function updateTotalContractsAndMembers() { const groups = await getGroups() for (const group of groups) { @@ -101,9 +101,22 @@ async function updateTotalContractsAndMembers() { }) } } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function removeUnusedMemberAndContractFields() { + const groups = await getGroups() + for (const group of groups) { + log('removing member and contract ids', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + await groupRef.update({ + memberIds: admin.firestore.FieldValue.delete(), + contractIds: admin.firestore.FieldValue.delete(), + }) + } +} if (require.main === module) { initAdmin() // convertGroupFieldsToGroupDocuments() - updateTotalContractsAndMembers() + // updateTotalContractsAndMembers() + removeUnusedMemberAndContractFields() } From 5af92a7d8184564ac13ed998a0791c01a0c8eeac Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:24:26 -0600 Subject: [PATCH 26/62] Update groups API --- docs/docs/api.md | 12 ++++- web/lib/firebase/groups.ts | 51 +++++++++++++------ .../v0/group/by-id/{[id].ts => [id]/index.ts} | 0 web/pages/api/v0/group/by-id/[id]/markets.ts | 18 +++++++ web/pages/api/v0/groups.ts | 34 +++++++++++-- 5 files changed, 95 insertions(+), 20 deletions(-) rename web/pages/api/v0/group/by-id/{[id].ts => [id]/index.ts} (100%) create mode 100644 web/pages/api/v0/group/by-id/[id]/markets.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index c02a5141..e284abdf 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -54,6 +54,10 @@ Returns the authenticated user. Gets all groups, in no particular order. +Parameters: +- `availableToUserId`: Optional. if specified, only groups that the user can + join and groups they've already joined will be returned. + Requires no authorization. ### `GET /v0/groups/[slug]` @@ -62,12 +66,18 @@ Gets a group by its slug. Requires no authorization. -### `GET /v0/groups/by-id/[id]` +### `GET /v0/group/by-id/[id]` Gets a group by its unique ID. Requires no authorization. +### `GET /v0/group/by-id/[id]/markets` + +Gets a group's markets by its unique ID. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index ef67ff14..36bfe7cc 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -11,7 +11,7 @@ import { updateDoc, where, } from 'firebase/firestore' -import { uniq } from 'lodash' +import { uniq, uniqBy } from 'lodash' import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' import { coll, @@ -21,7 +21,7 @@ import { listenForValues, } from './utils' import { Contract } from 'common/contract' -import { updateContract } from 'web/lib/firebase/contracts' +import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' import { getUser } from 'web/lib/firebase/users' @@ -31,6 +31,9 @@ export const groupMembers = (groupId: string) => collection(groups, groupId, 'groupMembers') export const groupContracts = (groupId: string) => collection(groups, groupId, 'groupContracts') +const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true)) +const memberGroupsQuery = (userId: string) => + query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId)) export function groupPath( groupSlug: string, @@ -78,23 +81,24 @@ export function listenForGroupContractDocs( return listenForValues(groupContracts(groupId), setContractDocs) } -export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { - return listenForValues( - query(groups, where('anyoneCanJoin', '==', true)), - setGroups +export async function listGroupContracts(groupId: string) { + const contractDocs = await getValues<{ + contractId: string + createdTime: number + }>(groupContracts(groupId)) + return Promise.all( + contractDocs.map((doc) => getContractFromId(doc.contractId)) ) } +export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { + return listenForValues(openGroupsQuery, setGroups) +} + export function getGroup(groupId: string) { return getValue<Group>(doc(groups, groupId)) } -export function getGroupContracts(groupId: string) { - return getValues<{ contractId: string; createdTime: number }>( - groupContracts(groupId) - ) -} - export async function getGroupBySlug(slug: string) { const q = query(groups, where('slug', '==', slug)) const docs = (await getDocs(q)).docs @@ -112,10 +116,7 @@ export function listenForMemberGroupIds( userId: string, setGroupIds: (groupIds: string[]) => void ) { - const q = query( - collectionGroup(db, 'groupMembers'), - where('userId', '==', userId) - ) + const q = memberGroupsQuery(userId) return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => { if (snapshot.metadata.fromCache) return @@ -136,6 +137,24 @@ export function listenForMemberGroups( }) } +export async function listAvailableGroups(userId: string) { + const [openGroups, memberGroupSnapshot] = await Promise.all([ + getValues<Group>(openGroupsQuery), + getDocs(memberGroupsQuery(userId)), + ]) + const memberGroups = filterDefined( + await Promise.all( + memberGroupSnapshot.docs.map((doc) => { + return doc.ref.parent.parent?.id + ? getGroup(doc.ref.parent.parent?.id) + : null + }) + ) + ) + + return uniqBy([...openGroups, ...memberGroups], (g) => g.id) +} + export async function addUserToGroupViaId(groupId: string, userId: string) { // get group to get the member ids const group = await getGroup(groupId) diff --git a/web/pages/api/v0/group/by-id/[id].ts b/web/pages/api/v0/group/by-id/[id]/index.ts similarity index 100% rename from web/pages/api/v0/group/by-id/[id].ts rename to web/pages/api/v0/group/by-id/[id]/index.ts diff --git a/web/pages/api/v0/group/by-id/[id]/markets.ts b/web/pages/api/v0/group/by-id/[id]/markets.ts new file mode 100644 index 00000000..f7538277 --- /dev/null +++ b/web/pages/api/v0/group/by-id/[id]/markets.ts @@ -0,0 +1,18 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { listGroupContracts } from 'web/lib/firebase/groups' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + const { id } = req.query + const contracts = await listGroupContracts(id as string) + if (!contracts) { + res.status(404).json({ error: 'Group not found' }) + return + } + res.setHeader('Cache-Control', 'no-cache') + return res.status(200).json(contracts) +} diff --git a/web/pages/api/v0/groups.ts b/web/pages/api/v0/groups.ts index 84b773b3..60d94c1c 100644 --- a/web/pages/api/v0/groups.ts +++ b/web/pages/api/v0/groups.ts @@ -1,14 +1,42 @@ import type { NextApiRequest, NextApiResponse } from 'next' -import { listAllGroups } from 'web/lib/firebase/groups' +import { listAllGroups, listAvailableGroups } from 'web/lib/firebase/groups' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' +import { z } from 'zod' +import { validate } from 'web/pages/api/v0/_validate' +import { ValidationError } from 'web/pages/api/v0/_types' -type Data = any[] +const queryParams = z + .object({ + availableToUserId: z.string().optional(), + }) + .strict() export default async function handler( req: NextApiRequest, - res: NextApiResponse<Data> + res: NextApiResponse ) { await applyCorsHeaders(req, res, CORS_UNRESTRICTED) + let params: z.infer<typeof queryParams> + try { + params = validate(queryParams, req.query) + } catch (e) { + if (e instanceof ValidationError) { + return res.status(400).json(e) + } + console.error(`Unknown error during validation: ${e}`) + return res.status(500).json({ error: 'Unknown error during validation' }) + } + + const { availableToUserId } = params + + // TODO: should we check if the user is a real user? + if (availableToUserId) { + const groups = await listAvailableGroups(availableToUserId) + res.setHeader('Cache-Control', 'max-age=0') + res.status(200).json(groups) + return + } + const groups = await listAllGroups() res.setHeader('Cache-Control', 'max-age=0') res.status(200).json(groups) From 7c44abdcd712cbd2ac07fb77fe018111b80cceca Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:27:50 -0600 Subject: [PATCH 27/62] Comment out unused script functions --- functions/src/scripts/update-groups.ts | 150 ++++++++++++------------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts index 05666ab5..fc402292 100644 --- a/functions/src/scripts/update-groups.ts +++ b/functions/src/scripts/update-groups.ts @@ -9,83 +9,83 @@ const getGroups = async () => { return groups.docs.map((doc) => doc.data() as Group) } -const createContractIdForGroup = async ( - groupId: string, - contractId: string -) => { - const firestore = admin.firestore() - const now = Date.now() - const contractDoc = await firestore - .collection('groups') - .doc(groupId) - .collection('groupContracts') - .doc(contractId) - .get() - if (!contractDoc.exists) - await firestore - .collection('groups') - .doc(groupId) - .collection('groupContracts') - .doc(contractId) - .create({ - contractId, - createdTime: now, - }) -} +// const createContractIdForGroup = async ( +// groupId: string, +// contractId: string +// ) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const contractDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .get() +// if (!contractDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupContracts') +// .doc(contractId) +// .create({ +// contractId, +// createdTime: now, +// }) +// } -const createMemberForGroup = async (groupId: string, userId: string) => { - const firestore = admin.firestore() - const now = Date.now() - const memberDoc = await firestore - .collection('groups') - .doc(groupId) - .collection('groupMembers') - .doc(userId) - .get() - if (!memberDoc.exists) - await firestore - .collection('groups') - .doc(groupId) - .collection('groupMembers') - .doc(userId) - .create({ - userId, - createdTime: now, - }) -} +// const createMemberForGroup = async (groupId: string, userId: string) => { +// const firestore = admin.firestore() +// const now = Date.now() +// const memberDoc = await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .get() +// if (!memberDoc.exists) +// await firestore +// .collection('groups') +// .doc(groupId) +// .collection('groupMembers') +// .doc(userId) +// .create({ +// userId, +// createdTime: now, +// }) +// } + +// async function convertGroupFieldsToGroupDocuments() { +// const groups = await getGroups() +// for (const group of groups) { +// log('updating group', group.slug) +// const groupRef = admin.firestore().collection('groups').doc(group.id) +// const totalMembers = (await groupRef.collection('groupMembers').get()).size +// const totalContracts = (await groupRef.collection('groupContracts').get()) +// .size +// if ( +// totalMembers === group.memberIds?.length && +// totalContracts === group.contractIds?.length +// ) { +// log('group already converted', group.slug) +// continue +// } +// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 +// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 +// for (const contractId of group.contractIds?.slice( +// contractStart, +// group.contractIds?.length +// ) ?? []) { +// await createContractIdForGroup(group.id, contractId) +// } +// for (const userId of group.memberIds?.slice( +// membersStart, +// group.memberIds?.length +// ) ?? []) { +// await createMemberForGroup(group.id, userId) +// } +// } +// } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function convertGroupFieldsToGroupDocuments() { - const groups = await getGroups() - for (const group of groups) { - log('updating group', group.slug) - const groupRef = admin.firestore().collection('groups').doc(group.id) - const totalMembers = (await groupRef.collection('groupMembers').get()).size - const totalContracts = (await groupRef.collection('groupContracts').get()) - .size - if ( - totalMembers === group.memberIds?.length && - totalContracts === group.contractIds?.length - ) { - log('group already converted', group.slug) - continue - } - const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1 - const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1 - for (const contractId of group.contractIds?.slice( - contractStart, - group.contractIds?.length - ) ?? []) { - await createContractIdForGroup(group.id, contractId) - } - for (const userId of group.memberIds?.slice( - membersStart, - group.memberIds?.length - ) ?? []) { - await createMemberForGroup(group.id, userId) - } - } -} // eslint-disable-next-line @typescript-eslint/no-unused-vars async function updateTotalContractsAndMembers() { const groups = await getGroups() From 74af54f3c058863fee09badcb1d79c68ff1a67a1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:36:41 -0600 Subject: [PATCH 28/62] Remove chance from FR og-images --- og-image/api/_lib/template.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 2469a636..f8e235b7 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -118,7 +118,9 @@ export function getHtml(parsedReq: ParsedRequest) { ? resolutionDiv : numericValue ? numericValueDiv - : probabilityDiv + : probability + ? probabilityDiv + : '' } </div> </div> From a038ef91eb2ef001dea84ca6122c432290b84605 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 09:58:24 -0600 Subject: [PATCH 29/62] Show num contracts in group selector --- web/components/groups/group-selector.tsx | 29 ++++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index d48256a6..344339d1 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -9,7 +9,7 @@ import { import clsx from 'clsx' import { CreateGroupButton } from 'web/components/groups/create-group-button' import { useState } from 'react' -import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group' +import { useMemberGroups } from 'web/hooks/use-group' import { User } from 'common/user' import { searchInAny } from 'common/util/parse' @@ -27,14 +27,9 @@ export function GroupSelector(props: { const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') - const openGroups = useOpenGroups() - const availableGroups = openGroups - .concat( - (useMemberGroups(creator?.id) ?? []).filter( - (g) => !openGroups.map((og) => og.id).includes(g.id) - ) - ) - .filter((group) => !ignoreGroupIds?.includes(group.id)) + const availableGroups = (useMemberGroups(creator?.id) ?? []).filter( + (group) => !ignoreGroupIds?.includes(group.id) + ) const filteredGroups = availableGroups.filter((group) => searchInAny(query, group.name) ) @@ -96,7 +91,7 @@ export function GroupSelector(props: { value={group} className={({ active }) => clsx( - 'relative h-12 cursor-pointer select-none py-2 pl-4 pr-9', + 'relative h-12 cursor-pointer select-none py-2 pl-4 pr-6', active ? 'bg-indigo-500 text-white' : 'text-gray-900' ) } @@ -115,11 +110,21 @@ export function GroupSelector(props: { )} <span className={clsx( - 'ml-5 mt-1 block truncate', + 'ml-3 mt-1 block flex flex-row justify-between', selected && 'font-semibold' )} > - {group.name} + <span className={'truncate'}>{group.name}</span> + <span + className={clsx( + 'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white', + group.totalContracts > 99 ? 'w-[2.1rem]' : '' + )} + > + {group.totalContracts > 99 + ? '99+' + : group.totalContracts} + </span> </span> </> )} From c59de1be2e321ac5506015d5b47bbd84fe93c2f6 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 6 Sep 2022 11:53:09 -0500 Subject: [PATCH 30/62] bet slider: decrease step size --- web/components/amount-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 08a9720a..9eff26ef 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -138,11 +138,11 @@ export function BuyAmountInput(props: { <input type="range" min="0" - max="250" + max="200" value={amount ?? 0} onChange={(e) => onAmountChange(parseInt(e.target.value))} className="range range-lg z-40 mb-2 xl:hidden" - step="25" + step="5" /> )} </> From 45e54789b72e8402cecc86e1d1764bdef2a51c69 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 15:51:36 -0600 Subject: [PATCH 31/62] Groups search shares query, sorted by contract & members --- web/components/groups/group-selector.tsx | 14 ++++++++++---- web/pages/groups.tsx | 23 +++++++---------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index 344339d1..a75a0a34 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -9,7 +9,7 @@ import { import clsx from 'clsx' import { CreateGroupButton } from 'web/components/groups/create-group-button' import { useState } from 'react' -import { useMemberGroups } from 'web/hooks/use-group' +import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group' import { User } from 'common/user' import { searchInAny } from 'common/util/parse' @@ -27,9 +27,15 @@ export function GroupSelector(props: { const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false) const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') - const availableGroups = (useMemberGroups(creator?.id) ?? []).filter( - (group) => !ignoreGroupIds?.includes(group.id) - ) + const openGroups = useOpenGroups() + const availableGroups = openGroups + .concat( + (useMemberGroups(creator?.id) ?? []).filter( + (g) => !openGroups.map((og) => og.id).includes(g.id) + ) + ) + .filter((group) => !ignoreGroupIds?.includes(group.id)) + const filteredGroups = availableGroups.filter((group) => searchInAny(query, group.name) ) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 3405ef3e..f39a7647 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -65,20 +65,9 @@ export default function Groups(props: { const [query, setQuery] = useState('') - // List groups with the highest question count, then highest member count - // TODO use find-active-contracts to sort by? - const matches = sortBy(groups, []).filter((g) => - searchInAny( - query, - g.name, - g.about || '', - creatorsDict[g.creatorId].username - ) - ) - - const matchesOrderedByRecentActivity = sortBy(groups, [ - (group) => - -1 * (group.mostRecentContractAddedTime ?? group.mostRecentActivityTime), + const matchesOrderedByMostContractAndMembers = sortBy(groups, [ + (group) => -1 * group.totalContracts, + (group) => -1 * group.totalMembers, ]).filter((g) => searchInAny( query, @@ -120,13 +109,14 @@ export default function Groups(props: { <Col> <input type="text" + value={query} onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search your groups" className="input input-bordered mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> - {matchesOrderedByRecentActivity + {matchesOrderedByMostContractAndMembers .filter((match) => memberGroupIds.includes(match.id) ) @@ -153,11 +143,12 @@ export default function Groups(props: { type="text" onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search groups" + value={query} className="input input-bordered mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> - {matches.map((group) => ( + {matchesOrderedByMostContractAndMembers.map((group) => ( <GroupCard key={group.id} group={group} From 668f30dd55965e652b8359ac362b5cb4b5435b4a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 6 Sep 2022 16:55:43 -0500 Subject: [PATCH 32/62] Free market creation shows cost striked through --- web/pages/create.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7e1ead90..1f1a006b 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -483,17 +483,17 @@ export function NewContract(props: { {formatMoney(ante)} </div> ) : ( - <div> - <div className="label-text text-primary pl-1"> - FREE{' '} - <span className="label-text pl-1 text-gray-500"> - (You have{' '} - {FREE_MARKETS_PER_USER_MAX - - (creator?.freeMarketsCreated ?? 0)}{' '} - free markets left) - </span> + <Row> + <div className="label-text text-neutral pl-1 line-through"> + {formatMoney(ante)} </div> - </div> + <div className="label-text text-primary pl-1">FREE </div> + <div className="label-text pl-1 text-gray-500"> + (You have{' '} + {FREE_MARKETS_PER_USER_MAX - (creator?.freeMarketsCreated ?? 0)}{' '} + free markets left) + </div> + </Row> )} {ante > balance && !deservesFreeMarket && ( From c16e7c6cfd652e9f7263d265ac0c826db3fafcb5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 16:20:43 -0600 Subject: [PATCH 33/62] Add membership indicators and link to see group --- web/components/groups/group-selector.tsx | 28 +++++++++++++++++++++--- web/pages/create.tsx | 24 +++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index a75a0a34..54fc0764 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -5,6 +5,7 @@ import { CheckIcon, PlusCircleIcon, SelectorIcon, + UserIcon, } from '@heroicons/react/outline' import clsx from 'clsx' import { CreateGroupButton } from 'web/components/groups/create-group-button' @@ -12,6 +13,7 @@ import { useState } from 'react' import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group' import { User } from 'common/user' import { searchInAny } from 'common/util/parse' +import { Row } from 'web/components/layout/row' export function GroupSelector(props: { selectedGroup: Group | undefined @@ -28,13 +30,26 @@ export function GroupSelector(props: { const { showSelector, showLabel, ignoreGroupIds } = options const [query, setQuery] = useState('') const openGroups = useOpenGroups() + const memberGroups = useMemberGroups(creator?.id) + const memberGroupIds = memberGroups?.map((g) => g.id) ?? [] const availableGroups = openGroups .concat( - (useMemberGroups(creator?.id) ?? []).filter( + (memberGroups ?? []).filter( (g) => !openGroups.map((og) => og.id).includes(g.id) ) ) .filter((group) => !ignoreGroupIds?.includes(group.id)) + .sort((a, b) => b.totalContracts - a.totalContracts) + // put the groups the user is a member of first + .sort((a, b) => { + if (memberGroupIds.includes(a.id)) { + return -1 + } + if (memberGroupIds.includes(b.id)) { + return 1 + } + return 0 + }) const filteredGroups = availableGroups.filter((group) => searchInAny(query, group.name) @@ -97,7 +112,7 @@ export function GroupSelector(props: { value={group} className={({ active }) => clsx( - 'relative h-12 cursor-pointer select-none py-2 pl-4 pr-6', + 'relative h-12 cursor-pointer select-none py-2 pr-6', active ? 'bg-indigo-500 text-white' : 'text-gray-900' ) } @@ -120,7 +135,14 @@ export function GroupSelector(props: { selected && 'font-semibold' )} > - <span className={'truncate'}>{group.name}</span> + <Row className={'items-center gap-1 truncate pl-5'}> + {memberGroupIds.includes(group.id) && ( + <UserIcon + className={'text-primary h-4 w-4 shrink-0'} + /> + )} + {group.name} + </Row> <span className={clsx( 'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white', diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 1f1a006b..5fb9549e 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -20,7 +20,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { getGroup } from 'web/lib/firebase/groups' +import { getGroup, groupPath } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' @@ -34,6 +34,8 @@ import { Title } from 'web/components/title' import { SEO } from 'web/components/SEO' import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers' import { MINUTE_MS } from 'common/util/time' +import { ExternalLinkIcon } from '@heroicons/react/outline' +import { SiteLink } from 'web/components/site-link' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -406,13 +408,19 @@ export function NewContract(props: { <Spacer h={6} /> - <GroupSelector - selectedGroup={selectedGroup} - setSelectedGroup={setSelectedGroup} - creator={creator} - options={{ showSelector: showGroupSelector, showLabel: true }} - /> - + <Row className={'items-end gap-x-2'}> + <GroupSelector + selectedGroup={selectedGroup} + setSelectedGroup={setSelectedGroup} + creator={creator} + options={{ showSelector: showGroupSelector, showLabel: true }} + /> + {showGroupSelector && selectedGroup && ( + <SiteLink href={groupPath(selectedGroup.slug)}> + <ExternalLinkIcon className=" ml-1 mb-3 h-5 w-5 text-gray-500" /> + </SiteLink> + )} + </Row> <Spacer h={6} /> <div className="form-control mb-1 items-start"> From 8759064ccb7f4cea4eef9a7a9524952383e91853 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 16:30:58 -0600 Subject: [PATCH 34/62] new bettors --- web/pages/notifications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 2ec3ac6f..ccfbf371 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -390,7 +390,7 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } unique traders on` + } new bettors on` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` From f7d027ccc99b6e3f88aa00721301679ad70f2b54 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 6 Sep 2022 16:38:01 -0600 Subject: [PATCH 35/62] Create button=>Site link --- web/components/create-question-button.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index 20225b78..d9146f1a 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -1,13 +1,13 @@ import React from 'react' -import Link from 'next/link' import { Button } from './button' +import { SiteLink } from 'web/components/site-link' export const CreateQuestionButton = () => { return ( - <Link href="/create" passHref> - <Button color="gradient" size="xl" className="mt-4"> + <SiteLink href="/create"> + <Button color="gradient" size="xl" className="mt-4 w-full"> Create a market </Button> - </Link> + </SiteLink> ) } From 537962a7dc233e6ac67307734d71dd0672e1a8cc Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 6 Sep 2022 16:55:33 -0700 Subject: [PATCH 36/62] Stop links from opening twice --- web/components/editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index c15d17b1..b36571ba 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -254,7 +254,7 @@ export function RichContent(props: { extensions: [ StarterKit, smallImage ? DisplayImage : Image, - DisplayLink, + DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, Iframe, TiptapTweet, From a9627bb2b65ac2f19840042048da6e9320a55d2d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 6 Sep 2022 22:12:18 -0500 Subject: [PATCH 37/62] market page: regenerate static props after 5 seconds --- web/pages/[username]/[contractSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index aeb50488..efc24fa2 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -69,7 +69,7 @@ export async function getStaticPropz(props: { comments: comments.slice(0, 1000), }, - revalidate: 60, // regenerate after a minute + revalidate: 5, // regenerate after five seconds } } From 85be84071a6365aaf6c4c8b2fdeabe0b41691483 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 6 Sep 2022 22:43:28 -0500 Subject: [PATCH 38/62] track embedded markets separtely --- web/hooks/use-is-iframe.ts | 2 +- web/hooks/use-tracking.ts | 8 +++++++- web/pages/[username]/[contractSlug].tsx | 14 +++++++++----- web/pages/embed/[username]/[contractSlug].tsx | 7 +++++++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/web/hooks/use-is-iframe.ts b/web/hooks/use-is-iframe.ts index 2ce7eda3..3085fa42 100644 --- a/web/hooks/use-is-iframe.ts +++ b/web/hooks/use-is-iframe.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -function inIframe() { +export function inIframe() { try { return window.self !== window.top } catch (e) { diff --git a/web/hooks/use-tracking.ts b/web/hooks/use-tracking.ts index 018e82a0..e62209c0 100644 --- a/web/hooks/use-tracking.ts +++ b/web/hooks/use-tracking.ts @@ -1,8 +1,14 @@ import { track } from '@amplitude/analytics-browser' import { useEffect } from 'react' +import { inIframe } from './use-is-iframe' -export const useTracking = (eventName: string, eventProperties?: any) => { +export const useTracking = ( + eventName: string, + eventProperties?: any, + excludeIframe?: boolean +) => { useEffect(() => { + if (excludeIframe && inIframe()) return track(eventName, eventProperties) }, []) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index efc24fa2..de0c7807 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -158,11 +158,15 @@ export function ContractPageContent( const contract = useContractWithPreload(props.contract) ?? props.contract usePrefetch(user?.id) - useTracking('view market', { - slug: contract.slug, - contractId: contract.id, - creatorId: contract.creatorId, - }) + useTracking( + 'view market', + { + slug: contract.slug, + contractId: contract.id, + creatorId: contract.creatorId, + }, + true + ) const bets = useBets(contract.id) ?? props.bets const nonChallengeBets = useMemo( diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 4a94b1db..c5fba0c8 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -21,6 +21,7 @@ import { SiteLink } from 'web/components/site-link' import { useContractWithPreload } from 'web/hooks/use-contract' import { useMeasureSize } from 'web/hooks/use-measure-size' import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { useTracking } from 'web/hooks/use-tracking' import { listAllBets } from 'web/lib/firebase/bets' import { contractPath, @@ -82,6 +83,12 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const { question, outcomeType } = contract + useTracking('view market embed', { + slug: contract.slug, + contractId: contract.id, + creatorId: contract.creatorId, + }) + const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' From 21870d7edb430c105ce3d971e77fd97fe9b7a60e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 6 Sep 2022 23:24:56 -0500 Subject: [PATCH 39/62] User page: Move portfolio graph and social stats to new tab --- .../portfolio/portfolio-value-graph.tsx | 11 ++++++--- .../portfolio/portfolio-value-section.tsx | 19 ++++++++++++--- web/components/user-page.tsx | 23 ++++++++++++------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index 61a1ce8b..d8489b47 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -8,16 +8,21 @@ import { formatTime } from 'web/lib/util/time' export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { portfolioHistory: PortfolioMetrics[] + mode: 'value' | 'profit' height?: number includeTime?: boolean }) { - const { portfolioHistory, height, includeTime } = props + const { portfolioHistory, height, includeTime, mode } = props const { width } = useWindowSize() const points = portfolioHistory.map((p) => { + const { timestamp, balance, investmentValue, totalDeposits } = p + const value = balance + investmentValue + const profit = value - totalDeposits + return { - x: new Date(p.timestamp), - y: p.balance + p.investmentValue, + x: new Date(timestamp), + y: mode === 'value' ? value : profit, } }) const data = [{ id: 'Value', data: points, color: '#11b981' }] diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index ab4bef0c..a7bce6bf 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -5,6 +5,7 @@ import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { Period } from 'web/lib/firebase/users' import { Col } from '../layout/col' import { Row } from '../layout/row' +import { Spacer } from '../layout/spacer' import { PortfolioValueGraph } from './portfolio-value-graph' export const PortfolioValueSection = memo( @@ -24,15 +25,16 @@ export const PortfolioValueSection = memo( return <></> } - const { balance, investmentValue } = lastPortfolioMetrics + const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics const totalValue = balance + investmentValue + const totalProfit = totalValue - totalDeposits return ( <> <Row className="gap-8"> <Col className="flex-1 justify-center"> - <div className="text-sm text-gray-500">Portfolio value</div> - <div className="text-lg">{formatMoney(totalValue)}</div> + <div className="text-sm text-gray-500">Profit</div> + <div className="text-lg">{formatMoney(totalProfit)}</div> </Col> <select className="select select-bordered self-start" @@ -49,6 +51,17 @@ export const PortfolioValueSection = memo( <PortfolioValueGraph portfolioHistory={currPortfolioHistory} includeTime={portfolioPeriod == 'daily'} + mode="profit" + /> + <Spacer h={8} /> + <Col className="flex-1 justify-center"> + <div className="text-sm text-gray-500">Portfolio value</div> + <div className="text-lg">{formatMoney(totalValue)}</div> + </Col> + <PortfolioValueGraph + portfolioHistory={currPortfolioHistory} + includeTime={portfolioPeriod == 'daily'} + mode="value" /> </> ) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index fd00888e..1dc59d87 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -255,13 +255,6 @@ export function UserPage(props: { user: User }) { title: 'Comments', content: ( <Col> - <Row className={'mt-2 mb-4 flex-wrap items-center gap-6'}> - <FollowingButton user={user} /> - <FollowersButton user={user} /> - <ReferralsButton user={user} /> - <GroupsButton user={user} /> - <UserLikesButton user={user} /> - </Row> <UserCommentsList user={user} /> </Col> ), @@ -270,11 +263,25 @@ export function UserPage(props: { user: User }) { title: 'Bets', content: ( <> - <PortfolioValueSection userId={user.id} /> <BetsList user={user} /> </> ), }, + { + title: 'Stats', + content: ( + <Col className="mb-8"> + <Row className={'mt-2 mb-8 flex-wrap items-center gap-6'}> + <FollowingButton user={user} /> + <FollowersButton user={user} /> + <ReferralsButton user={user} /> + <GroupsButton user={user} /> + <UserLikesButton user={user} /> + </Row> + <PortfolioValueSection userId={user.id} /> + </Col> + ), + }, ]} /> </Col> From 082125bd2fa1ff424f9ecd3ceae59cc46df4e91d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 6 Sep 2022 23:31:02 -0500 Subject: [PATCH 40/62] Remove some margin --- web/components/bets-list.tsx | 2 +- web/components/user-page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 2a9a76a1..ab232927 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -161,7 +161,7 @@ export function BetsList(props: { user: User }) { ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 return ( - <Col className="mt-6"> + <Col> <Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0"> <Row className="gap-8"> <Col> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 1dc59d87..905f14f5 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -271,7 +271,7 @@ export function UserPage(props: { user: User }) { title: 'Stats', content: ( <Col className="mb-8"> - <Row className={'mt-2 mb-8 flex-wrap items-center gap-6'}> + <Row className={'mb-8 flex-wrap items-center gap-6'}> <FollowingButton user={user} /> <FollowersButton user={user} /> <ReferralsButton user={user} /> From a40bdc28be178663162d492921b5826c0f6e275c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 6 Sep 2022 23:39:50 -0500 Subject: [PATCH 41/62] Remove some excess spacing on user page --- web/components/user-page.tsx | 104 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 905f14f5..2d4db1eb 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -168,62 +168,63 @@ export function UserPage(props: { user: User }) { <Spacer h={4} /> </> )} - <Row className="flex-wrap items-center gap-2 sm:gap-4"> - {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> - </Row> - </SiteLink> - )} + {(user.website || user.twitterHandle || user.discordHandle) && ( + <Row className="mb-5 flex-wrap items-center gap-2 sm:gap-4"> + {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> + </Row> + </SiteLink> + )} - {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" - className="h-4 w-4" - alt="Twitter" - /> - <span className="text-sm text-gray-500"> - {user.twitterHandle} - </span> - </Row> - </SiteLink> - )} + {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" + className="h-4 w-4" + alt="Twitter" + /> + <span className="text-sm text-gray-500"> + {user.twitterHandle} + </span> + </Row> + </SiteLink> + )} - {user.discordHandle && ( - <SiteLink href="https://discord.com/invite/eHQBNBqXuh"> - <Row className="items-center gap-1"> - <img - src="/discord-logo.svg" - className="h-4 w-4" - alt="Discord" - /> - <span className="text-sm text-gray-500"> - {user.discordHandle} - </span> - </Row> - </SiteLink> - )} - </Row> - <Spacer h={5} /> + {user.discordHandle && ( + <SiteLink href="https://discord.com/invite/eHQBNBqXuh"> + <Row className="items-center gap-1"> + <img + src="/discord-logo.svg" + className="h-4 w-4" + alt="Discord" + /> + <span className="text-sm text-gray-500"> + {user.discordHandle} + </span> + </Row> + </SiteLink> + )} + </Row> + )} {currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && ( <Row className={ - 'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600' + 'mb-5 w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600' } > <span> @@ -240,7 +241,6 @@ export function UserPage(props: { user: User }) { /> </Row> )} - <Spacer h={5} /> <QueryUncontrolledTabs currentPageForAnalytics={'profile'} labelClassName={'pb-2 pt-1 '} From ad18987e65ae132045ea260b6b29b2c96fd0c69c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 7 Sep 2022 01:18:11 -0500 Subject: [PATCH 42/62] Update Daily movers UI --- web/components/contract/prob-change-table.tsx | 54 ++++++++++--------- web/pages/experimental/home/index.tsx | 1 + 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 9f1f171d..f6e5d892 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -4,12 +4,13 @@ import { CPMMContract } from 'common/contract' import { formatPercent } from 'common/util/format' import { useProbChanges } from 'web/hooks/use-prob-changes' import { SiteLink } from '../site-link' +import { Col } from '../layout/col' +import { Row } from '../layout/row' export function ProbChangeTable(props: { userId: string | undefined }) { const { userId } = props const changes = useProbChanges(userId ?? '') - console.log('changes', changes) if (!changes) { return null @@ -20,31 +21,34 @@ export function ProbChangeTable(props: { userId: string | undefined }) { const count = 3 return ( - <div className="grid max-w-xl gap-x-2 gap-y-2 rounded bg-white p-4 text-gray-700"> - <div className="text-xl text-gray-800">Daily movers</div> - <div className="text-right">% pts</div> - {positiveChanges.slice(0, count).map((contract) => ( - <> - <div className="line-clamp-2"> - <SiteLink href={contractPath(contract)}> + <Row className="w-full flex-wrap divide-x-2 rounded bg-white shadow-md"> + <Col className="min-w-[300px] flex-1 divide-y"> + {positiveChanges.slice(0, count).map((contract) => ( + <Row className="hover:bg-gray-100"> + <ProbChange className="p-4 text-right" contract={contract} /> + <SiteLink + className="p-4 font-semibold text-indigo-700" + href={contractPath(contract)} + > {contract.question} </SiteLink> - </div> - <ProbChange className="text-right" contract={contract} /> - </> - ))} - <div className="col-span-2 my-2" /> - {negativeChanges.slice(0, count).map((contract) => ( - <> - <div className="line-clamp-2"> - <SiteLink href={contractPath(contract)}> + </Row> + ))} + </Col> + <Col className="justify-content-stretch min-w-[300px] flex-1 divide-y"> + {negativeChanges.slice(0, count).map((contract) => ( + <Row className="hover:bg-gray-100"> + <ProbChange className="p-4 text-right" contract={contract} /> + <SiteLink + className="p-4 font-semibold text-indigo-700" + href={contractPath(contract)} + > {contract.question} </SiteLink> - </div> - <ProbChange className="text-right" contract={contract} /> - </> - ))} - </div> + </Row> + ))} + </Col> + </Row> ) } @@ -59,10 +63,10 @@ export function ProbChange(props: { const color = change > 0 - ? 'text-green-500' + ? 'text-green-600' : change < 0 - ? 'text-red-500' - : 'text-gray-500' + ? 'text-red-600' + : 'text-gray-600' const str = change === 0 diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 606b66c4..0f02b002 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -77,6 +77,7 @@ const Home = (props: { auth: { user: User } | null }) => { </> ) : ( <> + <div className="text-xl text-gray-800">Daily movers</div> <ProbChangeTable userId={user?.id} /> {visibleItems.map((item) => { From 87060488f5b5cc09cbf1bbbbaa3271dc1c0eead8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 7 Sep 2022 07:13:34 -0600 Subject: [PATCH 43/62] Convert market to lite market for Phil --- web/lib/firebase/groups.ts | 3 ++- web/pages/api/v0/group/by-id/[id]/markets.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 36bfe7cc..0366fe0b 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -86,9 +86,10 @@ export async function listGroupContracts(groupId: string) { contractId: string createdTime: number }>(groupContracts(groupId)) - return Promise.all( + const contracts = await Promise.all( contractDocs.map((doc) => getContractFromId(doc.contractId)) ) + return filterDefined(contracts) } export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { diff --git a/web/pages/api/v0/group/by-id/[id]/markets.ts b/web/pages/api/v0/group/by-id/[id]/markets.ts index f7538277..e9610a20 100644 --- a/web/pages/api/v0/group/by-id/[id]/markets.ts +++ b/web/pages/api/v0/group/by-id/[id]/markets.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' import { listGroupContracts } from 'web/lib/firebase/groups' +import { toLiteMarket } from 'web/pages/api/v0/_types' export default async function handler( req: NextApiRequest, @@ -8,7 +9,9 @@ export default async function handler( ) { await applyCorsHeaders(req, res, CORS_UNRESTRICTED) const { id } = req.query - const contracts = await listGroupContracts(id as string) + const contracts = (await listGroupContracts(id as string)).map((contract) => + toLiteMarket(contract) + ) if (!contracts) { res.status(404).json({ error: 'Group not found' }) return From cce14cbe1fd8efa15f7de7ed82f09a2922b39460 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Wed, 7 Sep 2022 17:04:30 +0100 Subject: [PATCH 44/62] Toggle monthly leaderboards (#790) * Toggle monthly leaderboards I didn't get to enabling monthly leaderboards after my work trial was over (I enabled daily/weekly/alltime). The cache has been filled out for a while now, this toggles it on. * Fix nits --- .../portfolio/portfolio-value-section.tsx | 1 + web/pages/leaderboards.tsx | 17 ++++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index a7bce6bf..a0006c60 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -44,6 +44,7 @@ export const PortfolioValueSection = memo( }} > <option value="allTime">All time</option> + <option value="monthly">Last Month</option> <option value="weekly">Last 7d</option> <option value="daily">Last 24h</option> </select> diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 45c484c4..08819833 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -135,21 +135,20 @@ export default function Leaderboards(_props: { defaultIndex={1} tabs={[ { - title: 'All Time', - content: LeaderboardWithPeriod('allTime'), + title: 'Daily', + content: LeaderboardWithPeriod('daily'), }, - // TODO: Enable this near the end of July! - // { - // title: 'Monthly', - // content: LeaderboardWithPeriod('monthly'), - // }, { title: 'Weekly', content: LeaderboardWithPeriod('weekly'), }, { - title: 'Daily', - content: LeaderboardWithPeriod('daily'), + title: 'Monthly', + content: LeaderboardWithPeriod('monthly'), + }, + { + title: 'All Time', + content: LeaderboardWithPeriod('allTime'), }, ]} /> From 28af2063c3f735af9311559cc9895f743c88ab93 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 7 Sep 2022 14:45:04 -0500 Subject: [PATCH 45/62] "bet" => "trade" --- functions/src/place-bet.ts | 2 +- web/components/answers/answer-bet-panel.tsx | 2 +- web/components/arrange-home.tsx | 2 +- web/components/bet-button.tsx | 2 +- web/components/bet-panel.tsx | 12 ++++++------ web/components/contract-search.tsx | 2 +- web/components/contract/contract-details.tsx | 2 +- web/components/contract/contract-info-dialog.tsx | 2 +- web/components/contract/contract-leaderboard.tsx | 2 +- web/components/contract/contract-tabs.tsx | 4 ++-- web/components/play-money-disclaimer.tsx | 2 +- web/components/profile/loans-modal.tsx | 2 +- web/components/resolution-panel.tsx | 4 ++-- web/components/user-page.tsx | 2 +- web/components/yes-no-selector.tsx | 2 +- web/pages/experimental/home/index.tsx | 2 +- web/pages/notifications.tsx | 4 ++-- 17 files changed, 25 insertions(+), 25 deletions(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 404fda50..d98430c1 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { !isFinite(newP) || Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) ) { - throw new APIError(400, 'Bet too large for current liquidity pool.') + throw new APIError(400, 'Trade too large for current liquidity pool.') } const betDoc = contractDoc.collection('bets').doc() diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index ace06b6c..dbf7ff11 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -120,7 +120,7 @@ export function AnswerBetPanel(props: { <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}> <Row className="items-center justify-between self-stretch"> <div className="text-xl"> - Bet on {isModal ? `"${answer.text}"` : 'this answer'} + Buy answer: {isModal ? `"${answer.text}"` : 'this answer'} </div> {!isModal && ( diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 2c43788c..2f49d144 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -112,7 +112,7 @@ export const getHomeItems = ( { label: 'Trending', id: 'score' }, { label: 'Newest', id: 'newest' }, { label: 'Close date', id: 'close-date' }, - { label: 'Your bets', id: 'your-bets' }, + { label: 'Your trades', id: 'your-bets' }, ...groups.map((g) => ({ label: g.name, id: g.id, diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 7d84bbc0..77b17678 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -38,7 +38,7 @@ export default function BetButton(props: { className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} onClick={() => setOpen(true)} > - Bet + Trade </Button> ) : ( <BetSignUpPrompt /> diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index c48e92a9..1f2b9bd3 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -281,7 +281,7 @@ function BuyPanel(props: { title="Whoa, there!" text={`You might not want to spend ${formatPercent( bankrollFraction - )} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( + )} of your balance on a single trade. \n\nCurrent balance: ${formatMoney( user?.balance ?? 0 )}`} /> @@ -379,11 +379,11 @@ function BuyPanel(props: { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Submit bet'} + {isSubmitting ? 'Submitting...' : 'Submit trade'} </button> )} - {wasSubmitted && <div className="mt-4">Bet submitted!</div>} + {wasSubmitted && <div className="mt-4">Trade submitted!</div>} </Col> ) } @@ -569,7 +569,7 @@ function LimitOrderPanel(props: { <Row className="mt-1 items-center gap-4"> <Col className="gap-2"> <div className="relative ml-1 text-sm text-gray-500"> - Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to + Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to </div> <ProbabilityOrNumericInput contract={contract} @@ -580,7 +580,7 @@ function LimitOrderPanel(props: { </Col> <Col className="gap-2"> <div className="ml-1 text-sm text-gray-500"> - Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to + Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to </div> <ProbabilityOrNumericInput contract={contract} @@ -750,7 +750,7 @@ function QuickOrLimitBet(props: { return ( <Row className="align-center mb-4 justify-between"> - <div className="text-4xl">Bet</div> + <div className="text-4xl">Trade</div> {!hideToggle && ( <Row className="mt-1 items-center gap-2"> <PillButton diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 0beedc1b..8e3b18e0 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -441,7 +441,7 @@ function ContractSearchControls(props: { selected={state.pillFilter === 'your-bets'} onSelect={selectPill('your-bets')} > - Your bets + Your trades </PillButton> )} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 48528029..c383d349 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -294,7 +294,7 @@ export function ExtraMobileContractDetails(props: { <Tooltip text={`${formatMoney( volume - )} bet - ${uniqueBettors} unique bettors`} + )} bet - ${uniqueBettors} unique traders`} > {volumeTranslation} </Tooltip> diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index f376a04a..ae586725 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -135,7 +135,7 @@ export function ContractInfoDialog(props: { </tr> */} <tr> - <td>Bettors</td> + <td>Traders</td> <td>{bettorsCount}</td> </tr> diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index ce5c7da6..1eaf7043 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -49,7 +49,7 @@ export function ContractLeaderboard(props: { return users && users.length > 0 ? ( <Leaderboard - title="🏅 Top bettors" + title="🏅 Top traders" users={users || []} columns={[ { diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 417de12b..40fa9da0 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -116,13 +116,13 @@ export function ContractTabs(props: { badge: `${comments.length}`, }, { - title: 'Bets', + title: 'Trades', content: betActivity, badge: `${visibleBets.length}`, }, ...(!user || !userBets?.length ? [] - : [{ title: 'Your bets', content: yourTrades }]), + : [{ title: 'Your trades', content: yourTrades }]), ]} /> {!user ? ( diff --git a/web/components/play-money-disclaimer.tsx b/web/components/play-money-disclaimer.tsx index 6ee16c1e..a3bda242 100644 --- a/web/components/play-money-disclaimer.tsx +++ b/web/components/play-money-disclaimer.tsx @@ -4,6 +4,6 @@ export const PlayMoneyDisclaimer = () => ( <InfoBox title="Play-money betting" className="mt-4 max-w-md" - text="Mana (M$) is the play-money used by our platform to keep track of your bets. It's completely free for you and your friends to get started!" + text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!" /> ) diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 46be649a..24b23e5b 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -11,7 +11,7 @@ export function LoansModal(props: { <Modal open={isOpen} setOpen={setOpen}> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <span className={'text-8xl'}>🏦</span> - <span className="text-xl">Daily loans on your bets</span> + <span className="text-xl">Daily loans on your trades</span> <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What are daily loans?</span> <span className={'ml-2'}> diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index fe062d06..5a7b993e 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -83,14 +83,14 @@ export function ResolutionPanel(props: { <div> {outcome === 'YES' ? ( <> - Winnings will be paid out to YES bettors. + Winnings will be paid out to traders who bought YES. {/* <br /> <br /> You will earn {earnedFees}. */} </> ) : outcome === 'NO' ? ( <> - Winnings will be paid out to NO bettors. + Winnings will be paid out to traders who bought NO. {/* <br /> <br /> You will earn {earnedFees}. */} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 2d4db1eb..81aed562 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -260,7 +260,7 @@ export function UserPage(props: { user: User }) { ), }, { - title: 'Bets', + title: 'Trades', content: ( <> <BetsList user={user} /> diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index aaf1764e..719308bf 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -193,7 +193,7 @@ export function BuyButton(props: { className?: string; onClick?: () => void }) { )} onClick={onClick} > - Bet + Buy </button> ) } diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 0f02b002..fb0b488d 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -86,7 +86,7 @@ const Home = (props: { auth: { user: User } | null }) => { return ( <SearchSection key={id} - label={'Your bets'} + label={'Your trades'} sort={'prob-change-day'} user={user} yourBets diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index ccfbf371..d10812bf 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -390,7 +390,7 @@ function IncomeNotificationItem(props: { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } new bettors on` + } new traders on` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` @@ -508,7 +508,7 @@ function IncomeNotificationItem(props: { {(isTip || isUniqueBettorBonus) && ( <MultiUserTransactionLink userInfos={userLinks} - modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'} + modalLabel={isTip ? 'Who tipped you' : 'Unique traders'} /> )} <Row className={'line-clamp-2 flex max-w-xl'}> From b4e0e9ebc0b698bd4e36a2f0bf58107bb49be205 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 7 Sep 2022 15:01:02 -0500 Subject: [PATCH 46/62] "A market for every question" --- web/components/landing-page-panel.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/web/components/landing-page-panel.tsx b/web/components/landing-page-panel.tsx index 2e3d85e2..a5b46b08 100644 --- a/web/components/landing-page-panel.tsx +++ b/web/components/landing-page-panel.tsx @@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) { <div className="m-4 max-w-[550px] self-center"> <h1 className="text-3xl sm:text-6xl xl:text-6xl"> <div className="font-semibold sm:mb-2"> - Predict{' '} + A{' '} <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> - anything! - </span> + market + </span>{' '} + for every question </div> </h1> <Spacer h={6} /> <div className="mb-4 px-2 "> - Create a play-money prediction market on any topic you care about - and bet with your friends on what will happen! + Create a play-money prediction market on any topic you care about. + Trade with your friends to forecast the future. <br /> - {/* <br /> - Sign up and get {formatMoney(1000)} - worth $10 to your{' '} - <SiteLink className="font-semibold" href="/charity"> - favorite charity. - </SiteLink> - <br /> */} </div> </div> <Spacer h={6} /> From b3343c210a736d04470b6b0cc20ec71aed576333 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 7 Sep 2022 15:04:34 -0500 Subject: [PATCH 47/62] more "bet" => "trade" --- web/components/play-money-disclaimer.tsx | 2 +- web/components/sign-up-prompt.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/play-money-disclaimer.tsx b/web/components/play-money-disclaimer.tsx index a3bda242..860075d0 100644 --- a/web/components/play-money-disclaimer.tsx +++ b/web/components/play-money-disclaimer.tsx @@ -2,7 +2,7 @@ import { InfoBox } from './info-box' export const PlayMoneyDisclaimer = () => ( <InfoBox - title="Play-money betting" + title="Play-money trading" className="mt-4 max-w-md" text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!" /> diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 5ade4c1f..239fb4ad 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: { size={size} color="gradient" > - {label ?? 'Sign up to bet!'} + {label ?? 'Sign up to trade!'} </Button> ) : null } From ce52f21ce97383c26ef6c232067e53c6761e59e1 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 7 Sep 2022 15:13:17 -0500 Subject: [PATCH 48/62] fix sidebar profile link to your trades --- web/components/nav/profile-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index aad17d84..e7cc056f 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics' export function ProfileSummary(props: { user: User }) { const { user } = props return ( - <Link href={`/${user.username}?tab=bets`}> + <Link href={`/${user.username}?tab=trades`}> <a onClick={trackCallback('sidebar: profile')} className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" From 0acdec787d5c583d758f630c7335df3f16f84013 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Wed, 7 Sep 2022 23:09:20 +0100 Subject: [PATCH 49/62] Adds comments to posts (#844) * Adds comments to posts * Uncoupled CommentInput from Contracts * Fix nits --- common/comment.ts | 12 +- firestore.rules | 4 + web/components/comment-input.tsx | 175 +++++++++++ web/components/feed/contract-activity.tsx | 4 +- .../feed/feed-answer-comment-group.tsx | 4 +- web/components/feed/feed-comments.tsx | 273 +++++------------- web/components/tipper.tsx | 4 +- web/hooks/use-comments.ts | 18 +- web/hooks/use-tip-txns.ts | 7 +- web/lib/firebase/comments.ts | 109 +++++-- web/lib/firebase/txns.ts | 14 + web/pages/post/[...slugs]/index.tsx | 77 ++++- web/posts/post-comments.tsx | 172 +++++++++++ 13 files changed, 635 insertions(+), 238 deletions(-) create mode 100644 web/components/comment-input.tsx create mode 100644 web/posts/post-comments.tsx diff --git a/common/comment.ts b/common/comment.ts index 3a4bd9ac..7ecbb6d4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,6 +1,6 @@ import type { JSONContent } from '@tiptap/core' -export type AnyCommentType = OnContract | OnGroup +export type AnyCommentType = OnContract | OnGroup | OnPost // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. @@ -20,7 +20,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = { userAvatarUrl?: string } & T -type OnContract = { +export type OnContract = { commentType: 'contract' contractId: string answerOutcome?: string @@ -35,10 +35,16 @@ type OnContract = { betOutcome?: string } -type OnGroup = { +export type OnGroup = { commentType: 'group' groupId: string } +export type OnPost = { + commentType: 'post' + postId: string +} + export type ContractComment = Comment<OnContract> export type GroupComment = Comment<OnGroup> +export type PostComment = Comment<OnPost> diff --git a/firestore.rules b/firestore.rules index 15b60d0f..30bf0ec9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -203,6 +203,10 @@ service cloud.firestore { .affectedKeys() .hasOnly(['name', 'content']); allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; + match /comments/{commentId} { + allow read; + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ; + } } } } diff --git a/web/components/comment-input.tsx b/web/components/comment-input.tsx new file mode 100644 index 00000000..1d0b3cc1 --- /dev/null +++ b/web/components/comment-input.tsx @@ -0,0 +1,175 @@ +import { PaperAirplaneIcon } from '@heroicons/react/solid' +import { Editor } from '@tiptap/react' +import clsx from 'clsx' +import { User } from 'common/user' +import { useEffect, useState } from 'react' +import { useUser } from 'web/hooks/use-user' +import { useWindowSize } from 'web/hooks/use-window-size' +import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments' +import { Avatar } from './avatar' +import { TextEditor, useTextEditor } from './editor' +import { Row } from './layout/row' +import { LoadingIndicator } from './loading-indicator' + +export function CommentInput(props: { + replyToUser?: { id: string; username: string } + // Reply to a free response answer + parentAnswerOutcome?: string + // Reply to another comment + parentCommentId?: string + onSubmitComment?: (editor: Editor, betId: string | undefined) => void + className?: string + presetId?: string +}) { + const { + parentAnswerOutcome, + parentCommentId, + replyToUser, + onSubmitComment, + presetId, + } = props + const user = useUser() + + const { editor, upload } = useTextEditor({ + simple: true, + max: MAX_COMMENT_LENGTH, + placeholder: + !!parentCommentId || !!parentAnswerOutcome + ? 'Write a reply...' + : 'Write a comment...', + }) + + const [isSubmitting, setIsSubmitting] = useState(false) + + async function submitComment(betId: string | undefined) { + if (!editor || editor.isEmpty || isSubmitting) return + setIsSubmitting(true) + onSubmitComment?.(editor, betId) + setIsSubmitting(false) + } + + if (user?.isBannedFromPosting) return <></> + + return ( + <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}> + <Avatar + avatarUrl={user?.avatarUrl} + username={user?.username} + size="sm" + className="mt-2" + /> + <div className="min-w-0 flex-1 pl-0.5 text-sm"> + <CommentInputTextArea + editor={editor} + upload={upload} + replyToUser={replyToUser} + user={user} + submitComment={submitComment} + isSubmitting={isSubmitting} + presetId={presetId} + /> + </div> + </Row> + ) +} + +export function CommentInputTextArea(props: { + user: User | undefined | null + replyToUser?: { id: string; username: string } + editor: Editor | null + upload: Parameters<typeof TextEditor>[0]['upload'] + submitComment: (id?: string) => void + isSubmitting: boolean + submitOnEnter?: boolean + presetId?: string +}) { + const { + user, + editor, + upload, + submitComment, + presetId, + isSubmitting, + submitOnEnter, + replyToUser, + } = props + const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch) + + useEffect(() => { + editor?.setEditable(!isSubmitting) + }, [isSubmitting, editor]) + + const submit = () => { + submitComment(presetId) + editor?.commands?.clearContent() + } + + useEffect(() => { + if (!editor) { + return + } + // submit on Enter key + editor.setOptions({ + editorProps: { + handleKeyDown: (view, event) => { + if ( + submitOnEnter && + event.key === 'Enter' && + !event.shiftKey && + (!isMobile || event.ctrlKey || event.metaKey) && + // mention list is closed + !(view.state as any).mention$.active + ) { + submit() + event.preventDefault() + return true + } + return false + }, + }, + }) + // insert at mention and focus + if (replyToUser) { + editor + .chain() + .setContent({ + type: 'mention', + attrs: { label: replyToUser.username, id: replyToUser.id }, + }) + .insertContent(' ') + .focus() + .run() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor]) + + return ( + <> + <TextEditor editor={editor} upload={upload}> + {user && !isSubmitting && ( + <button + className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" + disabled={!editor || editor.isEmpty} + onClick={submit} + > + <PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" /> + </button> + )} + + {isSubmitting && ( + <LoadingIndicator spinnerClassName={'border-gray-500'} /> + )} + </TextEditor> + <Row> + {!user && ( + <button + className={'btn btn-outline btn-sm mt-2 normal-case'} + onClick={() => submitComment(presetId)} + > + Add my comment + </button> + )} + </Row> + </> + ) +} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 0878e570..55b8a958 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate' import { FeedBet } from './feed-bets' import { FeedLiquidity } from './feed-liquidity' import { FeedAnswerCommentGroup } from './feed-answer-comment-group' -import { FeedCommentThread, CommentInput } from './feed-comments' +import { FeedCommentThread, ContractCommentInput } from './feed-comments' import { User } from 'common/user' import { CommentTipMap } from 'web/hooks/use-tip-txns' import { LiquidityProvision } from 'common/liquidity-provision' @@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: { return ( <> - <CommentInput + <ContractCommentInput className="mb-5" contract={contract} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 7758ec82..0535ac33 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar' import { Linkify } from 'web/components/linkify' import clsx from 'clsx' import { - CommentInput, + ContractCommentInput, FeedComment, getMostRecentCommentableBet, } from 'web/components/feed/feed-comments' @@ -177,7 +177,7 @@ export function FeedAnswerCommentGroup(props: { className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> - <CommentInput + <ContractCommentInput contract={contract} betsByCurrentUser={betsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index fa2cc6f5..a63a4b6e 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -13,22 +13,18 @@ import { Avatar } from 'web/components/avatar' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' -import { - createCommentOnContract, - MAX_COMMENT_LENGTH, -} from 'web/lib/firebase/comments' +import { createCommentOnContract } from 'web/lib/firebase/comments' import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' import { getProbability } from 'common/calculate' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { PaperAirplaneIcon } from '@heroicons/react/outline' import { track } from 'web/lib/service/analytics' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' -import { useWindowSize } from 'web/hooks/use-window-size' -import { Content, TextEditor, useTextEditor } from '../editor' + +import { Content } from '../editor' import { Editor } from '@tiptap/react' import { UserLink } from 'web/components/user-link' +import { CommentInput } from '../comment-input' export function FeedCommentThread(props: { user: User | null | undefined @@ -90,14 +86,16 @@ export function FeedCommentThread(props: { className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> - <CommentInput + <ContractCommentInput contract={contract} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} parentCommentId={parentComment.id} replyToUser={replyTo} parentAnswerOutcome={parentComment.answerOutcome} - onSubmitComment={() => setShowReply(false)} + onSubmitComment={() => { + setShowReply(false) + }} /> </Col> )} @@ -267,67 +265,76 @@ function CommentStatus(props: { ) } -//TODO: move commentinput and comment input text area into their own files -export function CommentInput(props: { +export function ContractCommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: ContractComment[] className?: string + parentAnswerOutcome?: string | undefined replyToUser?: { id: string; username: string } - // Reply to a free response answer - parentAnswerOutcome?: string - // Reply to another comment parentCommentId?: string onSubmitComment?: () => void }) { - const { - contract, - betsByCurrentUser, - commentsByCurrentUser, - className, - parentAnswerOutcome, - parentCommentId, - replyToUser, - onSubmitComment, - } = props const user = useUser() - const { editor, upload } = useTextEditor({ - simple: true, - max: MAX_COMMENT_LENGTH, - placeholder: - !!parentCommentId || !!parentAnswerOutcome - ? 'Write a reply...' - : 'Write a comment...', - }) - const [isSubmitting, setIsSubmitting] = useState(false) - - const mostRecentCommentableBet = getMostRecentCommentableBet( - betsByCurrentUser, - commentsByCurrentUser, - user, - parentAnswerOutcome - ) - const { id } = mostRecentCommentableBet || { id: undefined } - - async function submitComment(betId: string | undefined) { + async function onSubmitComment(editor: Editor, betId: string | undefined) { if (!user) { track('sign in to comment') return await firebaseLogin() } - if (!editor || editor.isEmpty || isSubmitting) return - setIsSubmitting(true) await createCommentOnContract( - contract.id, + props.contract.id, editor.getJSON(), user, betId, - parentAnswerOutcome, - parentCommentId + props.parentAnswerOutcome, + props.parentCommentId ) - onSubmitComment?.() - setIsSubmitting(false) + props.onSubmitComment?.() } + const mostRecentCommentableBet = getMostRecentCommentableBet( + props.betsByCurrentUser, + props.commentsByCurrentUser, + user, + props.parentAnswerOutcome + ) + + const { id } = mostRecentCommentableBet || { id: undefined } + + return ( + <Col> + <CommentBetArea + betsByCurrentUser={props.betsByCurrentUser} + contract={props.contract} + commentsByCurrentUser={props.commentsByCurrentUser} + parentAnswerOutcome={props.parentAnswerOutcome} + user={useUser()} + className={props.className} + mostRecentCommentableBet={mostRecentCommentableBet} + /> + <CommentInput + replyToUser={props.replyToUser} + parentAnswerOutcome={props.parentAnswerOutcome} + parentCommentId={props.parentCommentId} + onSubmitComment={onSubmitComment} + className={props.className} + presetId={id} + /> + </Col> + ) +} + +function CommentBetArea(props: { + betsByCurrentUser: Bet[] + contract: Contract + commentsByCurrentUser: ContractComment[] + parentAnswerOutcome?: string + user?: User | null + className?: string + mostRecentCommentableBet?: Bet +}) { + const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props + const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( contract, Date.now(), @@ -336,158 +343,36 @@ export function CommentInput(props: { const isNumeric = contract.outcomeType === 'NUMERIC' - if (user?.isBannedFromPosting) return <></> - return ( - <Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}> - <Avatar - avatarUrl={user?.avatarUrl} - username={user?.username} - size="sm" - className="mt-2" - /> - <div className="min-w-0 flex-1 pl-0.5 text-sm"> - <div className="mb-1 text-gray-500"> - {mostRecentCommentableBet && ( - <BetStatusText + <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}> + <div className="mb-1 text-gray-500"> + {mostRecentCommentableBet && ( + <BetStatusText + contract={contract} + bet={mostRecentCommentableBet} + isSelf={true} + hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'} + /> + )} + {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( + <> + {"You're"} + <CommentStatus + outcome={outcome} contract={contract} - bet={mostRecentCommentableBet} - isSelf={true} - hideOutcome={ - isNumeric || contract.outcomeType === 'FREE_RESPONSE' + prob={ + contract.outcomeType === 'BINARY' + ? getProbability(contract) + : undefined } /> - )} - {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( - <> - {"You're"} - <CommentStatus - outcome={outcome} - contract={contract} - prob={ - contract.outcomeType === 'BINARY' - ? getProbability(contract) - : undefined - } - /> - </> - )} - </div> - <CommentInputTextArea - editor={editor} - upload={upload} - replyToUser={replyToUser} - user={user} - submitComment={submitComment} - isSubmitting={isSubmitting} - presetId={id} - /> + </> + )} </div> </Row> ) } -export function CommentInputTextArea(props: { - user: User | undefined | null - replyToUser?: { id: string; username: string } - editor: Editor | null - upload: Parameters<typeof TextEditor>[0]['upload'] - submitComment: (id?: string) => void - isSubmitting: boolean - submitOnEnter?: boolean - presetId?: string -}) { - const { - user, - editor, - upload, - submitComment, - presetId, - isSubmitting, - submitOnEnter, - replyToUser, - } = props - const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch) - - useEffect(() => { - editor?.setEditable(!isSubmitting) - }, [isSubmitting, editor]) - - const submit = () => { - submitComment(presetId) - editor?.commands?.clearContent() - } - - useEffect(() => { - if (!editor) { - return - } - // submit on Enter key - editor.setOptions({ - editorProps: { - handleKeyDown: (view, event) => { - if ( - submitOnEnter && - event.key === 'Enter' && - !event.shiftKey && - (!isMobile || event.ctrlKey || event.metaKey) && - // mention list is closed - !(view.state as any).mention$.active - ) { - submit() - event.preventDefault() - return true - } - return false - }, - }, - }) - // insert at mention and focus - if (replyToUser) { - editor - .chain() - .setContent({ - type: 'mention', - attrs: { label: replyToUser.username, id: replyToUser.id }, - }) - .insertContent(' ') - .focus() - .run() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor]) - - return ( - <> - <TextEditor editor={editor} upload={upload}> - {user && !isSubmitting && ( - <button - className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300" - disabled={!editor || editor.isEmpty} - onClick={submit} - > - <PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" /> - </button> - )} - - {isSubmitting && ( - <LoadingIndicator spinnerClassName={'border-gray-500'} /> - )} - </TextEditor> - <Row> - {!user && ( - <button - className={'btn btn-outline btn-sm mt-2 normal-case'} - onClick={() => submitComment(presetId)} - > - Add my comment - </button> - )} - </Row> - </> - ) -} - function getBettorsLargestPositionBeforeTime( contract: Contract, createdTime: number, diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index b9ebdefc..7aef6189 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { comment.commentType === 'contract' ? comment.contractId : undefined const groupId = comment.commentType === 'group' ? comment.groupId : undefined + const postId = comment.commentType === 'post' ? comment.postId : undefined await transact({ amount: change, fromId: user.id, @@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { toType: 'USER', token: 'M$', category: 'TIP', - data: { commentId: comment.id, contractId, groupId }, + data: { commentId: comment.id, contractId, groupId, postId }, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, }) @@ -62,6 +63,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { commentId: comment.id, contractId, groupId, + postId, amount: change, fromId: user.id, toId: comment.userId, diff --git a/web/hooks/use-comments.ts b/web/hooks/use-comments.ts index 172d2cee..b380d53f 100644 --- a/web/hooks/use-comments.ts +++ b/web/hooks/use-comments.ts @@ -1,8 +1,14 @@ import { useEffect, useState } from 'react' -import { Comment, ContractComment, GroupComment } from 'common/comment' +import { + Comment, + ContractComment, + GroupComment, + PostComment, +} from 'common/comment' import { listenForCommentsOnContract, listenForCommentsOnGroup, + listenForCommentsOnPost, listenForRecentComments, } from 'web/lib/firebase/comments' @@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => { return comments } +export const useCommentsOnPost = (postId: string | undefined) => { + const [comments, setComments] = useState<PostComment[] | undefined>() + + useEffect(() => { + if (postId) return listenForCommentsOnPost(postId, setComments) + }, [postId]) + + return comments +} + export const useRecentComments = () => { const [recentComments, setRecentComments] = useState<Comment[] | undefined>() useEffect(() => listenForRecentComments(setRecentComments), []) diff --git a/web/hooks/use-tip-txns.ts b/web/hooks/use-tip-txns.ts index 8d26176f..8726fd6e 100644 --- a/web/hooks/use-tip-txns.ts +++ b/web/hooks/use-tip-txns.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react' import { listenForTipTxns, listenForTipTxnsOnGroup, + listenForTipTxnsOnPost, } from 'web/lib/firebase/txns' export type CommentTips = { [userId: string]: number } @@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips } export function useTipTxns(on: { contractId?: string groupId?: string + postId?: string }): CommentTipMap { const [txns, setTxns] = useState<TipTxn[]>([]) - const { contractId, groupId } = on + const { contractId, groupId, postId } = on useEffect(() => { if (contractId) return listenForTipTxns(contractId, setTxns) if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns) - }, [contractId, groupId, setTxns]) + if (postId) return listenForTipTxnsOnPost(postId, setTxns) + }, [contractId, groupId, postId, setTxns]) return useMemo(() => { const byComment = groupBy(txns, 'data.commentId') diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index aab4de85..e00d7397 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -7,12 +7,22 @@ import { query, setDoc, where, + DocumentData, + DocumentReference, } from 'firebase/firestore' import { getValues, listenForValues } from './utils' import { db } from './init' import { User } from 'common/user' -import { Comment, ContractComment, GroupComment } from 'common/comment' +import { + Comment, + ContractComment, + GroupComment, + OnContract, + OnGroup, + OnPost, + PostComment, +} from 'common/comment' import { removeUndefinedProps } from 'common/util/object' import { track } from '@amplitude/analytics-browser' import { JSONContent } from '@tiptap/react' @@ -24,7 +34,7 @@ export const MAX_COMMENT_LENGTH = 10000 export async function createCommentOnContract( contractId: string, content: JSONContent, - commenter: User, + user: User, betId?: string, answerOutcome?: string, replyToCommentId?: string @@ -32,28 +42,20 @@ export async function createCommentOnContract( const ref = betId ? doc(getCommentsCollection(contractId), betId) : doc(getCommentsCollection(contractId)) - // contract slug and question are set via trigger - const comment = removeUndefinedProps({ - id: ref.id, + const onContract = { commentType: 'contract', contractId, - userId: commenter.id, - content: content, - createdTime: Date.now(), - userName: commenter.name, - userUsername: commenter.username, - userAvatarUrl: commenter.avatarUrl, - betId: betId, - answerOutcome: answerOutcome, - replyToCommentId: replyToCommentId, - }) - track('comment', { + betId, + answerOutcome, + } as OnContract + return await createComment( contractId, - commentId: ref.id, - betId: betId, - replyToCommentId: replyToCommentId, - }) - return await setDoc(ref, comment) + onContract, + content, + user, + ref, + replyToCommentId + ) } export async function createCommentOnGroup( groupId: string, @@ -62,10 +64,45 @@ export async function createCommentOnGroup( replyToCommentId?: string ) { const ref = doc(getCommentsOnGroupCollection(groupId)) + const onGroup = { commentType: 'group', groupId: groupId } as OnGroup + return await createComment( + groupId, + onGroup, + content, + user, + ref, + replyToCommentId + ) +} + +export async function createCommentOnPost( + postId: string, + content: JSONContent, + user: User, + replyToCommentId?: string +) { + const ref = doc(getCommentsOnPostCollection(postId)) + const onPost = { postId: postId, commentType: 'post' } as OnPost + return await createComment( + postId, + onPost, + content, + user, + ref, + replyToCommentId + ) +} + +async function createComment( + surfaceId: string, + extraFields: OnContract | OnGroup | OnPost, + content: JSONContent, + user: User, + ref: DocumentReference<DocumentData>, + replyToCommentId?: string +) { const comment = removeUndefinedProps({ id: ref.id, - commentType: 'group', - groupId, userId: user.id, content: content, createdTime: Date.now(), @@ -73,11 +110,13 @@ export async function createCommentOnGroup( userUsername: user.username, userAvatarUrl: user.avatarUrl, replyToCommentId: replyToCommentId, + ...extraFields, }) - track('group message', { + + track(`${extraFields.commentType} message`, { user, commentId: ref.id, - groupId, + surfaceId, replyToCommentId: replyToCommentId, }) return await setDoc(ref, comment) @@ -91,6 +130,10 @@ function getCommentsOnGroupCollection(groupId: string) { return collection(db, 'groups', groupId, 'comments') } +function getCommentsOnPostCollection(postId: string) { + return collection(db, 'posts', postId, 'comments') +} + export async function listAllComments(contractId: string) { return await getValues<Comment>( query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')) @@ -103,6 +146,12 @@ export async function listAllCommentsOnGroup(groupId: string) { ) } +export async function listAllCommentsOnPost(postId: string) { + return await getValues<PostComment>( + query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')) + ) +} + export function listenForCommentsOnContract( contractId: string, setComments: (comments: ContractComment[]) => void @@ -126,6 +175,16 @@ export function listenForCommentsOnGroup( ) } +export function listenForCommentsOnPost( + postId: string, + setComments: (comments: PostComment[]) => void +) { + return listenForValues<PostComment>( + query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')), + setComments + ) +} + const DAY_IN_MS = 24 * 60 * 60 * 1000 // Define "recent" as "<3 days ago" for now diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 88ab1352..141217e4 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) => where('data.groupId', '==', groupId) ) +const getTipsOnPostQuery = (postId: string) => + query( + txns, + where('category', '==', 'TIP'), + where('data.postId', '==', postId) + ) + export function listenForTipTxns( contractId: string, setTxns: (txns: TipTxn[]) => void @@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup( return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns) } +export function listenForTipTxnsOnPost( + postId: string, + setTxns: (txns: TipTxn[]) => void +) { + return listenForValues<TipTxn>(getTipsOnPostQuery(postId), setTxns) +} + // Find all manalink Txns that are from or to this user export function useManalinkTxns(userId: string) { const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([]) diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 737e025f..11c37f22 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -16,17 +16,25 @@ import { Col } from 'web/components/layout/col' import { ENV_CONFIG } from 'common/envs/constants' import Custom404 from 'web/pages/404' import { UserLink } from 'web/components/user-link' +import { listAllCommentsOnPost } from 'web/lib/firebase/comments' +import { PostComment } from 'common/comment' +import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' +import { groupBy, sortBy } from 'lodash' +import { PostCommentInput, PostCommentThread } from 'web/posts/post-comments' +import { useCommentsOnPost } from 'web/hooks/use-comments' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params const post = await getPostBySlug(slugs[0]) const creator = post ? await getUser(post.creatorId) : null + const comments = post && (await listAllCommentsOnPost(post.id)) return { props: { post: post, creator: creator, + comments: comments, }, revalidate: 60, // regenerate after a minute @@ -37,28 +45,36 @@ export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } -export default function PostPage(props: { post: Post; creator: User }) { +export default function PostPage(props: { + post: Post + creator: User + comments: PostComment[] +}) { const [isShareOpen, setShareOpen] = useState(false) + const { post, creator } = props - if (props.post == null) { + const tips = useTipTxns({ postId: post.id }) + const shareUrl = `https://${ENV_CONFIG.domain}${postPath(post.slug)}` + const updatedComments = useCommentsOnPost(post.id) + const comments = updatedComments ?? props.comments + + if (post == null) { return <Custom404 /> } - const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}` - return ( <Page> <div className="mx-auto w-full max-w-3xl "> <Spacer h={1} /> - <Title className="!mt-0" text={props.post.title} /> + <Title className="!mt-0" text={post.title} /> <Row> <Col className="flex-1"> <div className={'inline-flex'}> <div className="mr-1 text-gray-500">Created by</div> <UserLink className="text-neutral" - name={props.creator.name} - username={props.creator.username} + name={creator.name} + username={creator.username} /> </div> </Col> @@ -88,10 +104,55 @@ export default function PostPage(props: { post: Post; creator: User }) { <Spacer h={2} /> <div className="rounded-lg bg-white px-6 py-4 sm:py-0"> <div className="form-control w-full py-2"> - <Content content={props.post.content} /> + <Content content={post.content} /> </div> </div> + + <Spacer h={2} /> + <div className="rounded-lg bg-white px-6 py-4 sm:py-0"> + <PostCommentsActivity + post={post} + comments={comments} + tips={tips} + user={creator} + /> + </div> </div> </Page> ) } + +export function PostCommentsActivity(props: { + post: Post + comments: PostComment[] + tips: CommentTipMap + user: User | null | undefined +}) { + const { post, comments, user, tips } = props + const commentsByUserId = groupBy(comments, (c) => c.userId) + const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') + const topLevelComments = sortBy( + commentsByParentId['_'] ?? [], + (c) => -c.createdTime + ) + + return ( + <> + <PostCommentInput post={post} /> + {topLevelComments.map((parent) => ( + <PostCommentThread + key={parent.id} + user={user} + post={post} + parentComment={parent} + threadComments={sortBy( + commentsByParentId[parent.id] ?? [], + (c) => c.createdTime + )} + tips={tips} + commentsByUserId={commentsByUserId} + /> + ))} + </> + ) +} diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx new file mode 100644 index 00000000..d129f807 --- /dev/null +++ b/web/posts/post-comments.tsx @@ -0,0 +1,172 @@ +import { track } from '@amplitude/analytics-browser' +import { Editor } from '@tiptap/core' +import clsx from 'clsx' +import { PostComment } from 'common/comment' +import { Post } from 'common/post' +import { User } from 'common/user' +import { Dictionary } from 'lodash' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { Avatar } from 'web/components/avatar' +import { CommentInput } from 'web/components/comment-input' +import { Content } from 'web/components/editor' +import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Tipper } from 'web/components/tipper' +import { UserLink } from 'web/components/user-link' +import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' +import { useUser } from 'web/hooks/use-user' +import { createCommentOnPost } from 'web/lib/firebase/comments' +import { firebaseLogin } from 'web/lib/firebase/users' + +export function PostCommentThread(props: { + user: User | null | undefined + post: Post + threadComments: PostComment[] + tips: CommentTipMap + parentComment: PostComment + commentsByUserId: Dictionary<PostComment[]> +}) { + const { post, threadComments, tips, parentComment } = props + const [showReply, setShowReply] = useState(false) + const [replyTo, setReplyTo] = useState<{ id: string; username: string }>() + + function scrollAndOpenReplyInput(comment: PostComment) { + setReplyTo({ id: comment.userId, username: comment.userUsername }) + setShowReply(true) + } + + return ( + <Col className="relative w-full items-stretch gap-3 pb-4"> + <span + className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" + aria-hidden="true" + /> + {[parentComment].concat(threadComments).map((comment, commentIdx) => ( + <PostComment + key={comment.id} + indent={commentIdx != 0} + post={post} + comment={comment} + tips={tips[comment.id]} + onReplyClick={scrollAndOpenReplyInput} + /> + ))} + {showReply && ( + <Col className="-pb-2 relative ml-6"> + <span + className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" + aria-hidden="true" + /> + <PostCommentInput + post={post} + parentCommentId={parentComment.id} + replyToUser={replyTo} + onSubmitComment={() => setShowReply(false)} + /> + </Col> + )} + </Col> + ) +} + +export function PostCommentInput(props: { + post: Post + parentCommentId?: string + replyToUser?: { id: string; username: string } + onSubmitComment?: () => void +}) { + const user = useUser() + + const { post, parentCommentId, replyToUser } = props + + async function onSubmitComment(editor: Editor) { + if (!user) { + track('sign in to comment') + return await firebaseLogin() + } + await createCommentOnPost(post.id, editor.getJSON(), user, parentCommentId) + props.onSubmitComment?.() + } + + return ( + <CommentInput + replyToUser={replyToUser} + parentCommentId={parentCommentId} + onSubmitComment={onSubmitComment} + /> + ) +} + +export function PostComment(props: { + post: Post + comment: PostComment + tips: CommentTips + indent?: boolean + probAtCreatedTime?: number + onReplyClick?: (comment: PostComment) => void +}) { + const { post, comment, tips, indent, onReplyClick } = props + const { text, content, userUsername, userName, userAvatarUrl, createdTime } = + comment + + const [highlighted, setHighlighted] = useState(false) + const router = useRouter() + useEffect(() => { + if (router.asPath.endsWith(`#${comment.id}`)) { + setHighlighted(true) + } + }, [comment.id, router.asPath]) + + return ( + <Row + id={comment.id} + className={clsx( + 'relative', + indent ? 'ml-6' : '', + highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : '' + )} + > + {/*draw a gray line from the comment to the left:*/} + {indent ? ( + <span + className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" + aria-hidden="true" + /> + ) : null} + <Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} /> + <div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3"> + <div className="mt-0.5 text-sm text-gray-500"> + <UserLink + className="text-gray-500" + username={userUsername} + name={userName} + />{' '} + <CopyLinkDateTimeComponent + prefix={comment.userName} + slug={post.slug} + createdTime={createdTime} + elementId={comment.id} + /> + </div> + <Content + className="mt-2 text-[15px] text-gray-700" + content={content || text} + smallImage + /> + <Row className="mt-2 items-center gap-6 text-xs text-gray-500"> + <Tipper comment={comment} tips={tips ?? {}} /> + {onReplyClick && ( + <button + className="font-bold hover:underline" + onClick={() => onReplyClick(comment)} + > + Reply + </button> + )} + </Row> + </div> + </Row> + ) +} From e6c6f64077746b86390df3bb519388b232a253de Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 7 Sep 2022 21:16:58 -0500 Subject: [PATCH 50/62] fix mobile nav for trades tab --- web/components/nav/nav-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 5a81f566..242d6ff5 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -64,7 +64,7 @@ export function BottomNavBar() { item={{ name: formatMoney(user.balance), trackingEventName: 'profile', - href: `/${user.username}?tab=bets`, + href: `/${user.username}?tab=trades`, icon: () => ( <Avatar className="mx-auto my-1" From 4439447a6dbe5229b8e8a5426c32a9fffd876398 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 7 Sep 2022 21:33:33 -0500 Subject: [PATCH 51/62] Persist group markets and scroll position on back (Marshall's machinery) --- web/pages/group/[...slugs]/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4df21faf..768e2f82 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -227,6 +227,7 @@ export default function GroupPage(props: { defaultSort={'newest'} defaultFilter={suggestedFilter} additionalFilter={{ groupSlug: group.slug }} + persistPrefix={`group-${group.slug}`} /> ) From 35de4c485a6709c506592ac5a34d0d6b808f5845 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 7 Sep 2022 21:39:14 -0600 Subject: [PATCH 52/62] Just submit, allow xs on pills --- web/components/answers/answer-bet-panel.tsx | 2 +- web/components/buttons/pill-button.tsx | 10 +++++----- web/components/challenges/accept-challenge-button.tsx | 3 ++- web/components/numeric-bet-panel.tsx | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index dbf7ff11..6e54b3b8 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -206,7 +206,7 @@ export function AnswerBetPanel(props: { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Submit trade'} + {isSubmitting ? 'Submitting...' : 'Submit'} </button> ) : ( <BetSignUpPrompt /> diff --git a/web/components/buttons/pill-button.tsx b/web/components/buttons/pill-button.tsx index 8e47c94e..949aa428 100644 --- a/web/components/buttons/pill-button.tsx +++ b/web/components/buttons/pill-button.tsx @@ -5,19 +5,19 @@ export function PillButton(props: { selected: boolean onSelect: () => void color?: string - big?: boolean + xs?: boolean children: ReactNode }) { - const { children, selected, onSelect, color, big } = props + const { children, selected, onSelect, color, xs } = props return ( <button className={clsx( - 'cursor-pointer select-none whitespace-nowrap rounded-full', + 'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 sm:text-sm', + xs ? 'text-xs' : 'text-sm', selected ? ['text-white', color ?? 'bg-greyscale-6'] - : 'bg-greyscale-2 hover:bg-greyscale-3', - big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm' + : 'bg-greyscale-2 hover:bg-greyscale-3' )} onClick={onSelect} > diff --git a/web/components/challenges/accept-challenge-button.tsx b/web/components/challenges/accept-challenge-button.tsx index fcf64b30..b25b870c 100644 --- a/web/components/challenges/accept-challenge-button.tsx +++ b/web/components/challenges/accept-challenge-button.tsx @@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: { setErrorText('') }, [open]) - if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" /> + if (!user) + return <BetSignUpPrompt label="Sign up to accept" className="mt-4" /> const iAcceptChallenge = () => { setLoading(true) diff --git a/web/components/numeric-bet-panel.tsx b/web/components/numeric-bet-panel.tsx index e747b78d..b7d6c268 100644 --- a/web/components/numeric-bet-panel.tsx +++ b/web/components/numeric-bet-panel.tsx @@ -203,7 +203,7 @@ function NumericBuyPanel(props: { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Submit bet'} + {isSubmitting ? 'Submitting...' : 'Submit'} </button> )} From bcee49878bc24a0f9363b69e35c49a1c0f1bc3f3 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 7 Sep 2022 21:39:21 -0600 Subject: [PATCH 53/62] Weigh in --- web/components/bet-button.tsx | 7 +++++-- web/components/bet-panel.tsx | 12 ++++++++---- web/components/sign-up-prompt.tsx | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 77b17678..8cfd2b4c 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -35,10 +35,13 @@ export default function BetButton(props: { {user ? ( <Button size="lg" - className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} + className={clsx( + 'my-auto inline-flex min-w-[75px] whitespace-nowrap ', + btnClassName + )} onClick={() => setOpen(true)} > - Trade + Weigh in </Button> ) : ( <BetSignUpPrompt /> diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 1f2b9bd3..468082b6 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -297,7 +297,7 @@ function BuyPanel(props: { return ( <Col className={hidden ? 'hidden' : ''}> <div className="my-3 text-left text-sm text-gray-500"> - {isPseudoNumeric ? 'Direction' : 'Outcome'} + {isPseudoNumeric ? 'Direction' : 'Buy'} </div> <YesNoSelector className="mb-4" @@ -379,7 +379,7 @@ function BuyPanel(props: { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Submit trade'} + {isSubmitting ? 'Submitting...' : 'Submit'} </button> )} @@ -750,15 +750,18 @@ function QuickOrLimitBet(props: { return ( <Row className="align-center mb-4 justify-between"> - <div className="text-4xl">Trade</div> + <div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0 sm:text-4xl"> + Weigh in + </div> {!hideToggle && ( - <Row className="mt-1 items-center gap-2"> + <Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2"> <PillButton selected={!isLimitOrder} onSelect={() => { setIsLimitOrder(false) track('select quick order') }} + xs={true} > Quick </PillButton> @@ -768,6 +771,7 @@ function QuickOrLimitBet(props: { setIsLimitOrder(true) track('select limit order') }} + xs={true} > Limit </PillButton> diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 239fb4ad..bd88152a 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: { size={size} color="gradient" > - {label ?? 'Sign up to trade!'} + {label ?? 'Sign up to weigh in!'} </Button> ) : null } From 45a965476efb5eeb1ee7a1c257c602d2f3755b1d Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 7 Sep 2022 20:58:51 -0700 Subject: [PATCH 54/62] Use next/future/image component to optimize avatar images --- web/components/avatar.tsx | 6 +++++- web/next.config.js | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 55cf3169..44c37128 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -2,6 +2,7 @@ import Router from 'next/router' import clsx from 'clsx' import { MouseEvent, useEffect, useState } from 'react' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' +import Image from 'next/future/image' export function Avatar(props: { username?: string @@ -14,6 +15,7 @@ export function Avatar(props: { const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 + const sizeInPx = s * 4 const onClick = noLink && username @@ -26,7 +28,9 @@ export function Avatar(props: { // there can be no avatar URL or username in the feed, we show a "submit comment" // item with a fake grey user circle guy even if you aren't signed in return avatarUrl ? ( - <img + <Image + width={sizeInPx} + height={sizeInPx} className={clsx( 'flex-shrink-0 rounded-full bg-white object-cover', `w-${s} h-${s}`, diff --git a/web/next.config.js b/web/next.config.js index e99a3081..5438d206 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -9,6 +9,9 @@ module.exports = { reactStrictMode: true, optimizeFonts: false, experimental: { + images: { + allowFutureImage: true, + }, scrollRestoration: true, externalDir: true, modularizeImports: { @@ -25,7 +28,12 @@ module.exports = { }, }, images: { - domains: ['lh3.googleusercontent.com', 'i.imgur.com'], + domains: [ + 'manifold.markets', + 'lh3.googleusercontent.com', + 'i.imgur.com', + 'firebasestorage.googleapis.com', + ], }, async redirects() { return [ From 004671f032b36903531ce70f2b0a24ad8ca202ff Mon Sep 17 00:00:00 2001 From: ingawei <46611122+ingawei@users.noreply.github.com> Date: Wed, 7 Sep 2022 23:51:52 -0500 Subject: [PATCH 55/62] Inga/challenge icon (#857) * changed challenge icon to custom icon * fixed tip button alignment --- .../contract/extra-contract-actions-row.tsx | 5 ++--- .../contract/like-market-button.tsx | 6 +++--- web/lib/icons/challenge-icon.tsx | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 web/lib/icons/challenge-icon.tsx diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index d4918783..5d5ee4d8 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -14,6 +14,7 @@ import { Col } from 'web/components/layout/col' import { withTracking } from 'web/lib/service/analytics' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { CHALLENGES_ENABLED } from 'common/challenge' +import ChallengeIcon from 'web/lib/icons/challenge-icon' export function ExtraContractActionsRow(props: { contract: Contract }) { const { contract } = props @@ -61,9 +62,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { )} > <Col className="items-center sm:flex-row"> - <span className="h-[24px] w-5 sm:mr-2" aria-hidden="true"> - ⚔️ - </span> + <ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" /> <span>Challenge</span> </Col> <CreateChallengeModal diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index 6ea6996d..e35e3e7e 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -39,14 +39,14 @@ export function LikeMarketButton(props: { return ( <Button size={'lg'} - className={'mb-1'} + className={'max-w-xs self-center'} color={'gray-white'} onClick={onLike} > - <Col className={'items-center sm:flex-row sm:gap-x-2'}> + <Col className={'items-center sm:flex-row'}> <HeartIcon className={clsx( - 'h-6 w-6', + 'h-[24px] w-5 sm:mr-2', user && (userLikedContractIds?.includes(contract.id) || (!likes && contract.likedByUserIds?.includes(user.id))) diff --git a/web/lib/icons/challenge-icon.tsx b/web/lib/icons/challenge-icon.tsx new file mode 100644 index 00000000..6e5f3561 --- /dev/null +++ b/web/lib/icons/challenge-icon.tsx @@ -0,0 +1,19 @@ +export default function ChallengeIcon(props: { className?: string }) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="currentColor" + className={props.className} + viewBox="0 0 24 24" + > + <g> + <polygon points="18.63 15.11 15.37 18.49 3.39 6.44 1.82 1.05 7.02 2.68 18.63 15.11" /> + <polygon points="21.16 13.73 22.26 14.87 19.51 17.72 23 21.35 21.41 23 17.91 19.37 15.16 22.23 14.07 21.09 21.16 13.73" /> + </g> + <g> + <polygon points="8.6 18.44 5.34 15.06 16.96 2.63 22.15 1 20.58 6.39 8.6 18.44" /> + <polygon points="9.93 21.07 8.84 22.21 6.09 19.35 2.59 22.98 1 21.33 4.49 17.7 1.74 14.85 2.84 13.71 9.93 21.07" /> + </g> + </svg> + ) +} From edbebb7e67ff8631906973eb4e5173845053c7f0 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Thu, 8 Sep 2022 00:16:48 -0500 Subject: [PATCH 56/62] weighing in and trading "weigh in" for "trade" --- web/components/bet-button.tsx | 4 ++-- web/components/bet-panel.tsx | 2 +- web/components/sign-up-prompt.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 8cfd2b4c..b7be0bc7 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -36,12 +36,12 @@ export default function BetButton(props: { <Button size="lg" className={clsx( - 'my-auto inline-flex min-w-[75px] whitespace-nowrap ', + 'my-auto inline-flex min-w-[75px] whitespace-nowrap', btnClassName )} onClick={() => setOpen(true)} > - Weigh in + Trade </Button> ) : ( <BetSignUpPrompt /> diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 468082b6..b40844f6 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -751,7 +751,7 @@ function QuickOrLimitBet(props: { return ( <Row className="align-center mb-4 justify-between"> <div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0 sm:text-4xl"> - Weigh in + Trade </div> {!hideToggle && ( <Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2"> diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index bd88152a..239fb4ad 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: { size={size} color="gradient" > - {label ?? 'Sign up to weigh in!'} + {label ?? 'Sign up to trade!'} </Button> ) : null } From 54c227cf6c57a68bf0f0f1afeb2e1bdae42fdf36 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 8 Sep 2022 01:36:34 -0500 Subject: [PATCH 57/62] Updates to experimental home (#858) * Line clamp question in prob change table * Tweaks * Expand option for daily movers * Snap scrolling for carousel * Add arrows to section headers * Remove carousel from experimental/home * React querify fetching your groups * Edit home is its own page * Add daily profit and balance * Merge branch 'main' into new-home * Make experimental search by your followed groups/creators * Just submit, allow xs on pills * Weigh in * Use next/future/image component to optimize avatar images * Inga/challenge icon (#857) * changed challenge icon to custom icon * fixed tip button alignment * weighing in and trading "weigh in" for "trade" Co-authored-by: Ian Philips <iansphilips@gmail.com> Co-authored-by: Austin Chen <akrolsmir@gmail.com> Co-authored-by: ingawei <46611122+ingawei@users.noreply.github.com> Co-authored-by: mantikoros <sgrugett@gmail.com> --- common/calculate-metrics.ts | 6 +- web/components/arrange-home.tsx | 3 +- web/components/carousel.tsx | 2 +- web/components/contract-search.tsx | 32 ++- web/components/contract/prob-change-table.tsx | 86 ++++--- web/components/double-carousel.tsx | 3 +- web/hooks/use-group.ts | 13 +- web/lib/firebase/groups.ts | 11 +- web/pages/experimental/home/edit.tsx | 60 +++++ web/pages/experimental/home/index.tsx | 239 ++++++++---------- web/pages/tournaments/index.tsx | 2 +- 11 files changed, 265 insertions(+), 192 deletions(-) create mode 100644 web/pages/experimental/home/edit.tsx diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 3aad1a9c..b27ac977 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -116,12 +116,12 @@ const calculateProfitForPeriod = ( return currentProfit } - const startingProfit = calculateTotalProfit(startingPortfolio) + const startingProfit = calculatePortfolioProfit(startingPortfolio) return currentProfit - startingProfit } -const calculateTotalProfit = (portfolio: PortfolioMetrics) => { +export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits } @@ -129,7 +129,7 @@ export const calculateNewProfit = ( portfolioHistory: PortfolioMetrics[], newPortfolio: PortfolioMetrics ) => { - const allTimeProfit = calculateTotalProfit(newPortfolio) + const allTimeProfit = calculatePortfolioProfit(newPortfolio) const descendingPortfolio = sortBy( portfolioHistory, (p) => p.timestamp diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 2f49d144..ae02e3ea 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -12,7 +12,7 @@ import { User } from 'common/user' import { Group } from 'common/group' export function ArrangeHome(props: { - user: User | null + user: User | null | undefined homeSections: { visible: string[]; hidden: string[] } setHomeSections: (homeSections: { visible: string[] @@ -30,7 +30,6 @@ export function ArrangeHome(props: { return ( <DragDropContext onDragEnd={(e) => { - console.log('drag end', e) const { destination, source, draggableId } = e if (!destination) return diff --git a/web/components/carousel.tsx b/web/components/carousel.tsx index 79baa451..030c256c 100644 --- a/web/components/carousel.tsx +++ b/web/components/carousel.tsx @@ -38,7 +38,7 @@ export function Carousel(props: { return ( <div className={clsx('relative', className)}> <Row - className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth" + className="scrollbar-hide w-full snap-x gap-4 overflow-x-auto scroll-smooth" ref={ref} onScroll={onScroll} > diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8e3b18e0..e4b7f9cf 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -69,6 +69,7 @@ type AdditionalFilter = { excludeContractIds?: string[] groupSlug?: string yourBets?: boolean + followed?: boolean } export function ContractSearch(props: { @@ -88,6 +89,7 @@ export function ContractSearch(props: { useQueryUrlParam?: boolean isWholePage?: boolean noControls?: boolean + maxResults?: number renderContracts?: ( contracts: Contract[] | undefined, loadMore: () => void @@ -107,6 +109,7 @@ export function ContractSearch(props: { useQueryUrlParam, isWholePage, noControls, + maxResults, renderContracts, } = props @@ -189,7 +192,8 @@ export function ContractSearch(props: { const contracts = state.pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) - const renderedContracts = state.pages.length === 0 ? undefined : contracts + const renderedContracts = + state.pages.length === 0 ? undefined : contracts.slice(0, maxResults) if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return <ContractSearchFirestore additionalFilter={additionalFilter} /> @@ -292,6 +296,19 @@ function ContractSearchControls(props: { const pillGroups: { name: string; slug: string }[] = memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS + const personalFilters = user + ? [ + // Show contracts in groups that the user is a member of. + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) + // Or, show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []), + + // Subtract contracts you bet on, to show new ones. + `uniqueBettorIds:-${user.id}`, + ] + : [] + const additionalFilters = [ additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` @@ -304,6 +321,7 @@ function ContractSearchControls(props: { ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` : '', + ...(additionalFilter?.followed ? personalFilters : []), ] const facetFilters = query ? additionalFilters @@ -320,17 +338,7 @@ function ContractSearchControls(props: { state.pillFilter !== 'your-bets' ? `groupLinks.slug:${state.pillFilter}` : '', - state.pillFilter === 'personal' - ? // Show contracts in groups that the user is a member of - memberGroupSlugs - .map((slug) => `groupLinks.slug:${slug}`) - // Show contracts created by users the user follows - .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) - : '', - // Subtract contracts you bet on from For you. - state.pillFilter === 'personal' && user - ? `uniqueBettorIds:-${user.id}` - : '', + ...(state.pillFilter === 'personal' ? personalFilters : []), state.pillFilter === 'your-bets' && user ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index f6e5d892..f973d260 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -3,52 +3,74 @@ import { contractPath } from 'web/lib/firebase/contracts' import { CPMMContract } from 'common/contract' import { formatPercent } from 'common/util/format' import { useProbChanges } from 'web/hooks/use-prob-changes' -import { SiteLink } from '../site-link' +import { linkClass, SiteLink } from '../site-link' import { Col } from '../layout/col' import { Row } from '../layout/row' +import { useState } from 'react' export function ProbChangeTable(props: { userId: string | undefined }) { const { userId } = props const changes = useProbChanges(userId ?? '') + const [expanded, setExpanded] = useState(false) if (!changes) { return null } - const { positiveChanges, negativeChanges } = changes + const count = expanded ? 16 : 4 - const count = 3 + const { positiveChanges, negativeChanges } = changes + const filteredPositiveChanges = positiveChanges.slice(0, count / 2) + const filteredNegativeChanges = negativeChanges.slice(0, count / 2) + const filteredChanges = [ + ...filteredPositiveChanges, + ...filteredNegativeChanges, + ] return ( - <Row className="w-full flex-wrap divide-x-2 rounded bg-white shadow-md"> - <Col className="min-w-[300px] flex-1 divide-y"> - {positiveChanges.slice(0, count).map((contract) => ( - <Row className="hover:bg-gray-100"> - <ProbChange className="p-4 text-right" contract={contract} /> - <SiteLink - className="p-4 font-semibold text-indigo-700" - href={contractPath(contract)} - > - {contract.question} - </SiteLink> - </Row> - ))} + <Col> + <Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0"> + <Col className="flex-1 divide-y"> + {filteredChanges.slice(0, count / 2).map((contract) => ( + <Row className="items-center hover:bg-gray-100"> + <ProbChange + className="p-4 text-right text-xl" + contract={contract} + /> + <SiteLink + className="p-4 pl-2 font-semibold text-indigo-700" + href={contractPath(contract)} + > + <span className="line-clamp-2">{contract.question}</span> + </SiteLink> + </Row> + ))} + </Col> + <Col className="flex-1 divide-y"> + {filteredChanges.slice(count / 2).map((contract) => ( + <Row className="items-center hover:bg-gray-100"> + <ProbChange + className="p-4 text-right text-xl" + contract={contract} + /> + <SiteLink + className="p-4 pl-2 font-semibold text-indigo-700" + href={contractPath(contract)} + > + <span className="line-clamp-2">{contract.question}</span> + </SiteLink> + </Row> + ))} + </Col> </Col> - <Col className="justify-content-stretch min-w-[300px] flex-1 divide-y"> - {negativeChanges.slice(0, count).map((contract) => ( - <Row className="hover:bg-gray-100"> - <ProbChange className="p-4 text-right" contract={contract} /> - <SiteLink - className="p-4 font-semibold text-indigo-700" - href={contractPath(contract)} - > - {contract.question} - </SiteLink> - </Row> - ))} - </Col> - </Row> + <div + className={clsx(linkClass, 'cursor-pointer self-end')} + onClick={() => setExpanded(!expanded)} + > + {expanded ? 'Show less' : 'Show more'} + </div> + </Col> ) } @@ -63,9 +85,9 @@ export function ProbChange(props: { const color = change > 0 - ? 'text-green-600' + ? 'text-green-500' : change < 0 - ? 'text-red-600' + ? 'text-red-500' : 'text-gray-600' const str = diff --git a/web/components/double-carousel.tsx b/web/components/double-carousel.tsx index da01eb5a..12538cf7 100644 --- a/web/components/double-carousel.tsx +++ b/web/components/double-carousel.tsx @@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col' export function DoubleCarousel(props: { contracts: Contract[] - seeMoreUrl?: string showTime?: ShowTime loadMore?: () => void }) { @@ -19,7 +18,7 @@ export function DoubleCarousel(props: { ? range(0, Math.floor(contracts.length / 2)).map((col) => { const i = col * 2 return ( - <Col key={contracts[i].id}> + <Col className="snap-start scroll-m-4" key={contracts[i].id}> <ContractCard contract={contracts[i]} className="mb-2 w-96 shrink-0" diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 001c29c3..781da9cb 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -2,13 +2,13 @@ import { useEffect, useState } from 'react' import { Group } from 'common/group' import { User } from 'common/user' import { + getMemberGroups, GroupMemberDoc, groupMembers, listenForGroup, listenForGroupContractDocs, listenForGroups, listenForMemberGroupIds, - listenForMemberGroups, listenForOpenGroups, listGroups, } from 'web/lib/firebase/groups' @@ -17,6 +17,7 @@ import { filterDefined } from 'common/util/array' import { Contract } from 'common/contract' import { uniq } from 'lodash' import { listenForValues } from 'web/lib/firebase/utils' +import { useQuery } from 'react-query' export const useGroup = (groupId: string | undefined) => { const [group, setGroup] = useState<Group | null | undefined>() @@ -49,12 +50,10 @@ export const useOpenGroups = () => { } export const useMemberGroups = (userId: string | null | undefined) => { - const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() - useEffect(() => { - if (userId) - return listenForMemberGroups(userId, (groups) => setMemberGroups(groups)) - }, [userId]) - return memberGroups + const result = useQuery(['member-groups', userId ?? ''], () => + getMemberGroups(userId ?? '') + ) + return result.data } // Note: We cache member group ids in localstorage to speed up the initial load diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 0366fe0b..7a372d9a 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -32,7 +32,7 @@ export const groupMembers = (groupId: string) => export const groupContracts = (groupId: string) => collection(groups, groupId, 'groupContracts') const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true)) -const memberGroupsQuery = (userId: string) => +export const memberGroupsQuery = (userId: string) => query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId)) export function groupPath( @@ -113,6 +113,15 @@ export function listenForGroup( return listenForValue(doc(groups, groupId), setGroup) } +export async function getMemberGroups(userId: string) { + const snapshot = await getDocs(memberGroupsQuery(userId)) + const groupIds = filterDefined( + snapshot.docs.map((doc) => doc.ref.parent.parent?.id) + ) + const groups = await Promise.all(groupIds.map(getGroup)) + return filterDefined(groups) +} + export function listenForMemberGroupIds( userId: string, setGroupIds: (groupIds: string[]) => void diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx new file mode 100644 index 00000000..2cba3f19 --- /dev/null +++ b/web/pages/experimental/home/edit.tsx @@ -0,0 +1,60 @@ +import clsx from 'clsx' +import { useState } from 'react' +import { ArrangeHome } from 'web/components/arrange-home' +import { Button } from 'web/components/button' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { SiteLink } from 'web/components/site-link' +import { Title } from 'web/components/title' +import { useTracking } from 'web/hooks/use-tracking' +import { useUser } from 'web/hooks/use-user' +import { updateUser } from 'web/lib/firebase/users' + +export default function Home() { + const user = useUser() + + useTracking('edit home') + + const [homeSections, setHomeSections] = useState( + user?.homeSections ?? { visible: [], hidden: [] } + ) + + const updateHomeSections = (newHomeSections: { + visible: string[] + hidden: string[] + }) => { + if (!user) return + updateUser(user.id, { homeSections: newHomeSections }) + setHomeSections(newHomeSections) + } + + return ( + <Page> + <Col className="pm:mx-10 gap-4 px-4 pb-12"> + <Row className={'w-full items-center justify-between'}> + <Title text="Edit your home page" /> + <DoneButton /> + </Row> + + <ArrangeHome + user={user} + homeSections={homeSections} + setHomeSections={updateHomeSections} + /> + </Col> + </Page> + ) +} + +function DoneButton(props: { className?: string }) { + const { className } = props + + return ( + <SiteLink href="/experimental/home"> + <Button size="lg" color="blue" className={clsx(className, 'flex')}> + Done + </Button> + </SiteLink> + ) +} diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index fb0b488d..90b4f888 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -1,40 +1,36 @@ import React, { useState } from 'react' import Router from 'next/router' -import { PencilIcon, PlusSmIcon } from '@heroicons/react/solid' +import { + PencilIcon, + PlusSmIcon, + ArrowSmRightIcon, +} from '@heroicons/react/solid' +import clsx from 'clsx' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { ContractSearch, SORTS } from 'web/components/contract-search' import { User } from 'common/user' -import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' -import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { GetServerSideProps } from 'next' import { Sort } from 'web/components/contract-search' import { Group } from 'common/group' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { GroupLinkItem } from '../../groups' import { SiteLink } from 'web/components/site-link' import { useUser } from 'web/hooks/use-user' import { useMemberGroups } from 'web/hooks/use-group' -import { DoubleCarousel } from '../../../components/double-carousel' -import clsx from 'clsx' import { Button } from 'web/components/button' -import { ArrangeHome, getHomeItems } from '../../../components/arrange-home' +import { getHomeItems } from '../../../components/arrange-home' import { Title } from 'web/components/title' import { Row } from 'web/components/layout/row' import { ProbChangeTable } from 'web/components/contract/prob-change-table' +import { groupPath } from 'web/lib/firebase/groups' +import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' +import { calculatePortfolioProfit } from 'common/calculate-metrics' +import { formatMoney } from 'common/util/format' -export const getServerSideProps: GetServerSideProps = async (ctx) => { - const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.uid) : null - return { props: { auth } } -} - -const Home = (props: { auth: { user: User } | null }) => { - const user = useUser() ?? props.auth?.user ?? null +const Home = () => { + const user = useUser() useTracking('view home') @@ -42,76 +38,54 @@ const Home = (props: { auth: { user: User } | null }) => { const groups = useMemberGroups(user?.id) ?? [] - const [homeSections, setHomeSections] = useState( + const [homeSections] = useState( user?.homeSections ?? { visible: [], hidden: [] } ) const { visibleItems } = getHomeItems(groups, homeSections) - const updateHomeSections = (newHomeSections: { - visible: string[] - hidden: string[] - }) => { - if (!user) return - updateUser(user.id, { homeSections: newHomeSections }) - setHomeSections(newHomeSections) - } - - const [isEditing, setIsEditing] = useState(false) - return ( <Page> - <Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[125%]"> + <Col className="pm:mx-10 gap-4 px-4 pb-12"> <Row className={'w-full items-center justify-between'}> - <Title text={isEditing ? 'Edit your home page' : 'Home'} /> + <Title className="!mb-0" text="Home" /> - <EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} /> + <EditButton /> </Row> - {isEditing ? ( - <> - <ArrangeHome - user={user} - homeSections={homeSections} - setHomeSections={updateHomeSections} - /> - </> - ) : ( - <> - <div className="text-xl text-gray-800">Daily movers</div> - <ProbChangeTable userId={user?.id} /> + <DailyProfitAndBalance userId={user?.id} /> - {visibleItems.map((item) => { - const { id } = item - if (id === 'your-bets') { - return ( - <SearchSection - key={id} - label={'Your trades'} - sort={'prob-change-day'} - user={user} - yourBets - /> - ) - } - const sort = SORTS.find((sort) => sort.value === id) - if (sort) - return ( - <SearchSection - key={id} - label={sort.label} - sort={sort.value} - user={user} - /> - ) + <div className="text-xl text-gray-800">Daily movers</div> + <ProbChangeTable userId={user?.id} /> - const group = groups.find((g) => g.id === id) - if (group) - return <GroupSection key={id} group={group} user={user} /> + {visibleItems.map((item) => { + const { id } = item + if (id === 'your-bets') { + return ( + <SearchSection + key={id} + label={'Your trades'} + sort={'newest'} + user={user} + yourBets + /> + ) + } + const sort = SORTS.find((sort) => sort.value === id) + if (sort) + return ( + <SearchSection + key={id} + label={sort.label} + sort={sort.value} + user={user} + /> + ) - return null - })} - </> - )} + const group = groups.find((g) => g.id === id) + if (group) return <GroupSection key={id} group={group} user={user} /> + + return null + })} </Col> <button type="button" @@ -129,7 +103,7 @@ const Home = (props: { auth: { user: User } | null }) => { function SearchSection(props: { label: string - user: User | null + user: User | null | undefined sort: Sort yourBets?: boolean }) { @@ -139,88 +113,91 @@ function SearchSection(props: { return ( <Col> <SiteLink className="mb-2 text-xl" href={href}> - {label} + {label}{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> </SiteLink> <ContractSearch user={user} defaultSort={sort} - additionalFilter={yourBets ? { yourBets: true } : undefined} + additionalFilter={yourBets ? { yourBets: true } : { followed: true }} noControls - // persistPrefix={`experimental-home-${sort}`} - renderContracts={(contracts, loadMore) => - contracts ? ( - <DoubleCarousel - contracts={contracts} - seeMoreUrl={href} - showTime={ - sort === 'close-date' || sort === 'resolve-date' - ? sort - : undefined - } - loadMore={loadMore} - /> - ) : ( - <LoadingIndicator /> - ) - } + maxResults={6} + persistPrefix={`experimental-home-${sort}`} /> </Col> ) } -function GroupSection(props: { group: Group; user: User | null }) { +function GroupSection(props: { group: Group; user: User | null | undefined }) { const { group, user } = props return ( <Col> - <GroupLinkItem className="mb-2 text-xl" group={group} /> + <SiteLink className="mb-2 text-xl" href={groupPath(group.slug)}> + {group.name}{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> + </SiteLink> <ContractSearch user={user} defaultSort={'score'} additionalFilter={{ groupSlug: group.slug }} noControls - // persistPrefix={`experimental-home-${group.slug}`} - renderContracts={(contracts, loadMore) => - contracts ? ( - contracts.length == 0 ? ( - <div className="m-2 text-gray-500">No open markets</div> - ) : ( - <DoubleCarousel - contracts={contracts} - seeMoreUrl={`/group/${group.slug}`} - loadMore={loadMore} - /> - ) - ) : ( - <LoadingIndicator /> - ) - } + maxResults={6} + persistPrefix={`experimental-home-${group.slug}`} /> </Col> ) } -function EditDoneButton(props: { - isEditing: boolean - setIsEditing: (isEditing: boolean) => void - className?: string -}) { - const { isEditing, setIsEditing, className } = props +function EditButton(props: { className?: string }) { + const { className } = props return ( - <Button - size="lg" - color={isEditing ? 'blue' : 'gray-white'} - className={clsx(className, 'flex')} - onClick={() => { - setIsEditing(!isEditing) - }} - > - {!isEditing && ( - <PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> - )} - {isEditing ? 'Done' : 'Edit'} - </Button> + <SiteLink href="/experimental/home/edit"> + <Button size="lg" color="gray-white" className={clsx(className, 'flex')}> + <PencilIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />{' '} + Edit + </Button> + </SiteLink> + ) +} + +function DailyProfitAndBalance(props: { + userId: string | null | undefined + className?: string +}) { + const { userId, className } = props + const metrics = usePortfolioHistory(userId ?? '', 'daily') ?? [] + const [first, last] = [metrics[0], metrics[metrics.length - 1]] + + if (first === undefined || last === undefined) return null + + const profit = + calculatePortfolioProfit(last) - calculatePortfolioProfit(first) + + const balanceChange = last.balance - first.balance + + return ( + <div className={clsx(className, 'text-lg')}> + <span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}> + {profit >= 0 ? '+' : '-'} + {formatMoney(profit)} + </span>{' '} + profit and{' '} + <span + className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')} + > + {balanceChange >= 0 ? '+' : '-'} + {formatMoney(balanceChange)} + </span>{' '} + balance today + </div> ) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 4b573e3f..b308ee7f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -237,7 +237,7 @@ const MarketCarousel = (props: { slug: string }) => { key={m.id} contract={m} hideGroupLink - className="mb-2 max-h-[200px] w-96 shrink-0" + className="mb-2 max-h-[200px] w-96 shrink-0 snap-start scroll-m-4 md:snap-align-none" questionClass="line-clamp-3" trackingPostfix=" tournament" /> From bff4eff71926ad8076f0c8c3bab50349a47b4cb5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 8 Sep 2022 01:39:01 -0500 Subject: [PATCH 58/62] Persist user page markets on back (Marshall's machinery) --- web/components/contract/contracts-grid.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 3a09a167..c6356fdd 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -114,6 +114,7 @@ export function CreatorContractsList(props: { additionalFilter={{ creatorId: creator.id, }} + persistPrefix={`user-${creator.id}`} /> ) } From 3932a3dbd480ccce42d60457f4e44b407dba74c4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 8 Sep 2022 07:40:16 -0600 Subject: [PATCH 59/62] I predict this will do better than trade --- web/components/bet-button.tsx | 2 +- web/components/bet-panel.tsx | 4 ++-- web/components/buttons/pill-button.tsx | 4 ++-- web/components/sign-up-prompt.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index b7be0bc7..6875ecb0 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -41,7 +41,7 @@ export default function BetButton(props: { )} onClick={() => setOpen(true)} > - Trade + Predict </Button> ) : ( <BetSignUpPrompt /> diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index b40844f6..f870e179 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -297,7 +297,7 @@ function BuyPanel(props: { return ( <Col className={hidden ? 'hidden' : ''}> <div className="my-3 text-left text-sm text-gray-500"> - {isPseudoNumeric ? 'Direction' : 'Buy'} + {isPseudoNumeric ? 'Direction' : 'Outcome'} </div> <YesNoSelector className="mb-4" @@ -751,7 +751,7 @@ function QuickOrLimitBet(props: { return ( <Row className="align-center mb-4 justify-between"> <div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0 sm:text-4xl"> - Trade + Predict </div> {!hideToggle && ( <Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2"> diff --git a/web/components/buttons/pill-button.tsx b/web/components/buttons/pill-button.tsx index 949aa428..9aa6153f 100644 --- a/web/components/buttons/pill-button.tsx +++ b/web/components/buttons/pill-button.tsx @@ -13,8 +13,8 @@ export function PillButton(props: { return ( <button className={clsx( - 'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 sm:text-sm', - xs ? 'text-xs' : 'text-sm', + 'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 text-sm', + xs ? 'text-xs' : '', selected ? ['text-white', color ?? 'bg-greyscale-6'] : 'bg-greyscale-2 hover:bg-greyscale-3' diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 239fb4ad..5bb1aa9d 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -19,7 +19,7 @@ export function BetSignUpPrompt(props: { size={size} color="gradient" > - {label ?? 'Sign up to trade!'} + {label ?? 'Sign up to predict!'} </Button> ) : null } From 5547b30364773d1f3dd4ee6f2237ae96602b58c4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 8 Sep 2022 09:16:54 -0600 Subject: [PATCH 60/62] Add david to admins --- firestore.rules | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firestore.rules b/firestore.rules index 30bf0ec9..690b36e9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,7 +12,8 @@ service cloud.firestore { 'taowell@gmail.com', 'abc.sinclair@gmail.com', 'manticmarkets@gmail.com', - 'iansphilips@gmail.com' + 'iansphilips@gmail.com', + 'd4vidchee@gmail.com' ] } From d9bb7d1926b03ee8becb6fd0d79485b4364e6240 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Thu, 8 Sep 2022 16:23:19 +0100 Subject: [PATCH 61/62] Edit posts (#859) --- web/pages/post/[...slugs]/index.tsx | 80 +++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 11c37f22..8f99a802 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -1,12 +1,12 @@ import { Page } from 'web/components/page' -import { postPath, getPostBySlug } from 'web/lib/firebase/posts' +import { postPath, getPostBySlug, updatePost } from 'web/lib/firebase/posts' import { Post } from 'common/post' import { Title } from 'web/components/title' import { Spacer } from 'web/components/layout/spacer' -import { Content } from 'web/components/editor' +import { Content, TextEditor, useTextEditor } from 'web/components/editor' import { getUser, User } from 'web/lib/firebase/users' -import { ShareIcon } from '@heroicons/react/solid' +import { PencilIcon, ShareIcon } from '@heroicons/react/solid' import clsx from 'clsx' import { Button } from 'web/components/button' import { useState } from 'react' @@ -22,6 +22,8 @@ import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { groupBy, sortBy } from 'lodash' import { PostCommentInput, PostCommentThread } from 'web/posts/post-comments' import { useCommentsOnPost } from 'web/hooks/use-comments' +import { useUser } from 'web/hooks/use-user' +import { usePost } from 'web/hooks/use-post' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params @@ -51,12 +53,14 @@ export default function PostPage(props: { comments: PostComment[] }) { const [isShareOpen, setShareOpen] = useState(false) - const { post, creator } = props + const { creator } = props + const post = usePost(props.post.id) ?? props.post const tips = useTipTxns({ postId: post.id }) const shareUrl = `https://${ENV_CONFIG.domain}${postPath(post.slug)}` const updatedComments = useCommentsOnPost(post.id) const comments = updatedComments ?? props.comments + const user = useUser() if (post == null) { return <Custom404 /> @@ -104,7 +108,11 @@ export default function PostPage(props: { <Spacer h={2} /> <div className="rounded-lg bg-white px-6 py-4 sm:py-0"> <div className="form-control w-full py-2"> - <Content content={post.content} /> + {user && user.id === post.creatorId ? ( + <RichEditPost post={post} /> + ) : ( + <Content content={post.content} /> + )} </div> </div> @@ -156,3 +164,65 @@ export function PostCommentsActivity(props: { </> ) } + +function RichEditPost(props: { post: Post }) { + const { post } = props + const [editing, setEditing] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const { editor, upload } = useTextEditor({ + defaultValue: post.content, + disabled: isSubmitting, + }) + + async function savePost() { + if (!editor) return + + await updatePost(post, { + content: editor.getJSON(), + }) + } + + return editing ? ( + <> + <TextEditor editor={editor} upload={upload} /> + <Spacer h={2} /> + <Row className="gap-2"> + <Button + onClick={async () => { + setIsSubmitting(true) + await savePost() + setEditing(false) + setIsSubmitting(false) + }} + > + Save + </Button> + <Button color="gray" onClick={() => setEditing(false)}> + Cancel + </Button> + </Row> + </> + ) : ( + <> + <div className="relative"> + <div className="absolute top-0 right-0 z-10 space-x-2"> + <Button + color="gray" + size="xs" + onClick={() => { + setEditing(true) + editor?.commands.focus('end') + }} + > + <PencilIcon className="inline h-4 w-4" /> + Edit + </Button> + </div> + + <Content content={post.content} /> + <Spacer h={2} /> + </div> + </> + ) +} From adf2086141794382e273e3815c215cd8c396480c Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Thu, 8 Sep 2022 16:51:58 +0100 Subject: [PATCH 62/62] Add Fede to admins --- firestore.rules | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firestore.rules b/firestore.rules index 690b36e9..9a72e454 100644 --- a/firestore.rules +++ b/firestore.rules @@ -13,7 +13,8 @@ service cloud.firestore { 'abc.sinclair@gmail.com', 'manticmarkets@gmail.com', 'iansphilips@gmail.com', - 'd4vidchee@gmail.com' + 'd4vidchee@gmail.com', + 'federicoruizcassarino@gmail.com' ] }