From 571d3e71b52a2d565296aa2120252610bf3dcd03 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 06:31:30 -0600 Subject: [PATCH 001/279] Only show most recent streak notif, relative econ imports, pubsub emulator --- functions/package.json | 2 +- functions/src/create-market.ts | 2 +- functions/src/create-user.ts | 2 +- functions/src/on-update-user.ts | 6 +++--- web/pages/notifications.tsx | 14 +++++++++++--- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/functions/package.json b/functions/package.json index d6278c25..63ef9b5d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -14,7 +14,7 @@ "logs": "firebase functions:log", "dev": "nodemon src/serve.ts", "firestore": "firebase emulators:start --only firestore --import=./firestore_export", - "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", + "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index c3780a1f..ae120c43 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -18,7 +18,7 @@ import { randomString } from '../../common/util/random' import { chargeUser, getContract } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' -import { FIXED_ANTE } from 'common/economy' +import { FIXED_ANTE } from '../../common/economy' import { getCpmmInitialLiquidity, getFreeAnswerAnte, diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 54e37d62..216a7eb4 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -27,7 +27,7 @@ import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' -import { SUS_STARTING_BALANCE, STARTING_BALANCE } from 'common/economy' +import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' const bodySchema = z.object({ deviceToken: z.string().optional(), diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index 3dc09a1b..b45809d0 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,10 +5,10 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { createReferralNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' -import { LimitBet } from 'common/bet' +import { LimitBet } from '../../common/bet' import { QuerySnapshot } from 'firebase-admin/firestore' -import { Group } from 'common/group' -import { REFERRAL_AMOUNT } from 'common/economy' +import { Group } from '../../common/group' +import { REFERRAL_AMOUNT } from '../../common/economy' const firestore = admin.firestore() export const onUpdateUser = functions.firestore diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index ccfcf1b3..971201e8 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -271,9 +271,17 @@ function IncomeNotificationGroupItem(props: { } return newNotifications } - - const combinedNotifs = - combineNotificationsByAddingNumericSourceTexts(notifications) + const combinedNotifs = combineNotificationsByAddingNumericSourceTexts( + notifications.filter((n) => n.sourceType !== 'betting_streak_bonus') + ) + // Because the server's reset time will never align with the client's, we may + // erroneously sum 2 betting streak bonuses, therefore just show the most recent + const mostRecentBettingStreakBonus = notifications + .filter((n) => n.sourceType === 'betting_streak_bonus') + .sort((a, b) => a.createdTime - b.createdTime) + .pop() + if (mostRecentBettingStreakBonus) + combinedNotifs.unshift(mostRecentBettingStreakBonus) return (
Date: Mon, 22 Aug 2022 10:20:22 -0500 Subject: [PATCH 002/279] Check loans calc for isFinite --- common/loans.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/loans.ts b/common/loans.ts index 64742b3e..46c491b5 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -101,7 +101,7 @@ const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => { const oldestBet = minBy(bets, (bet) => bet.createdTime) const newLoan = calculateNewLoan(invested, loanAmount) - if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined + if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan @@ -125,7 +125,7 @@ const getFreeResponseContractLoanUpdate = ( const newLoan = calculateNewLoan(bet.amount, loanAmount) const loanTotal = loanAmount + newLoan - if (isNaN(newLoan) || newLoan <= 0) return undefined + if (!isFinite(newLoan) || newLoan <= 0) return undefined return { userId: bet.userId, From 8ea9a79760ea3f24a7357b656c8877540d635300 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 10:31:23 -0500 Subject: [PATCH 003/279] loan emoji --- web/pages/notifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 971201e8..116c367d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -413,10 +413,10 @@ function IncomeNotificationItem(props: { {reasonText} {sourceType === 'loan' ? ( simple ? ( - Loan + 🏦 Loan ) : ( - Loan + 🏦 Loan ) ) : sourceType === 'betting_streak_bonus' ? ( From 40a22b31f30699cc9193d409217e95c66b32fd6c Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 11:52:05 -0500 Subject: [PATCH 004/279] fix sitemap --- web/pages/server-sitemap.xml.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/web/pages/server-sitemap.xml.tsx b/web/pages/server-sitemap.xml.tsx index 246bb9ee..a6673dd4 100644 --- a/web/pages/server-sitemap.xml.tsx +++ b/web/pages/server-sitemap.xml.tsx @@ -1,23 +1,18 @@ -import { sortBy } from 'lodash' import { GetServerSideProps } from 'next' import { getServerSideSitemap, ISitemapField } from 'next-sitemap' -import { DOMAIN } from 'common/envs/constants' -import { LiteMarket } from './api/v0/_types' +import { listAllContracts } from 'web/lib/firebase/contracts' export const getServerSideProps: GetServerSideProps = async (ctx) => { - // Fetching data from https://manifold.markets/api - const response = await fetch(`https://${DOMAIN}/api/v0/markets`) + const contracts = await listAllContracts(1000, undefined) - const liteMarkets = (await response.json()) as LiteMarket[] - const sortedMarkets = sortBy(liteMarkets, (m) => -m.volume24Hours) + const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1)) - const fields = sortedMarkets.map((market) => ({ - // See https://www.sitemaps.org/protocol.html - loc: market.url, + const fields = contracts.map((market) => ({ + loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`, changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily', - priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1, - // TODO: Add `lastmod` aka last modified time + priority: score(market.popularityScore ?? 0), + lastmod: market.lastUpdatedTime, })) as ISitemapField[] return await getServerSideSitemap(ctx, fields) From 7a0d64e72ff310d16124655dba84d8e3ffae409e Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 12:02:08 -0500 Subject: [PATCH 005/279] host sitemap manually (delete nextjs sitemap) --- web/next-sitemap.js | 15 --------------- web/public/sitemap-0.xml | 6 ------ web/public/sitemap.xml | 12 +++++++++--- 3 files changed, 9 insertions(+), 24 deletions(-) delete mode 100644 web/next-sitemap.js delete mode 100644 web/public/sitemap-0.xml diff --git a/web/next-sitemap.js b/web/next-sitemap.js deleted file mode 100644 index cd6c9c35..00000000 --- a/web/next-sitemap.js +++ /dev/null @@ -1,15 +0,0 @@ -/** @type {import('next-sitemap').IConfig} */ - -module.exports = { - siteUrl: process.env.SITE_URL || 'https://manifold.markets', - changefreq: 'hourly', - priority: 0.7, // Set high priority by default - exclude: ['/admin', '/server-sitemap.xml'], - generateRobotsTxt: true, - robotsTxtOptions: { - additionalSitemaps: [ - 'https://manifold.markets/server-sitemap.xml', // <==== Add here - ], - }, - // Other options: https://github.com/iamvishnusankar/next-sitemap#configuration-options -} diff --git a/web/public/sitemap-0.xml b/web/public/sitemap-0.xml deleted file mode 100644 index d0750f46..00000000 --- a/web/public/sitemap-0.xml +++ /dev/null @@ -1,6 +0,0 @@ - - -https://manifold.marketshourly1.0 -https://manifold.markets/homehourly0.2 -https://manifold.markets/leaderboardsdaily0.2 - diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml index 050639f2..c52d0c0e 100644 --- a/web/public/sitemap.xml +++ b/web/public/sitemap.xml @@ -1,4 +1,10 @@ - -https://manifold.markets/sitemap-0.xml - \ No newline at end of file + +https://manifold.marketshourly1.0 +https://manifold.markets/homehourly0.2 +https://manifold.markets/leaderboardsdaily0.2 +https://manifold.markets/add-fundsdaily0.2 +https://manifold.markets/challengesdaily0.2 +https://manifold.markets/charitydaily0.7 +https://manifold.markets/groupsdaily0.2 + From 009c85b61a08c97fda131d958812cba3d9b1ba07 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 12:07:05 -0500 Subject: [PATCH 006/279] listAllContracts: order by popularity score --- web/lib/firebase/contracts.ts | 2 +- web/pages/server-sitemap.xml.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 1f83372e..453ec697 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -127,7 +127,7 @@ export async function listAllContracts( n: number, before?: string ): Promise { - let q = query(contracts, orderBy('createdTime', 'desc'), limit(n)) + let q = query(contracts, orderBy('popularityScore', 'desc'), limit(n)) if (before != null) { const snap = await getDoc(doc(contracts, before)) q = query(q, startAfter(snap)) diff --git a/web/pages/server-sitemap.xml.tsx b/web/pages/server-sitemap.xml.tsx index a6673dd4..0027c4dc 100644 --- a/web/pages/server-sitemap.xml.tsx +++ b/web/pages/server-sitemap.xml.tsx @@ -8,12 +8,14 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1)) - const fields = contracts.map((market) => ({ - loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`, - changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily', - priority: score(market.popularityScore ?? 0), - lastmod: market.lastUpdatedTime, - })) as ISitemapField[] + const fields = contracts + .sort((x) => x.popularityScore ?? 0) + .map((market) => ({ + loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`, + changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily', + priority: score(market.popularityScore ?? 0), + lastmod: market.lastUpdatedTime, + })) as ISitemapField[] return await getServerSideSitemap(ctx, fields) } From 2530171721bcf40bf006e0505b92b6cbfa5920e3 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 12:09:16 -0500 Subject: [PATCH 007/279] don't run next-sitemap post build --- web/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/web/package.json b/web/package.json index a41591ed..db3fdf45 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,6 @@ "start": "next start", "lint": "next lint", "format": "npx prettier --write .", - "postbuild": "next-sitemap", "verify": "(cd .. && yarn verify)", "verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit" }, From 0cd61eb214b4e62d1cf2b52e53ec79e5289d48b2 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 10:48:21 -0700 Subject: [PATCH 008/279] DX: Link to Firestore console from "..." --- common/envs/constants.ts | 4 ++++ .../contract/contract-info-dialog.tsx | 19 ++++++++++++++++++- web/hooks/use-admin.ts | 4 ++++ web/pages/admin.tsx | 3 ++- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 48f9bf63..89d040e8 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -44,3 +44,7 @@ export const CORS_ORIGIN_VERCEL = new RegExp( ) // Any localhost server on any port export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ + +export function firestoreConsolePath(contractId: string) { + return `https://console.firebase.google.com/project/${PROJECT_ID}/firestore/data/~2Fcontracts~2F${contractId}` +} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index be24d0b5..63c9ac72 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -13,6 +13,9 @@ import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Title } from '../title' import { InfoTooltip } from '../info-tooltip' +import { useAdmin, useDev } from 'web/hooks/use-admin' +import { SiteLink } from '../site-link' +import { firestoreConsolePath } from 'common/envs/constants' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -21,10 +24,12 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const [open, setOpen] = useState(false) + const isDev = useDev() + const isAdmin = useAdmin() const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z') - const { createdTime, closeTime, resolutionTime, mechanism, outcomeType } = + const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = contract const tradersCount = uniqBy( @@ -121,6 +126,18 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { {contractPool(contract)} + + {/* Show a path to Firebase if user is an admin, or we're on localhost */} + {(isAdmin || isDev) && ( + + [DEV] Firestore + + + Console link + + + + )} diff --git a/web/hooks/use-admin.ts b/web/hooks/use-admin.ts index 551c588b..aa566171 100644 --- a/web/hooks/use-admin.ts +++ b/web/hooks/use-admin.ts @@ -5,3 +5,7 @@ export const useAdmin = () => { const privateUser = usePrivateUser() return isAdmin(privateUser?.email || '') } + +export const useDev = () => { + return process.env.NODE_ENV === 'development' +} diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index 81f23ba9..209b38a3 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -10,6 +10,7 @@ import { mapKeys } from 'lodash' import { useAdmin } from 'web/hooks/use-admin' import { contractPath } from 'web/lib/firebase/contracts' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' +import { firestoreConsolePath } from 'common/envs/constants' export const getServerSideProps = redirectIfLoggedOut('/') @@ -198,7 +199,7 @@ function ContractsTable() { html(`${cell}`), + href="${firestoreConsolePath(cell as string)}">${cell}`), }, ]} search={true} From 7736f1e3c190d71fa7054a0b79ad737256473527 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 10:49:54 -0700 Subject: [PATCH 009/279] Make duplicating better: description, closetime, logscale Known issue: some markets like https://manifold.markets/FFSX/rojo-ronald-jones don't duplicate because too much stuff in JSON...? --- web/components/copy-contract-button.tsx | 17 ++++++++++++----- web/pages/create.tsx | 1 + 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx index 8536df71..cb23776c 100644 --- a/web/components/copy-contract-button.tsx +++ b/web/components/copy-contract-button.tsx @@ -33,22 +33,29 @@ export function DuplicateContractButton(props: { // Pass along the Uri to create a new contract function duplicateContractHref(contract: Contract) { + const descriptionString = JSON.stringify(contract.description) + // Don't set a closeTime that's in the past + const closeTime = + (contract?.closeTime ?? 0) <= Date.now() ? 0 : contract.closeTime const params = { q: contract.question, - closeTime: contract.closeTime || 0, - description: - (contract.description ? `${contract.description}\n\n` : '') + - `(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`, + closeTime, + description: descriptionString, outcomeType: contract.outcomeType, } as Record if (contract.outcomeType === 'PSEUDO_NUMERIC') { params.min = contract.min params.max = contract.max - params.isLogScale = contract.isLogScale + if (contract.isLogScale) { + // Conditional, because `?isLogScale=false` evaluates to `true` + params.isLogScale = true + } params.initValue = getMappedValue(contract)(contract.initialProbability) } + // TODO: Support multiple choice markets? + if (contract.groupLinks && contract.groupLinks.length > 0) { params.groupId = contract.groupLinks[0].groupId } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 52f2a373..2ec86bb7 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -207,6 +207,7 @@ export function NewContract(props: { max: MAX_DESCRIPTION_LENGTH, placeholder: descriptionPlaceholder, disabled: isSubmitting, + defaultValue: JSON.parse(params?.description ?? '{}'), }) const isEditorFilled = editor != null && !editor.isEmpty From 650aa68bcd8c596382621a3983a119a3cb2bcb00 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 11:31:33 -0700 Subject: [PATCH 010/279] Fix imports --- web/components/copy-contract-button.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx index cb23776c..07e519e1 100644 --- a/web/components/copy-contract-button.tsx +++ b/web/components/copy-contract-button.tsx @@ -1,9 +1,7 @@ import { DuplicateIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Contract } from 'common/contract' -import { ENV_CONFIG } from 'common/envs/constants' import { getMappedValue } from 'common/pseudo-numeric' -import { contractPath } from 'web/lib/firebase/contracts' import { trackCallback } from 'web/lib/service/analytics' export function DuplicateContractButton(props: { From 3313b55853c83be7746f9b8d011fc0cf885afc91 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 14:23:43 -0500 Subject: [PATCH 011/279] listAllContracts: sort by createdTime by default --- web/lib/firebase/contracts.ts | 5 +++-- web/pages/server-sitemap.xml.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 453ec697..9fe1e59c 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -125,9 +125,10 @@ export async function listTaggedContractsCaseInsensitive( export async function listAllContracts( n: number, - before?: string + before?: string, + sortDescBy = 'createdTime' ): Promise { - let q = query(contracts, orderBy('popularityScore', 'desc'), limit(n)) + let q = query(contracts, orderBy(sortDescBy, 'desc'), limit(n)) if (before != null) { const snap = await getDoc(doc(contracts, before)) q = query(q, startAfter(snap)) diff --git a/web/pages/server-sitemap.xml.tsx b/web/pages/server-sitemap.xml.tsx index 0027c4dc..15cb734c 100644 --- a/web/pages/server-sitemap.xml.tsx +++ b/web/pages/server-sitemap.xml.tsx @@ -4,7 +4,7 @@ import { getServerSideSitemap, ISitemapField } from 'next-sitemap' import { listAllContracts } from 'web/lib/firebase/contracts' export const getServerSideProps: GetServerSideProps = async (ctx) => { - const contracts = await listAllContracts(1000, undefined) + const contracts = await listAllContracts(1000, undefined, 'popularityScore') const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1)) From 571cf80e134450f2ce1ffa55a083484b62b20563 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Mon, 22 Aug 2022 14:42:23 -0500 Subject: [PATCH 012/279] markets api: only load 500 markets by default --- web/pages/api/v0/markets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/api/v0/markets.ts b/web/pages/api/v0/markets.ts index 56ecc594..78c54772 100644 --- a/web/pages/api/v0/markets.ts +++ b/web/pages/api/v0/markets.ts @@ -10,7 +10,7 @@ const queryParams = z .object({ limit: z .number() - .default(1000) + .default(500) .or(z.string().regex(/\d+/).transform(Number)) .refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'), before: z.string().optional(), From b9a667b1262f17d4ab8fcde5196318123b1e0adb Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 14:59:11 -0600 Subject: [PATCH 013/279] Add logs to weekly emails --- functions/src/weekly-markets-emails.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 1e43b7dc..881ba7ba 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -39,6 +39,11 @@ async function sendTrendingMarketsEmailsToAllUsers() { const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return !user.unsubscribedFromWeeklyTrendingEmails }) + log( + 'Sending weekly trending emails to', + privateUsersToSendEmailsTo.length, + 'users' + ) const trendingContracts = (await getTrendingContracts()) .filter( (contract) => @@ -48,6 +53,10 @@ async function sendTrendingMarketsEmailsToAllUsers() { ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS ) .slice(0, 20) + log( + `Found ${trendingContracts.length} trending contracts:\n`, + trendingContracts.map((c) => c.question).join('\n ') + ) for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { log(`No email for ${privateUser.username}`) From ec4d0f6b4ac641d250a8538f8d25d8ba89d38fc8 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 15:26:54 -0700 Subject: [PATCH 014/279] Fix notification for updated questions (#782) * Fix update notification for question, description * Don't notify on updated description --- functions/src/on-update-contract.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 2042f726..28523eae 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -40,19 +40,16 @@ export const onUpdateContract = functions.firestore ) } else if ( previousValue.closeTime !== contract.closeTime || - previousValue.description !== contract.description + previousValue.question !== contract.question ) { let sourceText = '' - if (previousValue.closeTime !== contract.closeTime && contract.closeTime) + if ( + previousValue.closeTime !== contract.closeTime && + contract.closeTime + ) { sourceText = contract.closeTime.toString() - else { - const oldTrimmedDescription = previousValue.description.trim() - const newTrimmedDescription = contract.description.trim() - if (oldTrimmedDescription === '') sourceText = newTrimmedDescription - else - sourceText = newTrimmedDescription - .split(oldTrimmedDescription)[1] - .trim() + } else if (previousValue.question !== contract.question) { + sourceText = contract.question } await createNotification( From e1775681aa47b67601abda567b19024529c190d8 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 16:36:39 -0600 Subject: [PATCH 015/279] Add weekly email sent flag, filter out manifold grouped markets --- common/user.ts | 1 + functions/src/index.ts | 1 + functions/src/reset-betting-streaks.ts | 2 +- functions/src/reset-weekly-emails-flag.ts | 24 +++++++++++++++++++++++ functions/src/weekly-markets-emails.ts | 20 ++++++++++++++----- 5 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 functions/src/reset-weekly-emails-flag.ts diff --git a/common/user.ts b/common/user.ts index dee1413f..9927a3d3 100644 --- a/common/user.ts +++ b/common/user.ts @@ -54,6 +54,7 @@ export type PrivateUser = { unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean unsubscribedFromWeeklyTrendingEmails?: boolean + weeklyTrendingEmailSent?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/functions/src/index.ts b/functions/src/index.ts index b0ad50fa..26a1ddf6 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -29,6 +29,7 @@ export * from './on-delete-group' export * from './score-contracts' export * from './weekly-markets-emails' export * from './reset-betting-streaks' +export * from './reset-weekly-emails-flag' // v2 export * from './health' diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index c781aba2..924f5c22 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -9,7 +9,7 @@ const firestore = admin.firestore() export const resetBettingStreaksForUsers = functions.pubsub .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) - .timeZone('utc') + .timeZone('Etc/UTC') .onRun(async () => { await resetBettingStreaksInternal() }) diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flag.ts new file mode 100644 index 00000000..fc6b396a --- /dev/null +++ b/functions/src/reset-weekly-emails-flag.ts @@ -0,0 +1,24 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { getAllPrivateUsers } from './utils' + +export const resetWeeklyEmailsFlag = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + // every Monday at 1 am PT (UTC -07:00) ( 12 hours before the emails will be sent) + .pubsub.schedule('0 7 * * 1') + .timeZone('Etc/UTC') + .onRun(async () => { + const privateUsers = await getAllPrivateUsers() + // get all users that haven't unsubscribed from weekly emails + const privateUsersToSendEmailsTo = privateUsers.filter((user) => { + return !user.unsubscribedFromWeeklyTrendingEmails + }) + const firestore = admin.firestore() + await Promise.all( + privateUsersToSendEmailsTo.map(async (user) => { + return firestore.collection('private-users').doc(user.id).update({ + weeklyTrendingEmailSent: false, + }) + }) + ) + }) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 881ba7ba..c7331dae 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -9,9 +9,9 @@ import { DAY_MS } from '../../common/util/time' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) - // every Monday at 12pm PT (UTC -07:00) - .pubsub.schedule('0 19 * * 1') - .timeZone('utc') + // every minute on Monday for an hour at 12pm PT (UTC -07:00) + .pubsub.schedule('* 18 * * 1') + .timeZone('Etc/UTC') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() }) @@ -37,7 +37,10 @@ async function sendTrendingMarketsEmailsToAllUsers() { const privateUsers = await getAllPrivateUsers() // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers.filter((user) => { - return !user.unsubscribedFromWeeklyTrendingEmails + return ( + !user.unsubscribedFromWeeklyTrendingEmails && + !user.weeklyTrendingEmailSent + ) }) log( 'Sending weekly trending emails to', @@ -50,13 +53,17 @@ async function sendTrendingMarketsEmailsToAllUsers() { !( contract.question.toLowerCase().includes('trump') && contract.question.toLowerCase().includes('president') - ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS + ) && + (contract?.closeTime ?? 0) > Date.now() + DAY_MS && + !contract.groupSlugs?.includes('manifold-features') && + !contract.groupSlugs?.includes('manifold-6748e065087e') ) .slice(0, 20) log( `Found ${trendingContracts.length} trending contracts:\n`, trendingContracts.map((c) => c.question).join('\n ') ) + for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { log(`No email for ${privateUser.username}`) @@ -79,6 +86,9 @@ async function sendTrendingMarketsEmailsToAllUsers() { if (!user) continue await sendInterestingMarketsEmail(user, privateUser, contractsToSend) + await firestore.collection('private-users').doc(user.id).update({ + weeklyTrendingEmailSent: true, + }) } } From 6929076740179a10bccede717de1c5edd133e110 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 16:43:08 -0600 Subject: [PATCH 016/279] Be more specific about unsubscribe --- functions/src/email-templates/interesting-markets.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html index fc067643..d00b227e 100644 --- a/functions/src/email-templates/interesting-markets.html +++ b/functions/src/email-templates/interesting-markets.html @@ -444,7 +444,7 @@ style=" color: inherit; text-decoration: none; - " target="_blank">click here to unsubscribe. + " target="_blank">click here to unsubscribe from future recommended markets.

From 3bea9836620ad54cdf994155a20ba3e251612d76 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 22 Aug 2022 16:56:28 -0600 Subject: [PATCH 017/279] Be more explicit after unsubscribing from weekly trending --- functions/src/reset-weekly-emails-flag.ts | 2 +- functions/src/unsubscribe.ts | 4 ++++ functions/src/weekly-markets-emails.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flag.ts index fc6b396a..5a71b65b 100644 --- a/functions/src/reset-weekly-emails-flag.ts +++ b/functions/src/reset-weekly-emails-flag.ts @@ -4,7 +4,7 @@ import { getAllPrivateUsers } from './utils' export const resetWeeklyEmailsFlag = functions .runWith({ secrets: ['MAILGUN_KEY'] }) - // every Monday at 1 am PT (UTC -07:00) ( 12 hours before the emails will be sent) + // every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent) .pubsub.schedule('0 7 * * 1') .timeZone('Etc/UTC') .onRun(async () => { diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index 4db91539..da7b507f 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -69,6 +69,10 @@ export const unsubscribe: EndpointDefinition = { res.send( `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` ) + else if (type === 'weekly-trending') + res.send( + `${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.` + ) else res.send(`${name}, you have been unsubscribed.`) }, } diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index c7331dae..a20e40a2 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -10,7 +10,7 @@ import { DAY_MS } from '../../common/util/time' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) // every minute on Monday for an hour at 12pm PT (UTC -07:00) - .pubsub.schedule('* 18 * * 1') + .pubsub.schedule('* 19 * * 1') .timeZone('Etc/UTC') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() From 552f9add700554a480d58469524245f4ce426d4c Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 22 Aug 2022 17:23:59 -0700 Subject: [PATCH 018/279] Reduce min time on contract graph to 1h Allows more resolution on real-time markets, where a lot of trading happens within minutes --- web/components/contract/contract-prob-graph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 98440ec8..693befbb 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -58,7 +58,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const { width } = useWindowSize() const numXTickValues = !width || width < 800 ? 2 : 5 - const hoursAgo = latestTime.subtract(5, 'hours') + const hoursAgo = latestTime.subtract(1, 'hours') const startDate = dayjs(times[0]).isBefore(hoursAgo) ? times[0] : hoursAgo.toDate() From 20fd286756c2b957850c8ab84394bf0e4116452e Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 17:45:23 -0700 Subject: [PATCH 019/279] Fix link classes duplicating on paste (#788) --- web/components/editor.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index f4166f27..6af58caa 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -6,6 +6,7 @@ import { JSONContent, Content, Editor, + mergeAttributes, } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { Image } from '@tiptap/extension-image' @@ -38,7 +39,16 @@ const DisplayImage = Image.configure({ }, }) -const DisplayLink = Link.configure({ +const DisplayLink = Link.extend({ + renderHTML({ HTMLAttributes }) { + delete HTMLAttributes.class // only use our classes (don't duplicate on paste) + return [ + 'a', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ] + }, +}).configure({ HTMLAttributes: { class: clsx('no-underline !text-indigo-700', linkClass), }, From baa27a3c856bfad7edf4f8674f7ac940e7393d80 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 17:50:59 -0700 Subject: [PATCH 020/279] Make Sinclair an admin --- firestore.rules | 1 + 1 file changed, 1 insertion(+) diff --git a/firestore.rules b/firestore.rules index c0d17dac..b28ac6a5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -10,6 +10,7 @@ service cloud.firestore { 'akrolsmir@gmail.com', 'jahooma@gmail.com', 'taowell@gmail.com', + 'abc.sinclair@gmail.com', 'manticmarkets@gmail.com' ] } From b476a7e3f82ef0d248c3e480ea77aee5e0c00025 Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Mon, 22 Aug 2022 18:18:51 -0700 Subject: [PATCH 021/279] Take descriptions out of LiteMarket (#789) --- docs/docs/api.md | 5 ++--- web/pages/api/v0/_types.ts | 17 +++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/docs/api.md b/docs/docs/api.md index 7b0058c2..c02a5141 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -97,7 +97,6 @@ Requires no authorization. "creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c", "closeTime":1653893940000, "question":"Will I write a new blog post today?", - "description":"I'm supposed to, or else Beeminder charges me $90.\nTentative topic ideas:\n- \"Manifold funding, a history\"\n- \"Markets and bounties allow trades through time\"\n- \"equity vs money vs time\"\n\nClose date updated to 2022-05-29 11:59 pm", "tags":[ "personal", "commitments" @@ -135,8 +134,6 @@ Requires no authorization. // Market attributes. All times are in milliseconds since epoch closeTime?: number // Min of creator's chosen date, and resolutionTime question: string - description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json - textDescription: string // string description without formatting, images, or embeds // A list of tags on each market. Any user can add tags to any market. // This list also includes the predefined categories shown as filters on the home page. @@ -398,6 +395,8 @@ Requires no authorization. bets: Bet[] comments: Comment[] answers?: Answer[] + description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json + textDescription: string // string description without formatting, images, or embeds } type Bet = { diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index f0d9c443..968b770e 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -22,8 +22,6 @@ export type LiteMarket = { // Market attributes. All times are in milliseconds since epoch closeTime?: number question: string - description: string | JSONContent - textDescription: string // string version of description tags: string[] url: string outcomeType: string @@ -54,6 +52,8 @@ export type FullMarket = LiteMarket & { bets: Bet[] comments: Comment[] answers?: ApiAnswer[] + description: string | JSONContent + textDescription: string // string version of description } export type ApiError = { @@ -81,7 +81,6 @@ export function toLiteMarket(contract: Contract): LiteMarket { creatorAvatarUrl, closeTime, question, - description, tags, slug, pool, @@ -118,11 +117,6 @@ export function toLiteMarket(contract: Contract): LiteMarket { ? Math.min(resolutionTime, closeTime) : closeTime, question, - description, - textDescription: - typeof description === 'string' - ? description - : richTextToString(description), tags, url: `https://manifold.markets/${creatorUsername}/${slug}`, pool, @@ -158,11 +152,18 @@ export function toFullMarket( ) : undefined + const { description } = contract + return { ...liteMarket, answers, comments, bets, + description, + textDescription: + typeof description === 'string' + ? description + : richTextToString(description), } } From 1c73d2192587356e6c2010faee1fe8178daba3a1 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Tue, 23 Aug 2022 00:27:07 -0500 Subject: [PATCH 022/279] weeklyMarketsEmails: send different markets to different users --- functions/src/weekly-markets-emails.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index a20e40a2..bf839d00 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -92,9 +92,11 @@ async function sendTrendingMarketsEmailsToAllUsers() { } } +const fiveMinutes = 5 * 60 * 1000 +const seed = Math.round(Date.now() / fiveMinutes).toString() +const rng = createRNG(seed) + function chooseRandomSubset(contracts: Contract[], count: number) { - const fiveMinutes = 5 * 60 * 1000 - const seed = Math.round(Date.now() / fiveMinutes).toString() - shuffle(contracts, createRNG(seed)) + shuffle(contracts, rng) return contracts.slice(0, count) } From bea94d58c579f0fd29702bf77bc03a836f8ee2b0 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 23 Aug 2022 07:55:26 -0600 Subject: [PATCH 023/279] Add extra text-sm --- 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 116c367d..94ad6680 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -165,7 +165,7 @@ function NotificationsList(props: { if (!paginatedGroupedNotifications || !allGroupedNotifications) return
return ( -
+
{paginatedGroupedNotifications.length === 0 && (
You don't have any notifications. Try changing your settings to see From 7da4eb8fe919bf0bb8cd138db33d1f89a1f5b77f Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Tue, 23 Aug 2022 14:31:52 -0700 Subject: [PATCH 024/279] Fix bet modal probability sticking (#793) * Fix button group styles * Reset prob strike-out when bet modal closed --- web/components/bet-inline.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index 7eda7198..64780f4b 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format' export function BetInline(props: { contract: CPMMBinaryContract | PseudoNumericContract className?: string - setProbAfter: (probAfter: number) => void + setProbAfter: (probAfter: number | undefined) => void onClose: () => void }) { const { contract, className, setProbAfter, onClose } = props @@ -82,7 +82,7 @@ export function BetInline(props: {
Bet
)} - From 78780a92199d233004cfec489bdc122e0a4f9a7c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 23 Aug 2022 19:25:57 -0500 Subject: [PATCH 025/279] Dedup contract leaderboards code from contract slug (merge error?) --- .../contract/contract-leaderboard.tsx | 2 +- web/pages/[username]/[contractSlug].tsx | 140 +----------------- 2 files changed, 8 insertions(+), 134 deletions(-) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 22175876..77af001e 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 ? ( ) } - -function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) { - const { contract, bets } = props - const [users, setUsers] = useState() - - const { userProfits, top5Ids } = useMemo(() => { - // Create a map of userIds to total profits (including sales) - const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) - const betsByUser = groupBy(openBets, 'userId') - - const userProfits = mapValues(betsByUser, (bets) => - sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount) - ) - // Find the 5 users with the most profits - const top5Ids = Object.entries(userProfits) - .sort(([_i1, p1], [_i2, p2]) => p2 - p1) - .filter(([, p]) => p > 0) - .slice(0, 5) - .map(([id]) => id) - return { userProfits, top5Ids } - }, [contract, bets]) - - useEffect(() => { - if (top5Ids.length > 0) { - listUsers(top5Ids).then((users) => { - const sortedUsers = sortBy(users, (user) => -userProfits[user.id]) - setUsers(sortedUsers) - }) - } - }, [userProfits, top5Ids]) - - return users && users.length > 0 ? ( - formatMoney(userProfits[user.id] || 0), - }, - ]} - className="mt-12 max-w-sm" - /> - ) : null -} - -function ContractTopTrades(props: { - contract: Contract - bets: Bet[] - comments: ContractComment[] - tips: CommentTipMap -}) { - const { contract, bets, comments, tips } = props - const commentsById = keyBy(comments, 'id') - const betsById = keyBy(bets, 'id') - - // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit - // Otherwise, we record the profit at resolution time - const profitById: Record = {} - for (const bet of bets) { - if (bet.sale) { - const originalBet = betsById[bet.sale.betId] - const profit = bet.sale.amount - originalBet.amount - profitById[bet.id] = profit - profitById[originalBet.id] = profit - } else { - profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount - } - } - - // Now find the betId with the highest profit - const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id - const topBettor = useUserById(betsById[topBetId]?.userId) - - // And also the commentId of the comment with the highest profit - const topCommentId = sortBy( - comments, - (c) => c.betId && -profitById[c.betId] - )[0]?.id - - return ( -
- {topCommentId && profitById[topCommentId] > 0 && ( - <> - - <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> - <FeedComment - contract={contract} - comment={commentsById[topCommentId]} - tips={tips[topCommentId]} - betsBySameUser={[betsById[topCommentId]]} - smallAvatar={false} - /> - </div> - <div className="mt-2 text-sm text-gray-500"> - {commentsById[topCommentId].userName} made{' '} - {formatMoney(profitById[topCommentId] || 0)}! - </div> - <Spacer h={16} /> - </> - )} - - {/* If they're the same, only show the comment; otherwise show both */} - {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( - <> - <Title text="💸 Smartest money" 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]} - hideOutcome={false} - smallAvatar={false} - /> - </div> - <div className="mt-2 text-sm text-gray-500"> - {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! - </div> - </> - )} - </div> - ) -} From f50b4775a1e563f18a15fa441101ffb5fdc710b3 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 10:49:53 -0600 Subject: [PATCH 026/279] Allow to follow/unfollow markets, backfill as well (#794) * Allow to follow/unfollow markets, backfill as well * remove yarn script edit * add decrement comment * Lint * Decrement follow count on unfollow * Follow/unfollow button logic * Unfollow/follow => heart * Add user to followers in place-bet and sell-shares * Add tracking * Show contract follow modal for first time following * Increment follower count as well * Remove add follow from bet trigger * restore on-create-bet * Add pubsub to dev.sh, show heart on FR, remove from answer trigger --- common/contract.ts | 1 + common/notification.ts | 1 + common/user.ts | 1 + dev.sh | 2 +- firestore.rules | 7 +- functions/package.json | 2 +- functions/src/create-notification.ts | 440 +++++++++++------- functions/src/follow-market.ts | 36 ++ functions/src/index.ts | 1 + functions/src/on-create-answer.ts | 7 +- .../src/on-create-comment-on-contract.ts | 18 +- functions/src/on-create-contract.ts | 2 + .../src/on-create-liquidity-provision.ts | 2 + functions/src/on-update-contract-follow.ts | 45 ++ functions/src/on-update-contract.ts | 10 +- functions/src/place-bet.ts | 3 + .../scripts/backfill-contract-followers.ts | 75 +++ functions/src/sell-shares.ts | 6 +- web/components/NotificationSettings.tsx | 82 ++-- web/components/contract/contract-overview.tsx | 69 +-- .../contract/follow-market-modal.tsx | 33 ++ web/components/follow-market-button.tsx | 76 +++ web/hooks/use-follows.ts | 11 + web/hooks/use-notifications.ts | 1 + web/lib/firebase/contracts.ts | 23 + 25 files changed, 719 insertions(+), 235 deletions(-) create mode 100644 functions/src/follow-market.ts create mode 100644 functions/src/on-update-contract-follow.ts create mode 100644 functions/src/scripts/backfill-contract-followers.ts create mode 100644 web/components/contract/follow-market-modal.tsx create mode 100644 web/components/follow-market-button.tsx diff --git a/common/contract.ts b/common/contract.ts index 2a8f897a..343bc750 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -57,6 +57,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { uniqueBettorIds?: string[] uniqueBettorCount?: number popularityScore?: number + followerCount?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/notification.ts b/common/notification.ts index 0a69f89d..f10bd3f6 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -70,3 +70,4 @@ export type notification_reason_types = | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' + | 'you_follow_contract' diff --git a/common/user.ts b/common/user.ts index 9927a3d3..b278300c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -42,6 +42,7 @@ export type User = { shouldShowWelcome?: boolean lastBetTime?: number currentBettingStreak?: number + hasSeenContractFollowModal?: boolean } export type PrivateUser = { diff --git a/dev.sh b/dev.sh index ca3246ac..d392646e 100755 --- a/dev.sh +++ b/dev.sh @@ -24,7 +24,7 @@ then npx concurrently \ -n FIRESTORE,FUNCTIONS,NEXT,TS \ -c green,white,magenta,cyan \ - "yarn --cwd=functions firestore" \ + "yarn --cwd=functions localDbScript" \ "cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \ "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \ NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \ diff --git a/firestore.rules b/firestore.rules index b28ac6a5..0e5a759b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -23,7 +23,7 @@ service cloud.firestore { allow read; allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']); // User referral rules allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() @@ -44,6 +44,11 @@ service cloud.firestore { allow read; } + match /contracts/{contractId}/follows/{userId} { + allow read; + allow create, delete: if userId == request.auth.uid; + } + match /contracts/{contractId}/challenges/{challengeId}{ allow read; allow create: if request.auth.uid == request.resource.data.creatorId; diff --git a/functions/package.json b/functions/package.json index 63ef9b5d..c8f295fc 100644 --- a/functions/package.json +++ b/functions/package.json @@ -13,7 +13,7 @@ "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", "dev": "nodemon src/serve.ts", - "firestore": "firebase emulators:start --only firestore --import=./firestore_export", + "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 3fb1f9c3..035126c5 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -7,7 +7,7 @@ import { } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues } from './utils' +import { getValues, log } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -33,19 +33,12 @@ export const createNotification = async ( sourceText: string, miscData?: { contract?: Contract - relatedSourceType?: notification_source_types recipients?: string[] slug?: string title?: string } ) => { - const { - contract: sourceContract, - relatedSourceType, - recipients, - slug, - title, - } = miscData ?? {} + const { contract: sourceContract, recipients, slug, title } = miscData ?? {} const shouldGetNotification = ( userId: string, @@ -90,24 +83,6 @@ export const createNotification = async ( ) } - const notifyLiquidityProviders = async ( - userToReasonTexts: user_to_reason_texts, - contract: Contract - ) => { - const liquidityProviders = await firestore - .collection(`contracts/${contract.id}/liquidity`) - .get() - const liquidityProvidersIds = uniq( - liquidityProviders.docs.map((doc) => doc.data().userId) - ) - liquidityProvidersIds.forEach((userId) => { - if (!shouldGetNotification(userId, userToReasonTexts)) return - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) - } - const notifyUsersFollowers = async ( userToReasonTexts: user_to_reason_texts ) => { @@ -129,23 +104,6 @@ export const createNotification = async ( }) } - const notifyRepliedUser = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string, - relatedSourceType: notification_source_types - ) => { - if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return - if (relatedSourceType === 'comment') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_comment', - } - } else if (relatedSourceType === 'answer') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_answer', - } - } - } - const notifyFollowedUser = ( userToReasonTexts: user_to_reason_texts, followedUserId: string @@ -182,71 +140,6 @@ export const createNotification = async ( } } - const notifyOtherAnswerersOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const answers = await getValues<Answer>( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('answers') - ) - const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) - } - - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const comments = await getValues<Comment>( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('comments') - ) - const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) - } - - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const betsSnap = await firestore - .collection(`contracts/${sourceContract.id}/bets`) - .get() - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - // filter bets for only users that have an amount invested still - const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter( - (userId) => { - return ( - getContractBetMetrics( - sourceContract, - bets.filter((bet) => bet.userId === userId) - ).invested > 0 - ) - } - ) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) - } - const notifyUserAddedToGroup = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string @@ -266,58 +159,289 @@ export const createNotification = async ( } } - const getUsersToNotify = async () => { - const userToReasonTexts: user_to_reason_texts = {} - // The following functions modify the userToReasonTexts object in place. - if (sourceType === 'follow' && recipients?.[0]) { - notifyFollowedUser(userToReasonTexts, recipients[0]) - } else if ( - sourceType === 'group' && - sourceUpdateType === 'created' && - recipients - ) { - recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) - } + const userToReasonTexts: user_to_reason_texts = {} + // The following functions modify the userToReasonTexts object in place. - // The following functions need sourceContract to be defined. - if (!sourceContract) return userToReasonTexts - - if ( - sourceType === 'comment' || - sourceType === 'answer' || - (sourceType === 'contract' && - (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) - ) { - if (sourceType === 'comment') { - if (recipients?.[0] && relatedSourceType) - notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) - } - await notifyContractCreator(userToReasonTexts, sourceContract) - await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) - await notifyLiquidityProviders(userToReasonTexts, sourceContract) - await notifyBettorsOnContract(userToReasonTexts, sourceContract) - await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) - } else if (sourceType === 'contract' && sourceUpdateType === 'created') { - await notifyUsersFollowers(userToReasonTexts) - notifyTaggedUsers(userToReasonTexts, recipients ?? []) - } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) - } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { - await notifyContractCreator(userToReasonTexts, sourceContract) - } else if (sourceType === 'bonus' && sourceUpdateType === 'created') { - // Note: the daily bonus won't have a contract attached to it - await notifyContractCreatorOfUniqueBettorsBonus( - userToReasonTexts, - sourceContract.creatorId - ) - } - return userToReasonTexts + if (sourceType === 'follow' && recipients?.[0]) { + notifyFollowedUser(userToReasonTexts, recipients[0]) + } else if ( + sourceType === 'group' && + sourceUpdateType === 'created' && + recipients + ) { + recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) + } else if ( + sourceType === 'contract' && + sourceUpdateType === 'created' && + sourceContract + ) { + await notifyUsersFollowers(userToReasonTexts) + notifyTaggedUsers(userToReasonTexts, recipients ?? []) + } else if ( + sourceType === 'contract' && + sourceUpdateType === 'closed' && + sourceContract + ) { + await notifyContractCreator(userToReasonTexts, sourceContract, { + force: true, + }) + } else if ( + sourceType === 'liquidity' && + sourceUpdateType === 'created' && + sourceContract + ) { + await notifyContractCreator(userToReasonTexts, sourceContract) + } else if ( + sourceType === 'bonus' && + sourceUpdateType === 'created' && + sourceContract + ) { + // Note: the daily bonus won't have a contract attached to it + await notifyContractCreatorOfUniqueBettorsBonus( + userToReasonTexts, + sourceContract.creatorId + ) } - const userToReasonTexts = await getUsersToNotify() + await createUsersNotifications(userToReasonTexts) +} + +export const createCommentOrAnswerOrUpdatedContractNotification = async ( + sourceId: string, + sourceType: notification_source_types, + sourceUpdateType: notification_source_update_types, + sourceUser: User, + idempotencyKey: string, + sourceText: string, + sourceContract: Contract, + miscData?: { + relatedSourceType?: notification_source_types + repliedUserId?: string + taggedUserIds?: string[] + } +) => { + const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {} + + const createUsersNotifications = async ( + userToReasonTexts: user_to_reason_texts + ) => { + await Promise.all( + Object.keys(userToReasonTexts).map(async (userId) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason: userToReasonTexts[userId].reason, + createdTime: Date.now(), + isSeen: false, + sourceId, + sourceType, + sourceUpdateType, + sourceContractId: sourceContract.id, + sourceUserName: sourceUser.name, + sourceUserUsername: sourceUser.username, + sourceUserAvatarUrl: sourceUser.avatarUrl, + sourceText, + sourceContractCreatorUsername: sourceContract.creatorUsername, + sourceContractTitle: sourceContract.question, + sourceContractSlug: sourceContract.slug, + sourceSlug: sourceContract.slug, + sourceTitle: sourceContract.question, + } + await notificationRef.set(removeUndefinedProps(notification)) + }) + ) + } + + // get contract follower documents and check here if they're a follower + const contractFollowersSnap = await firestore + .collection(`contracts/${sourceContract.id}/follows`) + .get() + const contractFollowersIds = contractFollowersSnap.docs.map( + (doc) => doc.data().id + ) + log('contractFollowerIds', contractFollowersIds) + + const stillFollowingContract = (userId: string) => { + return contractFollowersIds.includes(userId) + } + + const shouldGetNotification = ( + userId: string, + userToReasonTexts: user_to_reason_texts + ) => { + return ( + sourceUser.id != userId && + !Object.keys(userToReasonTexts).includes(userId) + ) + } + + const notifyContractFollowers = async ( + userToReasonTexts: user_to_reason_texts + ) => { + for (const userId of contractFollowersIds) { + if (shouldGetNotification(userId, userToReasonTexts)) + userToReasonTexts[userId] = { + reason: 'you_follow_contract', + } + } + } + + const notifyContractCreator = async ( + userToReasonTexts: user_to_reason_texts + ) => { + if ( + shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && + stillFollowingContract(sourceContract.creatorId) + ) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'on_users_contract', + } + } + + const notifyOtherAnswerersOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const answers = await getValues<Answer>( + firestore + .collection('contracts') + .doc(sourceContract.id) + .collection('answers') + ) + const recipientUserIds = uniq(answers.map((answer) => answer.userId)) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_answer', + } + }) + } + + const notifyOtherCommentersOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const comments = await getValues<Comment>( + firestore + .collection('contracts') + .doc(sourceContract.id) + .collection('comments') + ) + const recipientUserIds = uniq(comments.map((comment) => comment.userId)) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_comment', + } + }) + } + + const notifyBettorsOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const betsSnap = await firestore + .collection(`contracts/${sourceContract.id}/bets`) + .get() + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + // filter bets for only users that have an amount invested still + const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter( + (userId) => { + return ( + getContractBetMetrics( + sourceContract, + bets.filter((bet) => bet.userId === userId) + ).invested > 0 + ) + } + ) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_shares_in', + } + }) + } + + const notifyRepliedUser = ( + userToReasonTexts: user_to_reason_texts, + relatedUserId: string, + relatedSourceType: notification_source_types + ) => { + if ( + shouldGetNotification(relatedUserId, userToReasonTexts) && + stillFollowingContract(relatedUserId) + ) { + if (relatedSourceType === 'comment') { + userToReasonTexts[relatedUserId] = { + reason: 'reply_to_users_comment', + } + } else if (relatedSourceType === 'answer') { + userToReasonTexts[relatedUserId] = { + reason: 'reply_to_users_answer', + } + } + } + } + + const notifyTaggedUsers = ( + userToReasonTexts: user_to_reason_texts, + userIds: (string | undefined)[] + ) => { + userIds.forEach((id) => { + console.log('tagged user: ', id) + // Allowing non-following users to get tagged + if (id && shouldGetNotification(id, userToReasonTexts)) + userToReasonTexts[id] = { + reason: 'tagged_user', + } + }) + } + + const notifyLiquidityProviders = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const liquidityProviders = await firestore + .collection(`contracts/${sourceContract.id}/liquidity`) + .get() + const liquidityProvidersIds = uniq( + liquidityProviders.docs.map((doc) => doc.data().userId) + ) + liquidityProvidersIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) { + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_shares_in', + } + } + }) + } + const userToReasonTexts: user_to_reason_texts = {} + + if (sourceType === 'comment') { + if (repliedUserId && relatedSourceType) + notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) + if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) + } + await notifyContractCreator(userToReasonTexts) + await notifyOtherAnswerersOnContract(userToReasonTexts) + await notifyLiquidityProviders(userToReasonTexts) + await notifyBettorsOnContract(userToReasonTexts) + await notifyOtherCommentersOnContract(userToReasonTexts) + // if they weren't added previously, add them now + await notifyContractFollowers(userToReasonTexts) + await createUsersNotifications(userToReasonTexts) } diff --git a/functions/src/follow-market.ts b/functions/src/follow-market.ts new file mode 100644 index 00000000..3fc05120 --- /dev/null +++ b/functions/src/follow-market.ts @@ -0,0 +1,36 @@ +import * as admin from 'firebase-admin' + +const firestore = admin.firestore() + +export const addUserToContractFollowers = async ( + contractId: string, + userId: string +) => { + const followerDoc = await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .get() + if (followerDoc.exists) return + await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .set({ + id: userId, + createdTime: Date.now(), + }) +} + +export const removeUserFromContractFollowers = async ( + contractId: string, + userId: string +) => { + const followerDoc = await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .get() + if (!followerDoc.exists) return + await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .delete() +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 26a1ddf6..012ba241 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -30,6 +30,7 @@ export * from './score-contracts' export * from './weekly-markets-emails' export * from './reset-betting-streaks' export * from './reset-weekly-emails-flag' +export * from './on-update-contract-follow' // v2 export * from './health' diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index 6af5e699..611bf23b 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import { getContract, getUser } from './utils' -import { createNotification } from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Answer } from '../../common/answer' export const onCreateAnswer = functions.firestore @@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore const answerCreator = await getUser(answer.userId) if (!answerCreator) throw new Error('Could not find answer creator') - - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( answer.id, 'answer', 'created', answerCreator, eventId, answer.text, - { contract } + contract ) }) diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 9f19dfcc..8651bde0 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { createNotification } from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' +import { addUserToContractFollowers } from './follow-market' const firestore = admin.firestore() @@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions const commentCreator = await getUser(comment.userId) if (!commentCreator) throw new Error('Could not find comment creator') + await addUserToContractFollowers(contract.id, commentCreator.id) + await firestore .collection('contracts') .doc(contract.id) @@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId - const recipients = uniq( - compact([...parseMentions(comment.content), repliedUserId]) - ) - - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( comment.id, 'comment', 'created', commentCreator, eventId, richTextToString(comment.content), - { contract, relatedSourceType, recipients } + contract, + { + relatedSourceType, + repliedUserId, + taggedUserIds: compact(parseMentions(comment.content)), + } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 3785ecc9..d9826f6c 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -5,6 +5,7 @@ import { createNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' +import { addUserToContractFollowers } from './follow-market' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -18,6 +19,7 @@ export const onCreateContract = functions const desc = contract.description as JSONContent const mentioned = parseMentions(desc) + await addUserToContractFollowers(contract.id, contractCreator.id) await createNotification( contract.id, diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 6ec092a5..56a01bbb 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -2,6 +2,7 @@ import * as functions from 'firebase-functions' import { getContract, getUser } from './utils' import { createNotification } from './create-notification' import { LiquidityProvision } from 'common/liquidity-provision' +import { addUserToContractFollowers } from './follow-market' export const onCreateLiquidityProvision = functions.firestore .document('contracts/{contractId}/liquidity/{liquidityId}') @@ -18,6 +19,7 @@ export const onCreateLiquidityProvision = functions.firestore const liquidityProvider = await getUser(liquidity.userId) if (!liquidityProvider) throw new Error('Could not find liquidity provider') + await addUserToContractFollowers(contract.id, liquidityProvider.id) await createNotification( contract.id, diff --git a/functions/src/on-update-contract-follow.ts b/functions/src/on-update-contract-follow.ts new file mode 100644 index 00000000..f7d54fe8 --- /dev/null +++ b/functions/src/on-update-contract-follow.ts @@ -0,0 +1,45 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { FieldValue } from 'firebase-admin/firestore' + +export const onDeleteContractFollow = functions.firestore + .document('contracts/{contractId}/follows/{userId}') + .onDelete(async (change, context) => { + const { contractId } = context.params as { + contractId: string + } + const firestore = admin.firestore() + const contract = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + if (!contract.exists) throw new Error('Could not find contract') + + await firestore + .collection(`contracts`) + .doc(contractId) + .update({ + followerCount: FieldValue.increment(-1), + }) + }) + +export const onCreateContractFollow = functions.firestore + .document('contracts/{contractId}/follows/{userId}') + .onCreate(async (change, context) => { + const { contractId } = context.params as { + contractId: string + } + const firestore = admin.firestore() + const contract = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + if (!contract.exists) throw new Error('Could not find contract') + + await firestore + .collection(`contracts`) + .doc(contractId) + .update({ + followerCount: FieldValue.increment(1), + }) + }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 28523eae..d7ecd56e 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' export const onUpdateContract = functions.firestore @@ -29,14 +29,14 @@ export const onUpdateContract = functions.firestore resolutionText = `${contract.resolutionValue}` } - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( contract.id, 'contract', 'resolved', contractUpdater, eventId, resolutionText, - { contract } + contract ) } else if ( previousValue.closeTime !== contract.closeTime || @@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore sourceText = contract.question } - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( contract.id, 'contract', 'updated', contractUpdater, eventId, sourceText, - { contract } + contract ) } }) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 44a96210..237019a4 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet' import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' +import { addUserToContractFollowers } from 'functions/src/follow-market' const bodySchema = z.object({ contractId: z.string(), @@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => { return { betId: betDoc.id, makers, newBet } }) + await addUserToContractFollowers(contractId, auth.uid) + log('Main transaction finished.') if (result.newBet.amount !== 0) { diff --git a/functions/src/scripts/backfill-contract-followers.ts b/functions/src/scripts/backfill-contract-followers.ts new file mode 100644 index 00000000..9b936654 --- /dev/null +++ b/functions/src/scripts/backfill-contract-followers.ts @@ -0,0 +1,75 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues } from '../utils' +import { Contract } from 'common/lib/contract' +import { Comment } from 'common/lib/comment' +import { uniq } from 'lodash' +import { Bet } from 'common/lib/bet' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from 'common/lib/antes' + +const firestore = admin.firestore() + +async function backfillContractFollowers() { + console.log('Backfilling contract followers') + const contracts = await getValues<Contract>( + firestore.collection('contracts').where('isResolved', '==', false) + ) + let count = 0 + for (const contract of contracts) { + const comments = await getValues<Comment>( + firestore.collection('contracts').doc(contract.id).collection('comments') + ) + const commenterIds = uniq(comments.map((comment) => comment.userId)) + const betsSnap = await firestore + .collection(`contracts/${contract.id}/bets`) + .get() + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + // filter bets for only users that have an amount invested still + const bettorIds = uniq(bets.map((bet) => bet.userId)) + const liquidityProviders = await firestore + .collection(`contracts/${contract.id}/liquidity`) + .get() + const liquidityProvidersIds = uniq( + liquidityProviders.docs.map((doc) => doc.data().userId) + // exclude free market liquidity provider + ).filter( + (id) => + id !== HOUSE_LIQUIDITY_PROVIDER_ID || + id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID + ) + const followerIds = uniq([ + ...commenterIds, + ...bettorIds, + ...liquidityProvidersIds, + contract.creatorId, + ]) + for (const followerId of followerIds) { + await firestore + .collection(`contracts/${contract.id}/follows`) + .doc(followerId) + .set({ id: followerId, createdTime: Date.now() }) + } + // Perhaps handled by the trigger? + // const followerCount = followerIds.length + // await firestore + // .collection(`contracts`) + // .doc(contract.id) + // .update({ followerCount: followerCount }) + count += 1 + if (count % 100 === 0) { + console.log(`${count} contracts processed`) + } + } +} + +if (require.main === module) { + backfillContractFollowers() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index d9f99de3..0e669f39 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -13,6 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { FieldValue } from 'firebase-admin/firestore' import { redeemShares } from './redeem-shares' +import { removeUserFromContractFollowers } from 'functions/src/follow-market' const bodySchema = z.object({ contractId: z.string(), @@ -123,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) - return { newBet, makers } + return { newBet, makers, maxShares, soldShares } }) + if (result.maxShares === result.soldShares) { + await removeUserFromContractFollowers(contractId, auth.uid) + } const userIds = uniq(result.makers.map((maker) => maker.bet.userId)) await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) log('Share redemption transaction finished.') diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx index 7a839a7a..6d8aa25f 100644 --- a/web/components/NotificationSettings.tsx +++ b/web/components/NotificationSettings.tsx @@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { CheckIcon, XIcon } from '@heroicons/react/outline' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' +import { Col } from 'web/components/layout/col' +import { FollowMarketModal } from 'web/components/contract/follow-market-modal' export function NotificationSettings() { const user = useUser() @@ -17,6 +19,7 @@ export function NotificationSettings() { const [emailNotificationSettings, setEmailNotificationSettings] = useState<notification_subscribe_types>('all') const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) + const [showModal, setShowModal] = useState(false) useEffect(() => { if (user) listenForPrivateUser(user.id, setPrivateUser) @@ -121,12 +124,20 @@ export function NotificationSettings() { } function NotificationSettingLine(props: { - label: string + label: string | React.ReactNode highlight: boolean + onClick?: () => void }) { - const { label, highlight } = props + const { label, highlight, onClick } = props return ( - <Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}> + <Row + className={clsx( + 'my-1 gap-1 text-gray-300', + highlight && '!text-black', + onClick ? 'cursor-pointer' : '' + )} + onClick={onClick} + > {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />} {label} </Row> @@ -148,31 +159,45 @@ export function NotificationSettings() { toggleClassName={'w-24'} /> <div className={'mt-4 text-sm'}> - <div> - <div className={''}> - You will receive notifications for: - <NotificationSettingLine - label={"Resolution of questions you've interacted with"} - highlight={notificationSettings !== 'none'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={'Activity on your own questions, comments, & answers'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Activity on questions you're betting on"} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Income & referral bonuses you've received"} - /> - <NotificationSettingLine - label={"Activity on questions you've ever bet or commented on"} - highlight={notificationSettings === 'all'} - /> - </div> - </div> + <Col className={''}> + <Row className={'my-1'}> + You will receive notifications for these general events: + </Row> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Income & referral bonuses you've received"} + /> + <Row className={'my-1'}> + You will receive new comment, answer, & resolution notifications on + questions: + </Row> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={ + <span> + That <span className={'font-bold'}>you follow </span>- you + auto-follow questions if: + </span> + } + onClick={() => setShowModal(true)} + /> + <Col + className={clsx( + 'mb-2 ml-8', + 'gap-1 text-gray-300', + notificationSettings !== 'none' && '!text-black' + )} + > + <Row>• You create it</Row> + <Row>• You bet, comment on, or answer it</Row> + <Row>• You add liquidity to it</Row> + <Row> + • If you select 'Less' and you've commented on or answered a + question, you'll only receive notification on direct replies to + your comments or answers + </Row> + </Col> + </Col> </div> <div className={'mt-4'}>Email Notifications</div> <ChoicesToggleGroup @@ -205,6 +230,7 @@ export function NotificationSettings() { /> </div> </div> + <FollowMarketModal setOpen={setShowModal} open={showModal} /> </div> ) } diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index bba30776..2aa2d6df 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -22,6 +22,7 @@ import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' import { ShareRow } from './share-row' +import { FollowMarketButton } from 'web/components/follow-market-button' export const ContractOverview = (props: { contract: Contract @@ -44,47 +45,57 @@ export const ContractOverview = (props: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && + !resolution && ( + <div className={'xl:hidden'}> + <FollowMarketButton contract={contract} user={user} /> + </div> + )} + <Row className={'hidden gap-3 xl:flex'}> + <FollowMarketButton contract={contract} user={user} /> - {isBinary && ( - <BinaryResolutionOrChance - className="hidden items-end xl:flex" - contract={contract} - large - /> - )} + {isBinary && ( + <BinaryResolutionOrChance + className="items-end" + contract={contract} + large + /> + )} - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation - contract={contract} - className="hidden items-end xl:flex" - /> - )} + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation + contract={contract} + className="items-end" + /> + )} - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation - contract={contract} - className="hidden items-end xl:flex" - /> - )} + {outcomeType === 'NUMERIC' && ( + <NumericResolutionOrExpectation + contract={contract} + className="items-end" + /> + )} + </Row> </Row> {isBinary ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - - {tradingAllowed(contract) && ( - <BetButton contract={contract as CPMMBinaryContract} /> - )} + <Row className={'items-start gap-2'}> + <FollowMarketButton contract={contract} user={user} /> + {tradingAllowed(contract) && ( + <BetButton contract={contract as CPMMBinaryContract} /> + )} + </Row> </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} - </Row> - ) : isPseudoNumeric ? ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} + <Row className={'items-start gap-2'}> + <FollowMarketButton contract={contract} user={user} /> + {tradingAllowed(contract) && <BetButton contract={contract} />} + </Row> </Row> ) : ( (outcomeType === 'FREE_RESPONSE' || diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/follow-market-modal.tsx new file mode 100644 index 00000000..3dfb7ff4 --- /dev/null +++ b/web/components/contract/follow-market-modal.tsx @@ -0,0 +1,33 @@ +import { Col } from 'web/components/layout/col' +import { Modal } from 'web/components/layout/modal' +import React from 'react' + +export const FollowMarketModal = (props: { + open: boolean + setOpen: (b: boolean) => void + title?: string +}) => { + const { open, setOpen, title } = props + return ( + <Modal open={open} 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">{title ? title : 'Following questions'}</span> + <Col className={'gap-2'}> + <span className={'text-indigo-700'}>• What is following?</span> + <span className={'ml-2'}> + You can receive notifications on questions you're interested in by + clicking the ❤️ button on a question. + </span> + <span className={'text-indigo-700'}> + • What types of notifications will I receive? + </span> + <span className={'ml-2'}> + You'll receive in-app notifications for new comments, answers, and + updates to the question. + </span> + </Col> + </Col> + </Modal> + ) +} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx new file mode 100644 index 00000000..0a8ff4b4 --- /dev/null +++ b/web/components/follow-market-button.tsx @@ -0,0 +1,76 @@ +import { Button } from 'web/components/button' +import { + Contract, + followContract, + unFollowContract, +} from 'web/lib/firebase/contracts' +import toast from 'react-hot-toast' +import { CheckIcon, HeartIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { User } from 'common/user' +import { useContractFollows } from 'web/hooks/use-follows' +import { firebaseLogin, updateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { FollowMarketModal } from 'web/components/contract/follow-market-modal' +import { useState } from 'react' + +export const FollowMarketButton = (props: { + contract: Contract + user: User | undefined | null +}) => { + const { contract, user } = props + const followers = useContractFollows(contract.id) + const [open, setOpen] = useState(false) + + return ( + <Button + size={'lg'} + color={'gray-white'} + onClick={async () => { + if (!user) return firebaseLogin() + if (followers?.includes(user.id)) { + await unFollowContract(contract.id, user.id) + toast('Notifications from this market are now silenced.', { + icon: <CheckIcon className={'text-primary h-5 w-5'} />, + }) + track('Unfollow Market', { + slug: contract.slug, + }) + } else { + await followContract(contract.id, user.id) + toast('You are now following this market!', { + icon: <CheckIcon className={'text-primary h-5 w-5'} />, + }) + track('Follow Market', { + slug: contract.slug, + }) + } + if (!user.hasSeenContractFollowModal) { + await updateUser(user.id, { + hasSeenContractFollowModal: true, + }) + setOpen(true) + } + }} + > + {followers?.includes(user?.id ?? 'nope') ? ( + <HeartIcon + className={clsx('h-6 w-6 fill-red-600 stroke-red-600 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + ) : ( + <HeartIcon + className={clsx('h-6 w-6 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + )} + <FollowMarketModal + open={open} + setOpen={setOpen} + title={`You ${ + followers?.includes(user?.id ?? 'nope') ? 'followed' : 'unfollowed' + } a question!`} + /> + </Button> + ) +} diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts index 2a8caaea..2b418658 100644 --- a/web/hooks/use-follows.ts +++ b/web/hooks/use-follows.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' +import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts' export const useFollows = (userId: string | null | undefined) => { const [followIds, setFollowIds] = useState<string[] | undefined>() @@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => { return followerIds } + +export const useContractFollows = (contractId: string) => { + const [followIds, setFollowIds] = useState<string[] | undefined>() + + useEffect(() => { + return listenForContractFollows(contractId, setFollowIds) + }, [contractId]) + + return followIds +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index ecc4ce2a..32500943 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -147,6 +147,7 @@ export function useUnseenPreferredNotifications( const lessPriorityReasons = [ 'on_contract_with_users_comment', 'on_contract_with_users_answer', + // Notifications not currently generated for users who've sold their shares 'on_contract_with_users_shares_out', // Not sure if users will want to see these w/ less: // 'on_contract_with_users_shares_in', diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 9fe1e59c..fc205b6a 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -212,6 +212,29 @@ export function listenForContract( return listenForValue<Contract>(contractRef, setContract) } +export function listenForContractFollows( + contractId: string, + setFollowIds: (followIds: string[]) => void +) { + const follows = collection(contracts, contractId, 'follows') + return listenForValues<{ id: string }>(follows, (docs) => + setFollowIds(docs.map(({ id }) => id)) + ) +} + +export async function followContract(contractId: string, userId: string) { + const followDoc = doc(collection(contracts, contractId, 'follows'), userId) + return await setDoc(followDoc, { + id: userId, + createdTime: Date.now(), + }) +} + +export async function unFollowContract(contractId: string, userId: string) { + const followDoc = doc(collection(contracts, contractId, 'follows'), userId) + await deleteDoc(followDoc) +} + function chooseRandomSubset(contracts: Contract[], count: number) { const fiveMinutes = 5 * 60 * 1000 const seed = Math.round(Date.now() / fiveMinutes).toString() From 480371cf9f67ffd7eac05690937793608b258e2b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 10:50:55 -0600 Subject: [PATCH 027/279] Fix import --- functions/src/place-bet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 237019a4..404fda50 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -22,7 +22,7 @@ import { LimitBet } from '../../common/bet' import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' -import { addUserToContractFollowers } from 'functions/src/follow-market' +import { addUserToContractFollowers } from './follow-market' const bodySchema = z.object({ contractId: z.string(), From 5dcaae7af6eecb8794261804f9b88cb8b2e7042c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 10:51:21 -0600 Subject: [PATCH 028/279] Fix import --- functions/src/sell-shares.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 0e669f39..0e88a0b5 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -13,7 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { FieldValue } from 'firebase-admin/firestore' import { redeemShares } from './redeem-shares' -import { removeUserFromContractFollowers } from 'functions/src/follow-market' +import { removeUserFromContractFollowers } from './follow-market' const bodySchema = z.object({ contractId: z.string(), From a5812a5a7334b34ec1713fb3ff0cb888d1426936 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:15:38 -0600 Subject: [PATCH 029/279] Remove group chat display --- functions/src/create-user.ts | 19 ----------- web/components/nav/sidebar.tsx | 49 +++++++++++++--------------- web/pages/group/[...slugs]/index.tsx | 15 --------- 3 files changed, 22 insertions(+), 61 deletions(-) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 216a7eb4..fe8b7d77 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -144,24 +144,5 @@ const addUserToDefaultGroups = async (user: User) => { .update({ memberIds: uniq(group.memberIds.concat(user.id)), }) - const manifoldAccount = isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - - if (slug === 'welcome') { - const welcomeCommentDoc = firestore - .collection(`groups/${group.id}/comments`) - .doc() - await welcomeCommentDoc.create({ - id: welcomeCommentDoc.id, - groupId: group.id, - userId: manifoldAccount, - text: `Welcome, @${user.username} aka ${user.name}!`, - createdTime: Date.now(), - userName: 'Manifold Markets', - userUsername: MANIFOLD_USERNAME, - userAvatarUrl: MANIFOLD_AVATAR_URL, - }) - } } } diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 056ab78a..e982cb0e 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,15 +18,14 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React, { useMemo, useState } from 'react' +import React, { useState } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' -import { Group, GROUP_CHAT_SLUG } from 'common/group' +import { Group } from 'common/group' import { Spacer } from '../layout/spacer' -import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { PrivateUser } from 'common/user' import { useWindowSize } from 'web/hooks/use-window-size' import { CHALLENGES_ENABLED } from 'common/challenge' @@ -313,29 +312,29 @@ function GroupsList(props: { memberItems: Item[] privateUser: PrivateUser }) { - const { currentPage, memberItems, privateUser } = props - const preferredNotifications = useUnseenPreferredNotifications( - privateUser, - { - customHref: '/group/', - }, - memberItems.length > 0 ? memberItems.length : undefined - ) + const { currentPage, memberItems } = props const { height } = useWindowSize() const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0) - const notifIsForThisItem = useMemo( - () => (itemHref: string) => - preferredNotifications.some( - (n) => - !n.isSeen && - (n.isSeenOnHref === itemHref || - n.isSeenOnHref?.replace('/chat', '') === itemHref) - ), - [preferredNotifications] - ) + // const preferredNotifications = useUnseenPreferredNotifications( + // privateUser, + // { + // customHref: '/group/', + // }, + // memberItems.length > 0 ? memberItems.length : undefined + // ) + // const notifIsForThisItem = useMemo( + // () => (itemHref: string) => + // preferredNotifications.some( + // (n) => + // !n.isSeen && + // (n.isSeenOnHref === itemHref || + // n.isSeenOnHref?.replace('/chat', '') === itemHref) + // ), + // [preferredNotifications] + // ) return ( <> @@ -351,16 +350,12 @@ function GroupsList(props: { > {memberItems.map((item) => ( <a - href={ - item.href + - (notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '') - } + href={item.href} key={item.name} onClick={trackCallback('sidebar: ' + item.name)} className={clsx( 'cursor-pointer truncate', - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', - notifIsForThisItem(item.href) && 'font-bold' + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900' )} > {item.name} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 8926d0ab..6ce3e7c3 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -33,11 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' -import { useCommentsOnGroup } from 'web/hooks/use-comments' import { ContractSearch } from 'web/components/contract-search' import { FollowList } from 'web/components/follow-list' import { SearchIcon } from '@heroicons/react/outline' -import { useTipTxns } from 'web/hooks/use-tip-txns' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' @@ -46,7 +44,6 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { Button } from 'web/components/button' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { GroupComment } from 'common/comment' -import { GroupChat } from 'web/components/groups/group-chat' import { REFERRAL_AMOUNT } from 'common/economy' export const getStaticProps = fromPropz(getStaticPropz) @@ -149,9 +146,6 @@ export default function GroupPage(props: { const page = slugs?.[1] as typeof groupSubpages[number] const group = useGroup(props.group?.id) ?? props.group - const tips = useTipTxns({ groupId: group?.id }) - - const messages = useCommentsOnGroup(group?.id) ?? props.messages const user = useUser() @@ -201,21 +195,12 @@ export default function GroupPage(props: { /> ) - const chatTab = ( - <GroupChat messages={messages} group={group} user={user} tips={tips} /> - ) - const tabs = [ { title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), }, - { - title: 'Chat', - content: chatTab, - href: groupPath(group.slug, 'chat'), - }, { title: 'Leaderboards', content: leaderboard, From 432ee387ec6fabee3d7860b7427c778805df5a99 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:23:07 -0600 Subject: [PATCH 030/279] Show all groups on sidebar --- web/components/nav/sidebar.tsx | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index e982cb0e..5a5976f2 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -227,8 +227,6 @@ export default function Sidebar(props: { className?: string }) { const currentPage = router.pathname const user = useUser() - const privateUser = usePrivateUser() - // usePing(user?.id) const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user @@ -236,11 +234,9 @@ export default function Sidebar(props: { className?: string }) { : signedInMobileNavigation const memberItems = ( - useMemberGroups( - user?.id, - { withChatEnabled: true }, - { by: 'mostRecentChatActivityTime' } - ) ?? [] + useMemberGroups(user?.id, undefined, { + by: 'mostRecentContractAddedTime', + }) ?? [] ).map((group: Group) => ({ name: group.name, href: `${groupPath(group.slug)}`, @@ -274,13 +270,7 @@ export default function Sidebar(props: { className?: string }) { {memberItems.length > 0 && ( <hr className="!my-4 mr-2 border-gray-300" /> )} - {privateUser && ( - <GroupsList - currentPage={router.asPath} - memberItems={memberItems} - privateUser={privateUser} - /> - )} + <GroupsList currentPage={router.asPath} memberItems={memberItems} /> </div> {/* Desktop navigation */} @@ -295,23 +285,13 @@ export default function Sidebar(props: { className?: string }) { {/* Spacer if there are any groups */} {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />} - {privateUser && ( - <GroupsList - currentPage={router.asPath} - memberItems={memberItems} - privateUser={privateUser} - /> - )} + <GroupsList currentPage={router.asPath} memberItems={memberItems} /> </div> </nav> ) } -function GroupsList(props: { - currentPage: string - memberItems: Item[] - privateUser: PrivateUser -}) { +function GroupsList(props: { currentPage: string; memberItems: Item[] }) { const { currentPage, memberItems } = props const { height } = useWindowSize() From c72bf506c3ab187ebbe6f008ffbf3d18f99f62ee Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:53:29 -0600 Subject: [PATCH 031/279] Heart button on xl style --- web/components/contract/contract-overview.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 2aa2d6df..d2938a2e 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -53,7 +53,9 @@ export const ContractOverview = (props: { </div> )} <Row className={'hidden gap-3 xl:flex'}> - <FollowMarketButton contract={contract} user={user} /> + <div className={'mt-2'}> + <FollowMarketButton contract={contract} user={user} /> + </div> {isBinary && ( <BinaryResolutionOrChance From 3eb1b66e9a3fc1eb61d00fa649a5643d08b0e32a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 11:58:32 -0600 Subject: [PATCH 032/279] Lint --- functions/src/create-user.ts | 13 ++----------- web/components/nav/sidebar.tsx | 3 +-- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index fe8b7d77..35394e90 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -2,13 +2,8 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { uniq } from 'lodash' -import { - MANIFOLD_AVATAR_URL, - MANIFOLD_USERNAME, - PrivateUser, - User, -} from '../../common/user' -import { getUser, getUserByUsername, getValues, isProd } from './utils' +import { PrivateUser, User } from '../../common/user' +import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { cleanDisplayName, @@ -23,10 +18,6 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' -import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, - HOUSE_LIQUIDITY_PROVIDER_ID, -} from '../../common/antes' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' const bodySchema = z.object({ diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 5a5976f2..17fa3bde 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -12,7 +12,7 @@ import { import clsx from 'clsx' import Link from 'next/link' import Router, { useRouter } from 'next/router' -import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { useUser } from 'web/hooks/use-user' import { firebaseLogout, User } from 'web/lib/firebase/users' import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' @@ -26,7 +26,6 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' -import { PrivateUser } from 'common/user' import { useWindowSize } from 'web/hooks/use-window-size' import { CHALLENGES_ENABLED } from 'common/challenge' import { buildArray } from 'common/util/array' From 1c323c5a7fac604d842d9b8dbb37f1ac69954eca Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 12:59:21 -0500 Subject: [PATCH 033/279] Simple recommended contracts based on contract creator --- web/lib/firebase/contracts.ts | 20 ++++++++++++++++++++ web/pages/[username]/[contractSlug].tsx | 20 ++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index fc205b6a..6dc2ee3e 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -295,6 +295,26 @@ export async function getClosingSoonContracts() { return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime) } +export const getRandTopCreatorContracts = async ( + creatorId: string, + count: number, + excluding: string[] = [] +) => { + const creatorContractsQuery = query( + contracts, + where('isResolved', '==', false), + where('creatorId', '==', creatorId), + orderBy('popularityScore', 'desc'), + limit(Math.max(count * 2, 15)) + ) + const data = await getValues<Contract>(creatorContractsQuery) + const open = data + .filter((c) => c.closeTime && c.closeTime > Date.now()) + .filter((c) => !excluding.includes(c.id)) + + return chooseRandomSubset(open, count) +} + export async function getRecentBetsAndComments(contract: Contract) { const contractDoc = doc(contracts, contract.id) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index e62c457e..282df488 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -11,6 +11,7 @@ import { Spacer } from 'web/components/layout/spacer' import { Contract, getContractFromSlug, + getRandTopCreatorContracts, tradingAllowed, } from 'web/lib/firebase/contracts' import { SEO } from 'web/components/SEO' @@ -39,6 +40,8 @@ import { ContractLeaderboard, ContractTopTrades, } from 'web/components/contract/contract-leaderboard' +import { Subtitle } from 'web/components/subtitle' +import { ContractsGrid } from 'web/components/contract/contracts-grid' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -48,9 +51,12 @@ export async function getStaticPropz(props: { const contract = (await getContractFromSlug(contractSlug)) || null const contractId = contract?.id - const [bets, comments] = await Promise.all([ + const [bets, comments, recommendedContracts] = await Promise.all([ contractId ? listAllBets(contractId) : [], contractId ? listAllComments(contractId) : [], + contract + ? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id]) + : [], ]) return { @@ -61,6 +67,7 @@ export async function getStaticPropz(props: { // Limit the data sent to the client. Client will still load all bets and comments directly. bets: bets.slice(0, 5000), comments: comments.slice(0, 1000), + recommendedContracts, }, revalidate: 60, // regenerate after a minute @@ -77,6 +84,7 @@ export default function ContractPage(props: { bets: Bet[] comments: ContractComment[] slug: string + recommendedContracts: Contract[] backToHome?: () => void }) { props = usePropz(props, getStaticPropz) ?? { @@ -84,6 +92,7 @@ export default function ContractPage(props: { username: '', comments: [], bets: [], + recommendedContracts: [], slug: '', } @@ -145,7 +154,7 @@ export function ContractPageContent( user?: User | null } ) { - const { backToHome, comments, user } = props + const { backToHome, comments, user, recommendedContracts } = props const contract = useContractWithPreload(props.contract) ?? props.contract @@ -258,6 +267,13 @@ export function ContractPageContent( tips={tips} comments={comments} /> + + {recommendedContracts?.length > 0 && ( + <Col className="mx-2 gap-2 sm:mx-0"> + <Subtitle text="Recommended" /> + <ContractsGrid contracts={recommendedContracts} /> + </Col> + )} </Col> </Page> ) From d6d1e8e86fd7b82e1a75b27aa6cf3e8621c80b77 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 13:29:35 -0500 Subject: [PATCH 034/279] Fix types --- web/pages/home.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index e61d5c32..3aa791ab 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => { backToHome={() => { history.back() }} + recommendedContracts={[]} /> )} </> From d390b39e0a722417f23ca71f4fe3264ba6fc5b4d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 15:29:48 -0500 Subject: [PATCH 035/279] eliminate fees --- common/fees.ts | 6 +++--- functions/src/emails.ts | 10 +++++----- web/components/bet-panel.tsx | 14 ++++++------- .../contract/contract-info-dialog.tsx | 4 ++-- web/components/resolution-panel.tsx | 20 +++++++++---------- 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/common/fees.ts b/common/fees.ts index 0a537edc..f944933c 100644 --- a/common/fees.ts +++ b/common/fees.ts @@ -1,9 +1,9 @@ export const PLATFORM_FEE = 0 -export const CREATOR_FEE = 0.1 +export const CREATOR_FEE = 0 export const LIQUIDITY_FEE = 0 -export const DPM_PLATFORM_FEE = 0.01 -export const DPM_CREATOR_FEE = 0.04 +export const DPM_PLATFORM_FEE = 0.0 +export const DPM_CREATOR_FEE = 0.0 export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE export type Fees = { diff --git a/functions/src/emails.ts b/functions/src/emails.ts index f90366fa..047e6828 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` - const creatorPayoutText = - userId === creator.id - ? ` (plus ${formatMoney(creatorPayout)} in commissions)` - : '' + // const creatorPayoutText = + // userId === creator.id + // ? ` (plus ${formatMoney(creatorPayout)} in commissions)` + // : '' const emailType = 'market-resolved' const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` @@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async ( question: contract.question, outcome, investment: `${Math.floor(investment)}`, - payout: `${Math.floor(payout)}${creatorPayoutText}`, + payout: `${Math.floor(payout)}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, unsubscribeUrl, } diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 54aa961d..a74442d0 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -9,7 +9,6 @@ import { Row } from './layout/row' import { Spacer } from './layout/spacer' import { formatMoney, - formatMoneyWithDecimals, formatPercent, formatWithCommas, } from 'common/util/format' @@ -18,7 +17,6 @@ import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' import { APIError, placeBet, sellShares } from 'web/lib/firebase/api' import { AmountInput, BuyAmountInput } from './amount-input' -import { InfoTooltip } from './info-tooltip' import { BinaryOutcomeLabel, HigherLabel, @@ -346,9 +344,9 @@ function BuyPanel(props: { </> )} </div> - <InfoTooltip + {/* <InfoTooltip text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`} - /> + /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> @@ -665,9 +663,9 @@ function LimitOrderPanel(props: { </> )} </div> - <InfoTooltip + {/* <InfoTooltip text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`} - /> + /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> @@ -689,9 +687,9 @@ function LimitOrderPanel(props: { </> )} </div> - <InfoTooltip + {/* <InfoTooltip text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`} - /> + /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 63c9ac72..29746c65 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -110,10 +110,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <td>{formatMoney(contract.volume)}</td> </tr> - <tr> + {/* <tr> <td>Creator earnings</td> <td>{formatMoney(contract.collectedFees.creatorFee)}</td> - </tr> + </tr> */} <tr> <td>Traders</td> diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 7bb9f2d4..fe062d06 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' import { APIError, resolveMarket } from 'web/lib/firebase/api' import { ProbabilitySelector } from './probability-selector' -import { DPM_CREATOR_FEE } from 'common/fees' import { getProbability } from 'common/calculate' import { BinaryContract, resolution } from 'common/contract' -import { formatMoney } from 'common/util/format' export function ResolutionPanel(props: { creator: User @@ -20,10 +18,10 @@ export function ResolutionPanel(props: { }) { const { contract, className } = props - const earnedFees = - contract.mechanism === 'dpm-2' - ? `${DPM_CREATOR_FEE * 100}% of trader profits` - : `${formatMoney(contract.collectedFees.creatorFee)} in fees` + // const earnedFees = + // contract.mechanism === 'dpm-2' + // ? `${DPM_CREATOR_FEE * 100}% of trader profits` + // : `${formatMoney(contract.collectedFees.creatorFee)} in fees` const [outcome, setOutcome] = useState<resolution | undefined>() @@ -86,16 +84,16 @@ export function ResolutionPanel(props: { {outcome === 'YES' ? ( <> Winnings will be paid out to YES bettors. + {/* <br /> <br /> - <br /> - You will earn {earnedFees}. + You will earn {earnedFees}. */} </> ) : outcome === 'NO' ? ( <> Winnings will be paid out to NO bettors. + {/* <br /> <br /> - <br /> - You will earn {earnedFees}. + You will earn {earnedFees}. */} </> ) : outcome === 'CANCEL' ? ( <>All trades will be returned with no fees.</> @@ -106,7 +104,7 @@ export function ResolutionPanel(props: { probabilityInt={Math.round(prob)} setProbabilityInt={setProb} /> - You will earn {earnedFees}. + {/* You will earn {earnedFees}. */} </Col> ) : ( <>Resolving this market will immediately pay out traders.</> From de74b2987a2255fb9410039932040e3e37b644ea Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 15:34:00 -0500 Subject: [PATCH 036/279] eslint --- web/components/bet-panel.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index a74442d0..8e7f4999 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -259,8 +259,6 @@ function BuyPanel(props: { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) - const totalFees = sum(Object.values(newBet.fees)) - const format = getFormattedMappedValue(contract) const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) From d5ac560f0c3f3c90f3cbdd6c178bab74ff940def Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 15:36:57 -0500 Subject: [PATCH 037/279] eslint --- web/components/bet-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 8e7f4999..03bd3898 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' -import { clamp, partition, sum, sumBy } from 'lodash' +import { clamp, partition, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' From 5365fa6175b7a94f28642ba811a8bbd8796650c4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 15:09:28 -0600 Subject: [PATCH 038/279] =?UTF-8?q?=F0=9F=92=94=F0=9F=92=94=F0=9F=92=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/contract/contract-overview.tsx | 26 +++-------------- web/components/contract/share-row.tsx | 2 ++ web/components/follow-market-button.tsx | 29 ++++++++++++------- 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index d2938a2e..797921bf 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -22,7 +22,6 @@ import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' import { ShareRow } from './share-row' -import { FollowMarketButton } from 'web/components/follow-market-button' export const ContractOverview = (props: { contract: Contract @@ -45,18 +44,7 @@ export const ContractOverview = (props: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> - {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && - !resolution && ( - <div className={'xl:hidden'}> - <FollowMarketButton contract={contract} user={user} /> - </div> - )} <Row className={'hidden gap-3 xl:flex'}> - <div className={'mt-2'}> - <FollowMarketButton contract={contract} user={user} /> - </div> - {isBinary && ( <BinaryResolutionOrChance className="items-end" @@ -84,20 +72,14 @@ export const ContractOverview = (props: { {isBinary ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - <Row className={'items-start gap-2'}> - <FollowMarketButton contract={contract} user={user} /> - {tradingAllowed(contract) && ( - <BetButton contract={contract as CPMMBinaryContract} /> - )} - </Row> + {tradingAllowed(contract) && ( + <BetButton contract={contract as CPMMBinaryContract} /> + )} </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - <Row className={'items-start gap-2'}> - <FollowMarketButton contract={contract} user={user} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} - </Row> + {tradingAllowed(contract) && <BetButton contract={contract} />} </Row> ) : ( (outcomeType === 'FREE_RESPONSE' || diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 9011ff1b..0aa0c6b0 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -10,6 +10,7 @@ import { User } from 'common/user' import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' import { withTracking } from 'web/lib/service/analytics' +import { FollowMarketButton } from 'web/components/follow-market-button' export function ShareRow(props: { contract: Contract @@ -62,6 +63,7 @@ export function ShareRow(props: { /> </Button> )} + <FollowMarketButton contract={contract} user={user} /> </Row> ) } diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 0a8ff4b4..026902f3 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -5,7 +5,7 @@ import { unFollowContract, } from 'web/lib/firebase/contracts' import toast from 'react-hot-toast' -import { CheckIcon, HeartIcon } from '@heroicons/react/outline' +import { CheckIcon, EyeIcon, EyeOffIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { User } from 'common/user' import { useContractFollows } from 'web/hooks/use-follows' @@ -13,6 +13,7 @@ import { firebaseLogin, updateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' import { FollowMarketModal } from 'web/components/contract/follow-market-modal' import { useState } from 'react' +import { Row } from 'web/components/layout/row' export const FollowMarketButton = (props: { contract: Contract @@ -30,7 +31,7 @@ export const FollowMarketButton = (props: { if (!user) return firebaseLogin() if (followers?.includes(user.id)) { await unFollowContract(contract.id, user.id) - toast('Notifications from this market are now silenced.', { + toast("You'll no longer receive notifications from this market", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) track('Unfollow Market', { @@ -38,7 +39,7 @@ export const FollowMarketButton = (props: { }) } else { await followContract(contract.id, user.id) - toast('You are now following this market!', { + toast("You'll now receive notifications from this market!", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) track('Follow Market', { @@ -54,15 +55,21 @@ export const FollowMarketButton = (props: { }} > {followers?.includes(user?.id ?? 'nope') ? ( - <HeartIcon - className={clsx('h-6 w-6 fill-red-600 stroke-red-600 xl:h-7 xl:w-7')} - aria-hidden="true" - /> + <Row className={'gap-2'}> + <EyeOffIcon + className={clsx('h-6 w-6 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + Unfollow + </Row> ) : ( - <HeartIcon - className={clsx('h-6 w-6 xl:h-7 xl:w-7')} - aria-hidden="true" - /> + <Row className={'gap-2'}> + <EyeIcon + className={clsx('h-6 w-6 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + Follow + </Row> )} <FollowMarketModal open={open} From d553aae71eae510747a11e18dcc7510dcc76f1bf Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 15:11:38 -0600 Subject: [PATCH 039/279] Shrink icon --- web/components/follow-market-button.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 026902f3..44f4d4be 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -56,19 +56,13 @@ export const FollowMarketButton = (props: { > {followers?.includes(user?.id ?? 'nope') ? ( <Row className={'gap-2'}> - <EyeOffIcon - className={clsx('h-6 w-6 xl:h-7 xl:w-7')} - aria-hidden="true" - /> - Unfollow + <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Unwatch </Row> ) : ( <Row className={'gap-2'}> - <EyeIcon - className={clsx('h-6 w-6 xl:h-7 xl:w-7')} - aria-hidden="true" - /> - Follow + <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> + Watch </Row> )} <FollowMarketModal From 52a89d07836954b56bd92c4a4c6583b14258d968 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 15:42:09 -0600 Subject: [PATCH 040/279] Remove bolded More from navbar --- web/components/nav/nav-bar.tsx | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 680b8946..5a81f566 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -9,7 +9,7 @@ import { import { Transition, Dialog } from '@headlessui/react' import { useState, Fragment } from 'react' import Sidebar, { Item } from './sidebar' -import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import clsx from 'clsx' @@ -17,8 +17,6 @@ import { useRouter } from 'next/router' import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' -import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' -import { PrivateUser } from 'common/user' function getNavigation() { return [ @@ -44,7 +42,6 @@ export function BottomNavBar() { const currentPage = router.pathname const user = useUser() - const privateUser = usePrivateUser() const isIframe = useIsIframe() if (isIframe) { @@ -85,11 +82,7 @@ export function BottomNavBar() { onClick={() => setSidebarOpen(true)} > <MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" /> - {privateUser ? ( - <MoreMenuWithGroupNotifications privateUser={privateUser} /> - ) : ( - 'More' - )} + More </div> <MobileSidebar @@ -100,22 +93,6 @@ export function BottomNavBar() { ) } -function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) { - const { privateUser } = props - const preferredNotifications = useUnseenPreferredNotifications(privateUser, { - customHref: '/group/', - }) - return ( - <span - className={ - preferredNotifications.length > 0 ? 'font-bold' : 'font-normal' - } - > - More - </span> - ) -} - function NavBarItem(props: { item: Item; currentPage: string }) { const { item, currentPage } = props const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) From 74a0479cbdcb00c2e0408e5acace8275e73362d5 Mon Sep 17 00:00:00 2001 From: SirSaltyy <104849031+SirSaltyy@users.noreply.github.com> Date: Thu, 25 Aug 2022 06:51:33 +0900 Subject: [PATCH 041/279] Change about button (#796) About button name change and now directs to "Help and About Center" super.so --- web/components/nav/sidebar.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 17fa3bde..c3df3579 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -93,7 +93,7 @@ function getMoreNavigation(user?: User | null) { href: 'https://salemcenter.manifold.markets/', }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, - { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, + { name: 'Help & About', href: 'https://help.manifold.markets/' }, { name: 'Sign out', href: '#', @@ -107,16 +107,16 @@ const signedOutNavigation = [ { name: 'Home', href: '/', icon: HomeIcon }, { name: 'Explore', href: '/home', icon: SearchIcon }, { - name: 'About', - href: 'https://docs.manifold.markets/$how-to', + name: 'Help & About', + href: 'https://help.manifold.markets/', icon: BookOpenIcon, }, ] const signedOutMobileNavigation = [ { - name: 'About', - href: 'https://docs.manifold.markets/$how-to', + name: 'Help & About', + href: 'https://help.manifold.markets/', icon: BookOpenIcon, }, { name: 'Charity', href: '/charity', icon: HeartIcon }, @@ -130,8 +130,8 @@ const signedInMobileNavigation = [ ? [] : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), { - name: 'About', - href: 'https://docs.manifold.markets/$how-to', + name: 'Help & About', + href: 'https://help.manifold.markets/', icon: BookOpenIcon, }, ] From 5bf135760e88d29da39cdbd5e0931febad988394 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 17:23:26 -0500 Subject: [PATCH 042/279] fix sidebar tracking --- web/components/nav/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index c3df3579..e16a502e 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -331,7 +331,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { <a href={item.href} key={item.name} - onClick={trackCallback('sidebar: ' + item.name)} + onClick={trackCallback('click sidebar group', { name: item.name })} className={clsx( 'cursor-pointer truncate', 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900' From b6e636cbc04431b4e3723e4d40b61e600460d20f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 16:41:46 -0600 Subject: [PATCH 043/279] Small ux tweaks for signed out market page --- web/components/amount-input.tsx | 5 +- web/components/bet-button.tsx | 36 +++++----- web/components/contract/contract-details.tsx | 67 ++++++++++++++----- web/components/contract/contract-overview.tsx | 10 ++- web/components/contract/share-row.tsx | 36 +++++----- 5 files changed, 105 insertions(+), 49 deletions(-) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index cb071850..971a5496 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format' import { Col } from './layout/col' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' +import { useWindowSize } from 'web/hooks/use-window-size' export function AmountInput(props: { amount: number | undefined @@ -33,7 +34,8 @@ export function AmountInput(props: { const isInvalid = !str || isNaN(amount) onChange(isInvalid ? undefined : amount) } - + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 768 return ( <Col className={className}> <label className="input-group mb-4"> @@ -50,6 +52,7 @@ export function AmountInput(props: { inputMode="numeric" placeholder="0" maxLength={6} + autoFocus={!isMobile} value={amount ?? ''} disabled={disabled} onChange={(e) => onAmountChange(e.target.value)} diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index d7d62e7d..2aca1772 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' +import { Button } from 'web/components/button' +import { firebaseLogin } from 'web/lib/firebase/users' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -30,23 +32,27 @@ export default function BetButton(props: { return ( <> <Col className={clsx('items-center', className)}> - <button - className={clsx( - 'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24', - btnClassName - )} - onClick={() => setOpen(true)} + <Button + size={'lg'} + className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} + onClick={() => { + !user ? firebaseLogin() : setOpen(true) + }} > - Bet - </button> + {user ? 'Bet' : 'Sign up to Bet'} + </Button> - <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> - {hasYesShares - ? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})` - : hasNoShares - ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` - : ''} - </div> + {user && ( + <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> + {hasYesShares + ? `(${Math.floor(yesShares)} ${ + isPseudoNumeric ? 'HIGHER' : 'YES' + })` + : hasNoShares + ? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})` + : ''} + </div> + )} </Col> <Modal open={open} setOpen={setOpen}> diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 833b37eb..781cea59 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,7 +1,9 @@ import { ClockIcon, DatabaseIcon, + LinkIcon, PencilIcon, + ShareIcon, TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' @@ -9,7 +11,11 @@ import { import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' -import { Contract, updateContract } from 'web/lib/firebase/contracts' +import { + Contract, + contractPath, + updateContract, +} from 'web/lib/firebase/contracts' import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -32,6 +38,11 @@ import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import clsx from 'clsx' import { contractMetrics } from 'common/contract-details' +import { User } from 'common/user' +import { copyToClipboard } from 'web/lib/util/copy' +import toast from 'react-hot-toast' +import { track } from 'web/lib/service/analytics' +import { ENV_CONFIG } from 'common/envs/constants' export type ShowTime = 'resolve-date' | 'close-date' @@ -134,6 +145,7 @@ export function AbbrContractDetails(props: { export function ContractDetails(props: { contract: Contract bets: Bet[] + user: User | null | undefined isCreator?: boolean disabled?: boolean }) { @@ -146,7 +158,11 @@ export function ContractDetails(props: { groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null const user = useUser() const [open, setOpen] = useState(false) - + const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ + user?.username && contract.creatorUsername !== user?.username + ? '?referrer=' + user?.username + : '' + }` const groupInfo = ( <Row> <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> @@ -157,7 +173,7 @@ export function ContractDetails(props: { ) return ( - <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> + <Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2"> <Row className="items-center gap-2"> <Avatar username={creatorUsername} @@ -179,6 +195,8 @@ export function ContractDetails(props: { <Row> {disabled ? ( groupInfo + ) : !groupToDisplay && !user ? ( + <div /> ) : ( <Button size={'xs'} @@ -203,13 +221,30 @@ export function ContractDetails(props: { /> </Col> </Modal> - + {!user && ( + <Row className={'items-center justify-end'}> + <Button + size="xs" + color="gray-white" + className={'flex'} + onClick={() => { + copyToClipboard(shareUrl) + toast('Link copied!', { + icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, + }) + track('copy share link') + }} + > + <ShareIcon className={clsx('mr-2 h-5 w-5')} aria-hidden="true" /> + Share + </Button> + </Row> + )} {(!!closeTime || !!resolvedDate) && ( <Row className="items-center gap-1"> - <ClockIcon className="h-5 w-5" /> - {resolvedDate && contract.resolutionTime ? ( <> + <ClockIcon className="h-5 w-5" /> <DateTimeTooltip text="Market resolved:" time={dayjs(contract.resolutionTime)} @@ -219,8 +254,9 @@ export function ContractDetails(props: { </> ) : null} - {!resolvedDate && closeTime && ( + {!resolvedDate && closeTime && user && ( <> + <ClockIcon className="h-5 w-5" /> <EditableCloseDate closeTime={closeTime} contract={contract} @@ -230,14 +266,15 @@ export function ContractDetails(props: { )} </Row> )} - - <Row className="items-center gap-1"> - <DatabaseIcon className="h-5 w-5" /> - - <div className="whitespace-nowrap">{volumeLabel}</div> - </Row> - - {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} + {user && ( + <> + <Row className="items-center gap-1"> + <DatabaseIcon className="h-5 w-5" /> + <div className="whitespace-nowrap">{volumeLabel}</div> + </Row> + {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} + </> + )} </Row> ) } diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 797921bf..4676f796 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -73,7 +73,14 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> {tradingAllowed(contract) && ( - <BetButton contract={contract as CPMMBinaryContract} /> + <Col> + <BetButton contract={contract as CPMMBinaryContract} /> + {!user && ( + <div className="text-sm text-gray-500"> + (Don't worry, it's play money!) + </div> + )} + </Col> )} </Row> ) : isPseudoNumeric ? ( @@ -102,6 +109,7 @@ export const ContractOverview = (props: { contract={contract} bets={bets} isCreator={isCreator} + user={user} /> </Col> <Spacer h={4} /> diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 0aa0c6b0..b4c3b4f3 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -27,23 +27,25 @@ export function ShareRow(props: { return ( <Row className="mt-2"> - <Button - size="lg" - color="gray-white" - className={'flex'} - onClick={() => { - setShareOpen(true) - }} - > - <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> - Share - <ShareModal - isOpen={isShareOpen} - setOpen={setShareOpen} - contract={contract} - user={user} - /> - </Button> + {user && ( + <Button + size="lg" + color="gray-white" + className={'flex'} + onClick={() => { + setShareOpen(true) + }} + > + <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> + Share + <ShareModal + isOpen={isShareOpen} + setOpen={setShareOpen} + contract={contract} + user={user} + /> + </Button> + )} {showChallenge && ( <Button From 8d1cebf4db84a1df9947dae8578c03a5ab763771 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:07:22 -0600 Subject: [PATCH 044/279] Move share button back down, small spacing tweaks --- web/components/contract/contract-details.tsx | 38 ++----------------- web/components/contract/contract-overview.tsx | 5 +-- web/components/contract/share-row.tsx | 36 +++++++++--------- web/pages/embed/[username]/[contractSlug].tsx | 1 + 4 files changed, 23 insertions(+), 57 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 781cea59..7b6a6277 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -1,9 +1,7 @@ import { ClockIcon, DatabaseIcon, - LinkIcon, PencilIcon, - ShareIcon, TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' @@ -11,11 +9,7 @@ import { import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' -import { - Contract, - contractPath, - updateContract, -} from 'web/lib/firebase/contracts' +import { Contract, updateContract } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -39,10 +33,6 @@ import { insertContent } from '../editor/utils' import clsx from 'clsx' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' -import { copyToClipboard } from 'web/lib/util/copy' -import toast from 'react-hot-toast' -import { track } from 'web/lib/service/analytics' -import { ENV_CONFIG } from 'common/envs/constants' export type ShowTime = 'resolve-date' | 'close-date' @@ -158,11 +148,7 @@ export function ContractDetails(props: { groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null const user = useUser() const [open, setOpen] = useState(false) - const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${ - user?.username && contract.creatorUsername !== user?.username - ? '?referrer=' + user?.username - : '' - }` + const groupInfo = ( <Row> <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> @@ -221,25 +207,7 @@ export function ContractDetails(props: { /> </Col> </Modal> - {!user && ( - <Row className={'items-center justify-end'}> - <Button - size="xs" - color="gray-white" - className={'flex'} - onClick={() => { - copyToClipboard(shareUrl) - toast('Link copied!', { - icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />, - }) - track('copy share link') - }} - > - <ShareIcon className={clsx('mr-2 h-5 w-5')} aria-hidden="true" /> - Share - </Button> - </Row> - )} + {(!!closeTime || !!resolvedDate) && ( <Row className="items-center gap-1"> {resolvedDate && contract.resolutionTime ? ( diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 4676f796..cebde4d6 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -3,7 +3,6 @@ import clsx from 'clsx' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' -import { Spacer } from '../layout/spacer' import { ContractProbGraph } from './contract-prob-graph' import { useUser } from 'web/hooks/use-user' import { Row } from '../layout/row' @@ -76,7 +75,7 @@ export const ContractOverview = (props: { <Col> <BetButton contract={contract as CPMMBinaryContract} /> {!user && ( - <div className="text-sm text-gray-500"> + <div className="mt-1 text-sm text-gray-500"> (Don't worry, it's play money!) </div> )} @@ -112,7 +111,7 @@ export const ContractOverview = (props: { user={user} /> </Col> - <Spacer h={4} /> + <div className={'my-1 md:my-2'}></div> {(isBinary || isPseudoNumeric) && ( <ContractProbGraph contract={contract} bets={bets} /> )}{' '} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index b4c3b4f3..0aa0c6b0 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -27,25 +27,23 @@ export function ShareRow(props: { return ( <Row className="mt-2"> - {user && ( - <Button - size="lg" - color="gray-white" - className={'flex'} - onClick={() => { - setShareOpen(true) - }} - > - <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> - Share - <ShareModal - isOpen={isShareOpen} - setOpen={setShareOpen} - contract={contract} - user={user} - /> - </Button> - )} + <Button + size="lg" + color="gray-white" + className={'flex'} + onClick={() => { + setShareOpen(true) + }} + > + <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> + Share + <ShareModal + isOpen={isShareOpen} + setOpen={setShareOpen} + contract={contract} + user={user} + /> + </Button> {showChallenge && ( <Button diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 83d83871..7ec8daeb 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { contract={contract} bets={bets} isCreator={false} + user={null} disabled /> From 7a22c7d76ab9aee258830f91334b4673942d2251 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:09:07 -0600 Subject: [PATCH 045/279] Gap adjustment --- web/components/contract/contract-overview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index cebde4d6..6103fee7 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -38,7 +38,7 @@ export const ContractOverview = (props: { return ( <Col className={clsx('mb-6', className)}> - <Col className="gap-4 px-2"> + <Col className="gap-3 px-2 sm:gap-4"> <Row className="justify-between gap-4"> <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> From 7a38d67c5b1fa2fce1e2db8c18c4f8bd47f67f0d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:11:48 -0600 Subject: [PATCH 046/279] Reduce share row top margin on mobile --- web/components/contract/share-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 0aa0c6b0..1af52291 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -26,7 +26,7 @@ export function ShareRow(props: { const [isShareOpen, setShareOpen] = useState(false) return ( - <Row className="mt-2"> + <Row className="mt-0.5 sm:mt-2"> <Button size="lg" color="gray-white" From a8da5719fe5759504f5ca53f00d76c0c7bf2edc8 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 18:30:29 -0500 Subject: [PATCH 047/279] Create experimental home page --- web/components/contract-search.tsx | 17 ++- web/components/contract/contracts-grid.tsx | 10 +- web/pages/experimental/home.tsx | 118 +++++++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 web/pages/experimental/home.tsx diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 56bc965d..34e1ff0d 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -91,6 +91,8 @@ export function ContractSearch(props: { useQuerySortLocalStorage?: boolean useQuerySortUrlParams?: boolean isWholePage?: boolean + maxItems?: number + noControls?: boolean }) { const { user, @@ -105,6 +107,8 @@ export function ContractSearch(props: { useQuerySortLocalStorage, useQuerySortUrlParams, isWholePage, + maxItems, + noControls, } = props const [numPages, setNumPages] = useState(1) @@ -158,6 +162,8 @@ export function ContractSearch(props: { const contracts = pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) + const renderedContracts = + pages.length === 0 ? undefined : contracts.slice(0, maxItems) if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return <ContractSearchFirestore additionalFilter={additionalFilter} /> @@ -175,10 +181,11 @@ export function ContractSearch(props: { useQuerySortUrlParams={useQuerySortUrlParams} user={user} onSearchParametersChanged={onSearchParametersChanged} + noControls={noControls} /> <ContractsGrid - contracts={pages.length === 0 ? undefined : contracts} - loadMore={performQuery} + contracts={renderedContracts} + loadMore={noControls ? undefined : performQuery} showTime={showTime} onContractClick={onContractClick} highlightOptions={highlightOptions} @@ -198,6 +205,7 @@ function ContractSearchControls(props: { useQuerySortLocalStorage?: boolean useQuerySortUrlParams?: boolean user?: User | null + noControls?: boolean }) { const { className, @@ -209,6 +217,7 @@ function ContractSearchControls(props: { useQuerySortLocalStorage, useQuerySortUrlParams, user, + noControls, } = props const savedSort = useQuerySortLocalStorage ? getSavedSort() : null @@ -329,6 +338,10 @@ function ContractSearchControls(props: { }) }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) + if (noControls) { + return <></> + } + return ( <Col className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)} diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index f7b7eeac..603173f6 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -86,10 +86,12 @@ export function ContractsGrid(props: { /> ))} </Masonry> - <VisibilityObserver - onVisibilityUpdated={onVisibilityUpdated} - className="relative -top-96 h-1" - /> + {loadMore && ( + <VisibilityObserver + onVisibilityUpdated={onVisibilityUpdated} + className="relative -top-96 h-1" + /> + )} </Col> ) } diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx new file mode 100644 index 00000000..380f4286 --- /dev/null +++ b/web/pages/experimental/home.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { useRouter } from 'next/router' +import { PlusSmIcon } from '@heroicons/react/solid' + +import { Page } from 'web/components/page' +import { Col } from 'web/components/layout/col' +import { ContractSearch } from 'web/components/contract-search' +import { User } from 'common/user' +import { getUserAndPrivateUser } 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/hooks/use-sort-and-query-params' +import { Button } from 'web/components/button' +import { Spacer } from 'web/components/layout/spacer' +import { useMemberGroups } from 'web/hooks/use-group' +import { Group } from 'common/group' +import { Title } from 'web/components/title' + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const creds = await authenticateOnServer(ctx) + const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null + return { props: { auth } } +} + +const Home = (props: { auth: { user: User } | null }) => { + const user = props.auth ? props.auth.user : null + + const router = useRouter() + useTracking('view home') + + useSaveReferral() + + const memberGroups = (useMemberGroups(user?.id) ?? []).filter( + (group) => group.contractIds.length > 0 + ) + + return ( + <Page> + <Col className="mx-auto mb-8 w-full"> + <SearchSection label="Trending" sort="score" user={user} /> + <SearchSection label="Newest" sort="newest" user={user} /> + <SearchSection label="Closing soon" sort="close-date" user={user} /> + {memberGroups.map((group) => ( + <GroupSection key={group.id} group={group} user={user} /> + ))} + </Col> + <button + type="button" + className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" + onClick={() => { + router.push('/create') + track('mobile create button') + }} + > + <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> + </button> + </Page> + ) +} + +function SearchSection(props: { + label: string + user: User | null + sort: Sort +}) { + const { label, user, sort } = props + + const router = useRouter() + + return ( + <Col> + <Title className="mx-2 !text-gray-800 sm:mx-0" text={label} /> + <Spacer h={2} /> + <ContractSearch user={user} defaultSort={sort} maxItems={4} noControls /> + <Button + className="self-end" + color="blue" + size="sm" + onClick={() => router.push(`/home?s=${sort}`)} + > + See more + </Button> + </Col> + ) +} + +function GroupSection(props: { group: Group; user: User | null }) { + const { group, user } = props + + const router = useRouter() + + return ( + <Col className=""> + <Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} /> + <Spacer h={2} /> + <ContractSearch + user={user} + defaultSort={'score'} + additionalFilter={{ groupSlug: group.slug }} + maxItems={4} + noControls + /> + <Button + className="mr-2 self-end" + color="blue" + size="sm" + onClick={() => router.push(`/group/${group.slug}`)} + > + See more + </Button> + </Col> + ) +} + +export default Home From bca34dad6081e02cc6e1c1ffe74f8b22a125c5ea Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:31:35 -0600 Subject: [PATCH 048/279] Set max betting bonus to M --- common/economy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/economy.ts b/common/economy.ts index cd40f87c..16f1eb77 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -12,5 +12,5 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500 export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_AMOUNT = econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5 -export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 100 +export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 From 535e50eeac6d354d56b3b2ec7592812062ee6649 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 24 Aug 2022 17:40:03 -0600 Subject: [PATCH 049/279] Betting streak bonus per day:10, max:50 --- common/economy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/economy.ts b/common/economy.ts index 16f1eb77..8db4a7b9 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -11,6 +11,6 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500 export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_AMOUNT = - econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5 -export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25 + econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 +export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 From 25eca718463250a55d4862053627d11bcd23a78e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 21:16:38 -0500 Subject: [PATCH 050/279] Convert heart to eye and follow to watch --- web/components/NotificationSettings.tsx | 4 ++-- web/components/contract/follow-market-modal.tsx | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx index 6d8aa25f..7ee27fb5 100644 --- a/web/components/NotificationSettings.tsx +++ b/web/components/NotificationSettings.tsx @@ -175,8 +175,8 @@ export function NotificationSettings() { highlight={notificationSettings !== 'none'} label={ <span> - That <span className={'font-bold'}>you follow </span>- you - auto-follow questions if: + That <span className={'font-bold'}>you watch </span>- you + auto-watch questions if: </span> } onClick={() => setShowModal(true)} diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/follow-market-modal.tsx index 3dfb7ff4..fb62ce9f 100644 --- a/web/components/contract/follow-market-modal.tsx +++ b/web/components/contract/follow-market-modal.tsx @@ -1,6 +1,8 @@ import { Col } from 'web/components/layout/col' import { Modal } from 'web/components/layout/modal' +import { EyeIcon } from '@heroicons/react/outline' import React from 'react' +import clsx from 'clsx' export const FollowMarketModal = (props: { open: boolean @@ -11,13 +13,18 @@ export const FollowMarketModal = (props: { return ( <Modal open={open} 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">{title ? title : 'Following questions'}</span> + <EyeIcon className={clsx('h-20 w-20')} aria-hidden="true" /> + <span className="text-xl">{title ? title : 'Watching questions'}</span> <Col className={'gap-2'}> - <span className={'text-indigo-700'}>• What is following?</span> + <span className={'text-indigo-700'}>• What is watching?</span> <span className={'ml-2'}> You can receive notifications on questions you're interested in by - clicking the ❤️ button on a question. + clicking the + <EyeIcon + className={clsx('ml-1 inline h-6 w-6 align-top')} + aria-hidden="true" + /> + ️ button on a question. </span> <span className={'text-indigo-700'}> • What types of notifications will I receive? From 0caa5e24e8605b6f7750851d87ed143a91242a5a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 21:23:12 -0500 Subject: [PATCH 051/279] Some other follow to watch changes --- web/components/follow-market-button.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 44f4d4be..45d26ce4 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -34,7 +34,7 @@ export const FollowMarketButton = (props: { toast("You'll no longer receive notifications from this market", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) - track('Unfollow Market', { + track('Unwatch Market', { slug: contract.slug, }) } else { @@ -42,7 +42,7 @@ export const FollowMarketButton = (props: { toast("You'll now receive notifications from this market!", { icon: <CheckIcon className={'text-primary h-5 w-5'} />, }) - track('Follow Market', { + track('Watch Market', { slug: contract.slug, }) } @@ -69,7 +69,7 @@ export const FollowMarketButton = (props: { open={open} setOpen={setOpen} title={`You ${ - followers?.includes(user?.id ?? 'nope') ? 'followed' : 'unfollowed' + followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched' } a question!`} /> </Button> From cffd5dcd317d0d196eda52327d16205d3d6dadd2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 22:03:07 -0500 Subject: [PATCH 052/279] Weekly => daily loans --- common/loans.ts | 4 ++-- functions/src/update-loans.ts | 14 +++++++++----- web/components/profile/loans-modal.tsx | 15 ++++++++------- web/pages/notifications.tsx | 2 +- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/common/loans.ts b/common/loans.ts index 46c491b5..cb956c09 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -10,11 +10,11 @@ import { import { PortfolioMetrics, User } from './user' import { filterDefined } from './util/array' -const LOAN_WEEKLY_RATE = 0.05 +const LOAN_DAILY_RATE = 0.01 const calculateNewLoan = (investedValue: number, loanTotal: number) => { const netValue = investedValue - loanTotal - return netValue * LOAN_WEEKLY_RATE + return netValue * LOAN_DAILY_RATE } export const getLoanUpdates = ( diff --git a/functions/src/update-loans.ts b/functions/src/update-loans.ts index fd89b643..770315fd 100644 --- a/functions/src/update-loans.ts +++ b/functions/src/update-loans.ts @@ -12,8 +12,8 @@ const firestore = admin.firestore() export const updateLoans = functions .runWith({ memory: '2GB', timeoutSeconds: 540 }) - // Run every Monday. - .pubsub.schedule('0 0 * * 1') + // Run every day at midnight. + .pubsub.schedule('0 0 * * *') .timeZone('America/Los_Angeles') .onRun(updateLoansCore) @@ -79,9 +79,13 @@ async function updateLoansCore() { const today = new Date().toDateString().replace(' ', '-') const key = `loan-notifications-${today}` await Promise.all( - userPayouts.map(({ user, payout }) => - createLoanIncomeNotification(user, key, payout) - ) + userPayouts + // Don't send a notification if the payout is < M$1, + // because a M$0 loan is confusing. + .filter(({ payout }) => payout >= 1) + .map(({ user, payout }) => + createLoanIncomeNotification(user, key, payout) + ) ) log('Notifications sent!') diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index c8d30b4e..945fb6fe 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -11,11 +11,12 @@ 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">Loans on your bets</span> + <span className="text-xl">Daily loans on your bets</span> <Col className={'gap-2'}> - <span className={'text-indigo-700'}>• What are loans?</span> + <span className={'text-indigo-700'}>• What are daily loans?</span> <span className={'ml-2'}> - Every Monday, get 5% of your total bet amount back as a loan. + Every day at midnight PT, get 1% of your total bet amount back as a + loan. </span> <span className={'text-indigo-700'}> • Do I have to pay back a loan? @@ -33,12 +34,12 @@ export function LoansModal(props: { </span> <span className={'text-indigo-700'}>• What is an example?</span> <span className={'ml-2'}> - For example, if you bet M$100 on "Will I become a millionare?" on - Sunday, you will get M$5 back on Monday. + For example, if you bet M$1000 on "Will I become a millionare?" on + Monday, you will get M$10 back on Tuesday. </span> <span className={'ml-2'}> - Previous loans count against your total bet amount. So, the next - week, you would get back 5% of M$95 = M$4.75. + Previous loans count against your total bet amount. So on Wednesday, + you would get back 1% of M$990 = M$9.9. </span> </Col> </Col> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 94ad6680..9b717af9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -397,7 +397,7 @@ function IncomeNotificationItem(props: { } else if (sourceType === 'betting_streak_bonus') { reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { - reasonText = `of your invested bets returned as` + reasonText = `of your invested bets returned as a` } const bettingStreakText = From 51380febd4def2adc5f8476f86909067b61775ff Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 22:50:52 -0500 Subject: [PATCH 053/279] Increase memory for updateStats function --- functions/src/update-stats.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts index f99458ef..3f1b5d36 100644 --- a/functions/src/update-stats.ts +++ b/functions/src/update-stats.ts @@ -311,6 +311,6 @@ export const updateStatsCore = async () => { } export const updateStats = functions - .runWith({ memory: '1GB', timeoutSeconds: 540 }) + .runWith({ memory: '2GB', timeoutSeconds: 540 }) .pubsub.schedule('every 60 minutes') .onRun(updateStatsCore) From 93739e79905daba5f2414198741019fc87ed3eaa Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 24 Aug 2022 23:40:27 -0500 Subject: [PATCH 054/279] Fix betting streak number --- web/pages/notifications.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9b717af9..73ab5c4d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -31,10 +31,7 @@ import { import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' -import { - BETTING_STREAK_BONUS_AMOUNT, - UNIQUE_BETTOR_BONUS_AMOUNT, -} from 'common/economy' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/economy' import { groupBy, sum, uniq } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' @@ -44,6 +41,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' +import { useUser } from 'web/hooks/use-user' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -378,6 +376,8 @@ function IncomeNotificationItem(props: { const [highlighted] = useState(!notification.isSeen) const { width } = useWindowSize() const isMobile = (width && width < 768) || false + const user = useUser() + useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) @@ -403,9 +403,7 @@ function IncomeNotificationItem(props: { const bettingStreakText = sourceType === 'betting_streak_bonus' && (sourceText - ? `🔥 ${ - parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT - } day Betting Streak` + ? `🔥 ${user?.currentBettingStreak ?? 0} day Betting Streak` : 'Betting Streak') return ( From 18f2550e4dc4ea96506a922134b758e31a935b29 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 24 Aug 2022 23:44:23 -0500 Subject: [PATCH 055/279] resolution email: show n/a for canceled numeric --- functions/src/emails.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 047e6828..e6e52090 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -116,7 +116,9 @@ const toDisplayResolution = ( } if (contract.outcomeType === 'PSEUDO_NUMERIC') { - const { resolutionValue } = contract + const { resolution, resolutionValue } = contract + + if (resolution === 'CANCEL') return 'N/A' return resolutionValue ? formatLargeNumber(resolutionValue) From 465e219bfc5672969c5c6f0e66bb96f1b0744d0f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 05:10:24 -0600 Subject: [PATCH 056/279] Show old streak for old streak notifs --- web/pages/notifications.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 73ab5c4d..75ad2ab9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -31,7 +31,10 @@ import { import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' -import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/economy' +import { + BETTING_STREAK_BONUS_AMOUNT, + UNIQUE_BETTOR_BONUS_AMOUNT, +} from 'common/economy' import { groupBy, sum, uniq } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' @@ -42,6 +45,7 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { useUser } from 'web/hooks/use-user' +import { DAY_MS } from 'common/lib/util/time' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -400,11 +404,13 @@ function IncomeNotificationItem(props: { reasonText = `of your invested bets returned as a` } + const streakInDays = + Date.now() - notification.createdTime > 24 * 60 * 60 * 1000 + ? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT + : user?.currentBettingStreak ?? 0 const bettingStreakText = sourceType === 'betting_streak_bonus' && - (sourceText - ? `🔥 ${user?.currentBettingStreak ?? 0} day Betting Streak` - : 'Betting Streak') + (sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak') return ( <> From 6e3b8fdd4d7a1dce4d144157ef00d1fc5c0c9fd5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 05:10:38 -0600 Subject: [PATCH 057/279] Show old streak for old streak notifs --- web/pages/notifications.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 75ad2ab9..0fe3b179 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -45,7 +45,6 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { useUser } from 'web/hooks/use-user' -import { DAY_MS } from 'common/lib/util/time' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' From b9f0da9d3bc7a86897934916bc3740c9558da3d2 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 05:51:56 -0600 Subject: [PATCH 058/279] Give all users 5 free markets --- common/economy.ts | 1 + common/envs/prod.ts | 1 + common/user.ts | 1 + functions/src/create-market.ts | 27 ++++++++++++----- .../src/on-create-liquidity-provision.ts | 18 ++++++++++-- web/pages/create.tsx | 29 ++++++++++++++----- 6 files changed, 60 insertions(+), 17 deletions(-) diff --git a/common/economy.ts b/common/economy.ts index 8db4a7b9..c1449d4f 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -14,3 +14,4 @@ export const BETTING_STREAK_BONUS_AMOUNT = econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0 +export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 033d050f..33cf03c1 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -35,6 +35,7 @@ export type Economy = { BETTING_STREAK_BONUS_AMOUNT?: number BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_RESET_HOUR?: number + FREE_MARKETS_PER_USER_MAX?: number } type FirebaseConfig = { diff --git a/common/user.ts b/common/user.ts index b278300c..48a3d59c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -43,6 +43,7 @@ export type User = { lastBetTime?: number currentBettingStreak?: number hasSeenContractFollowModal?: boolean + freeMarketsCreated?: number } export type PrivateUser = { diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index ae120c43..e9804f90 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -15,15 +15,17 @@ import { import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { chargeUser, getContract } from './utils' +import { chargeUser, getContract, isProd } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' -import { FIXED_ANTE } from '../../common/economy' +import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, getCpmmInitialLiquidity, getFreeAnswerAnte, getMultipleChoiceAntes, getNumericAnte, + HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' @@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric' import { JSONContent } from '@tiptap/core' import { uniq, zip } from 'lodash' import { Bet } from '../../common/bet' +import { FieldValue } from 'firebase-admin/firestore' const descScehma: z.ZodType<JSONContent> = z.lazy(() => z.intersection( @@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => { const user = userDoc.data() as User const ante = FIXED_ANTE - + const deservesFreeMarket = + (user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX // TODO: this is broken because it's not in a transaction - if (ante > user.balance) + if (ante > user.balance && !deservesFreeMarket) throw new APIError(400, `Balance must be at least ${ante}.`) let group: Group | null = null @@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => { visibility ) - if (ante) await chargeUser(user.id, ante, true) + const providerId = deservesFreeMarket + ? isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + : user.id + + if (ante) await chargeUser(providerId, ante, true) + if (deservesFreeMarket) + await firestore + .collection('users') + .doc(user.id) + .update({ freeMarketsCreated: FieldValue.increment(1) }) await contractRef.create(contract) @@ -221,8 +236,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } } - const providerId = user.id - if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore .collection(`contracts/${contract.id}/liquidity`) diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 56a01bbb..3a1e551f 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -1,8 +1,13 @@ import * as functions from 'firebase-functions' -import { getContract, getUser } from './utils' +import { getContract, getUser, log } from './utils' import { createNotification } from './create-notification' -import { LiquidityProvision } from 'common/liquidity-provision' +import { LiquidityProvision } from '../../common/liquidity-provision' import { addUserToContractFollowers } from './follow-market' +import { FIXED_ANTE } from '../../common/economy' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' export const onCreateLiquidityProvision = functions.firestore .document('contracts/{contractId}/liquidity/{liquidityId}') @@ -11,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore const { eventId } = context // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision - if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return + if ( + (liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID || + liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) && + liquidity.amount === FIXED_ANTE + ) + return + + log(`onCreateLiquidityProvision: ${JSON.stringify(liquidity)}`) const contract = await getContract(liquidity.contractId) if (!contract) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 2ec86bb7..0c142d67 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer' import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { Contract, contractPath } from 'web/lib/firebase/contracts' import { createMarket } from 'web/lib/firebase/api' -import { FIXED_ANTE } from 'common/economy' +import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from 'common/economy' import { InfoTooltip } from 'web/components/info-tooltip' import { Page } from 'web/components/page' import { Row } from 'web/components/layout/row' @@ -158,6 +158,8 @@ export function NewContract(props: { : undefined const balance = creator.balance || 0 + const deservesFreeMarket = + (creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined @@ -177,7 +179,7 @@ export function NewContract(props: { question.length > 0 && ante !== undefined && ante !== null && - ante <= balance && + (ante <= balance || deservesFreeMarket) && // closeTime must be in the future closeTime && closeTime > Date.now() && @@ -461,12 +463,25 @@ export function NewContract(props: { text={`Cost to create your question. This amount is used to subsidize betting.`} /> </label> + {!deservesFreeMarket ? ( + <div className="label-text text-neutral pl-1"> + {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> + </div> + </div> + )} - <div className="label-text text-neutral pl-1"> - {formatMoney(ante)} - </div> - - {ante > balance && ( + {ante > balance && !deservesFreeMarket && ( <div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide"> <span className="mr-2 text-red-500">Insufficient balance</span> <button From dc89d5d4d037b40c7750c2e8cf55459425f1992b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 07:05:26 -0600 Subject: [PATCH 059/279] Feature markets on trending --- common/contract.ts | 1 + common/envs/prod.ts | 1 + firestore.rules | 3 +- .../contract/FeaturedContractBadge.tsx | 9 ++++ web/components/contract/contract-details.tsx | 3 ++ .../contract/contract-info-dialog.tsx | 48 ++++++++++++++++++- 6 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 web/components/contract/FeaturedContractBadge.tsx diff --git a/common/contract.ts b/common/contract.ts index 343bc750..2b330201 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -58,6 +58,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { uniqueBettorCount?: number popularityScore?: number followerCount?: number + featuredOnHomeRank?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 33cf03c1..5d9ac00e 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -71,6 +71,7 @@ export const PROD_CONFIG: EnvConfig = { 'taowell@gmail.com', // Stephen 'abc.sinclair@gmail.com', // Sinclair 'manticmarkets@gmail.com', // Manifold + 'iansphilips@gmail.com', // Ian ], visibility: 'PUBLIC', diff --git a/firestore.rules b/firestore.rules index 0e5a759b..4cd718d3 100644 --- a/firestore.rules +++ b/firestore.rules @@ -11,7 +11,8 @@ service cloud.firestore { 'jahooma@gmail.com', 'taowell@gmail.com', 'abc.sinclair@gmail.com', - 'manticmarkets@gmail.com' + 'manticmarkets@gmail.com', + 'iansphilips@gmail.com' ] } diff --git a/web/components/contract/FeaturedContractBadge.tsx b/web/components/contract/FeaturedContractBadge.tsx new file mode 100644 index 00000000..5ef34f4a --- /dev/null +++ b/web/components/contract/FeaturedContractBadge.tsx @@ -0,0 +1,9 @@ +import { SparklesIcon } from '@heroicons/react/solid' + +export function FeaturedContractBadge() { + return ( + <span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800"> + <SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured + </span> + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7b6a6277..56407c4d 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -33,6 +33,7 @@ import { insertContent } from '../editor/utils' import clsx from 'clsx' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' +import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge' export type ShowTime = 'resolve-date' | 'close-date' @@ -73,6 +74,8 @@ export function MiscDetails(props: { {'Resolved '} {fromNow(resolutionTime || 0)} </Row> + ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( + <FeaturedContractBadge /> ) : volume > 0 || !isNew ? ( <Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row> ) : ( diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 29746c65..7c35a071 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,7 +7,7 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { contractPool } from 'web/lib/firebase/contracts' +import { contractPool, updateContract } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' @@ -16,6 +16,7 @@ import { InfoTooltip } from '../info-tooltip' import { useAdmin, useDev } from 'web/hooks/use-admin' import { SiteLink } from '../site-link' import { firestoreConsolePath } from 'common/envs/constants' +import { deleteField } from 'firebase/firestore' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -24,6 +25,9 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props const [open, setOpen] = useState(false) + const [featured, setFeatured] = useState( + (contract?.featuredOnHomeRank ?? 0) > 0 + ) const isDev = useDev() const isAdmin = useAdmin() @@ -138,6 +142,48 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { </td> </tr> )} + {isAdmin && ( + <tr> + <td>Set featured</td> + <td> + <select + className="select select-bordered" + value={featured ? 'true' : 'false'} + onChange={(e) => { + const newVal = e.target.value === 'true' + if ( + newVal && + (contract.featuredOnHomeRank === 0 || + !contract?.featuredOnHomeRank) + ) + updateContract(id, { + featuredOnHomeRank: 1, + }) + .then(() => { + setFeatured(true) + }) + .catch(console.error) + else if ( + !newVal && + (contract?.featuredOnHomeRank ?? 0) > 0 + ) + updateContract(id, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + featuredOnHomeRank: deleteField(), + }) + .then(() => { + setFeatured(false) + }) + .catch(console.error) + }} + > + <option value="false">false</option> + <option value="true">true</option> + </select> + </td> + </tr> + )} </tbody> </table> From 90e1fdb586fc8102f3cbc2799f906abcb2a75486 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 07:54:50 -0600 Subject: [PATCH 060/279] Add david to admins --- common/envs/prod.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 5d9ac00e..2b1ee70e 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -72,6 +72,7 @@ export const PROD_CONFIG: EnvConfig = { 'abc.sinclair@gmail.com', // Sinclair 'manticmarkets@gmail.com', // Manifold 'iansphilips@gmail.com', // Ian + 'd4vidchee@gmail.com', // D4vid ], visibility: 'PUBLIC', From b785d4b047436cde6dc658685a917c192e00d98d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 10:02:22 -0600 Subject: [PATCH 061/279] With play money --- web/components/contract/contract-overview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 6103fee7..ac6b20f9 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -76,7 +76,7 @@ export const ContractOverview = (props: { <BetButton contract={contract as CPMMBinaryContract} /> {!user && ( <div className="mt-1 text-sm text-gray-500"> - (Don't worry, it's play money!) + (with play money!) </div> )} </Col> From 97b648a51e3d7b3adf89f5952914b303a375b53b Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 25 Aug 2022 12:59:26 -0500 Subject: [PATCH 062/279] Move recommended markets below market white bg onto gray bg --- web/pages/[username]/[contractSlug].tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 282df488..8250bde9 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -267,14 +267,14 @@ export function ContractPageContent( tips={tips} comments={comments} /> - - {recommendedContracts?.length > 0 && ( - <Col className="mx-2 gap-2 sm:mx-0"> - <Subtitle text="Recommended" /> - <ContractsGrid contracts={recommendedContracts} /> - </Col> - )} </Col> + + {recommendedContracts.length > 0 && ( + <Col className="gap-2 px-2 sm:px-0"> + <Subtitle text="Recommended" /> + <ContractsGrid contracts={recommendedContracts} /> + </Col> + )} </Page> ) } From 91bb4dfab2cdb6d2e2fd1a9081ac2e7bba630ec5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 25 Aug 2022 12:06:42 -0600 Subject: [PATCH 063/279] With play money on numeric & center text --- web/components/contract/contract-overview.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index ac6b20f9..23485179 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -75,7 +75,7 @@ export const ContractOverview = (props: { <Col> <BetButton contract={contract as CPMMBinaryContract} /> {!user && ( - <div className="mt-1 text-sm text-gray-500"> + <div className="mt-1 text-center text-sm text-gray-500"> (with play money!) </div> )} @@ -85,7 +85,16 @@ export const ContractOverview = (props: { ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} + {tradingAllowed(contract) && ( + <Col> + <BetButton contract={contract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + )} </Row> ) : ( (outcomeType === 'FREE_RESPONSE' || From 77732341384f4a6e816c3a91c6e4984ebcac5f8a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 25 Aug 2022 17:21:51 -0500 Subject: [PATCH 064/279] Add debug console.log --- web/components/auth-context.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index f62c10a2..6957d062 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -47,6 +47,7 @@ export function AuthProvider(props: { useEffect(() => { return onIdTokenChanged(auth, async (fbUser) => { + console.log('onIdTokenChanged', fbUser) if (fbUser) { setTokenCookies({ id: await fbUser.getIdToken(), From 0f49effade2f3de61acdb68cb89cb7e98bc2b8bc Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 25 Aug 2022 19:17:50 -0700 Subject: [PATCH 065/279] Tweak Featured badge design --- web/components/contract/FeaturedContractBadge.tsx | 9 --------- web/components/contract/contract-details.tsx | 2 +- web/components/contract/featured-contract-badge.tsx | 9 +++++++++ 3 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 web/components/contract/FeaturedContractBadge.tsx create mode 100644 web/components/contract/featured-contract-badge.tsx diff --git a/web/components/contract/FeaturedContractBadge.tsx b/web/components/contract/FeaturedContractBadge.tsx deleted file mode 100644 index 5ef34f4a..00000000 --- a/web/components/contract/FeaturedContractBadge.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SparklesIcon } from '@heroicons/react/solid' - -export function FeaturedContractBadge() { - return ( - <span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800"> - <SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured - </span> - ) -} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 56407c4d..4b73a227 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -33,7 +33,7 @@ import { insertContent } from '../editor/utils' import clsx from 'clsx' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' -import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge' +import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' diff --git a/web/components/contract/featured-contract-badge.tsx b/web/components/contract/featured-contract-badge.tsx new file mode 100644 index 00000000..fe31ecb9 --- /dev/null +++ b/web/components/contract/featured-contract-badge.tsx @@ -0,0 +1,9 @@ +import { BadgeCheckIcon } from '@heroicons/react/solid' + +export function FeaturedContractBadge() { + return ( + <span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-green-800"> + <BadgeCheckIcon className="h-4 w-4" aria-hidden="true" /> Featured + </span> + ) +} From 4faab4fcdc33abd262ad5a051904c660a0237dd3 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 25 Aug 2022 19:42:40 -0700 Subject: [PATCH 066/279] Clean up Featured code --- .../contract/contract-info-dialog.tsx | 58 +++++++------------ web/components/widgets/short-toggle.tsx | 8 ++- 2 files changed, 29 insertions(+), 37 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 7c35a071..5c66aa4c 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -17,6 +17,7 @@ import { useAdmin, useDev } from 'web/hooks/use-admin' import { SiteLink } from '../site-link' import { firestoreConsolePath } from 'common/envs/constants' import { deleteField } from 'firebase/firestore' +import ShortToggle from '../widgets/short-toggle' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -50,6 +51,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { ? 'Multiple choice' : 'Numeric' + const onFeaturedToggle = async (enabled: boolean) => { + if ( + enabled && + (contract.featuredOnHomeRank === 0 || !contract?.featuredOnHomeRank) + ) { + await updateContract(id, { featuredOnHomeRank: 1 }) + setFeatured(true) + } else if (!enabled && (contract?.featuredOnHomeRank ?? 0) > 0) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await updateContract(id, { featuredOnHomeRank: deleteField() }) + setFeatured(false) + } + } + return ( <> <button @@ -144,43 +160,13 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { )} {isAdmin && ( <tr> - <td>Set featured</td> + <td>[ADMIN] Featured</td> <td> - <select - className="select select-bordered" - value={featured ? 'true' : 'false'} - onChange={(e) => { - const newVal = e.target.value === 'true' - if ( - newVal && - (contract.featuredOnHomeRank === 0 || - !contract?.featuredOnHomeRank) - ) - updateContract(id, { - featuredOnHomeRank: 1, - }) - .then(() => { - setFeatured(true) - }) - .catch(console.error) - else if ( - !newVal && - (contract?.featuredOnHomeRank ?? 0) > 0 - ) - updateContract(id, { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - featuredOnHomeRank: deleteField(), - }) - .then(() => { - setFeatured(false) - }) - .catch(console.error) - }} - > - <option value="false">false</option> - <option value="true">true</option> - </select> + <ShortToggle + enabled={featured} + setEnabled={setFeatured} + onChange={onFeaturedToggle} + /> </td> </tr> )} diff --git a/web/components/widgets/short-toggle.tsx b/web/components/widgets/short-toggle.tsx index 3c307fda..339de361 100644 --- a/web/components/widgets/short-toggle.tsx +++ b/web/components/widgets/short-toggle.tsx @@ -5,13 +5,19 @@ import clsx from 'clsx' export default function ShortToggle(props: { enabled: boolean setEnabled: (enabled: boolean) => void + onChange?: (enabled: boolean) => void }) { const { enabled, setEnabled } = props return ( <Switch checked={enabled} - onChange={setEnabled} + onChange={(e: boolean) => { + setEnabled(e) + if (props.onChange) { + props.onChange(e) + } + }} className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" > <span className="sr-only">Use setting</span> From 1b029ce8ddd7c091e29d3d72d2bd4d0f400854ec Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 25 Aug 2022 20:56:38 -0700 Subject: [PATCH 067/279] bump tiptap version to fix multi-italic bug (#801) --- common/package.json | 4 ++-- functions/package.json | 4 ++-- web/package.json | 4 ++-- yarn.lock | 18 +++++++++--------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/common/package.json b/common/package.json index 955e9662..52195398 100644 --- a/common/package.json +++ b/common/package.json @@ -8,11 +8,11 @@ }, "sideEffects": false, "dependencies": { - "@tiptap/core": "2.0.0-beta.181", + "@tiptap/core": "2.0.0-beta.182", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.190", + "@tiptap/starter-kit": "2.0.0-beta.191", "lodash": "4.17.21" }, "devDependencies": { diff --git a/functions/package.json b/functions/package.json index c8f295fc..d5a578de 100644 --- a/functions/package.json +++ b/functions/package.json @@ -26,11 +26,11 @@ "dependencies": { "@amplitude/node": "1.10.0", "@google-cloud/functions-framework": "3.1.2", - "@tiptap/core": "2.0.0-beta.181", + "@tiptap/core": "2.0.0-beta.182", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.190", + "@tiptap/starter-kit": "2.0.0-beta.191", "cors": "2.8.5", "dayjs": "1.11.4", "express": "4.18.1", diff --git a/web/package.json b/web/package.json index db3fdf45..847c7ef5 100644 --- a/web/package.json +++ b/web/package.json @@ -27,14 +27,14 @@ "@nivo/line": "0.74.0", "@nivo/tooltip": "0.74.0", "@react-query-firebase/firestore": "0.4.2", - "@tiptap/core": "2.0.0-beta.181", + "@tiptap/core": "2.0.0-beta.182", "@tiptap/extension-character-count": "2.0.0-beta.31", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-placeholder": "2.0.0-beta.53", "@tiptap/react": "2.0.0-beta.114", - "@tiptap/starter-kit": "2.0.0-beta.190", + "@tiptap/starter-kit": "2.0.0-beta.191", "algoliasearch": "4.13.0", "browser-image-compression": "2.0.0", "clsx": "1.1.1", diff --git a/yarn.lock b/yarn.lock index bbc13091..f49b1ccf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2919,10 +2919,10 @@ lodash.isplainobject "^4.0.6" lodash.merge "^4.6.2" -"@tiptap/core@2.0.0-beta.181", "@tiptap/core@^2.0.0-beta.181": - version "2.0.0-beta.181" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.181.tgz#07aeea26336814ab82eb7f4199b17538187c6fbb" - integrity sha512-tbwRqjTVvY9v31TNAH6W0Njhr/OVwI28zWXmH55/USrwyU2CB1iCVfXktZKOhB+8WyvOaBv1JA5YplMIhstYTw== +"@tiptap/core@2.0.0-beta.182", "@tiptap/core@^2.0.0-beta.182": + version "2.0.0-beta.182" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.182.tgz#d2001e9b765adda95e15d171479860a3349e2d04" + integrity sha512-MZGkMGnVnWhBzjvpBNwQ9zBz38ndi3Irbf90uCTSArR0kaCVkW4vmyuPuOXd+0SO8Yv/l5oyDdOCpaG3rnQYfw== dependencies: prosemirror-commands "1.3.0" prosemirror-keymap "1.2.0" @@ -3099,12 +3099,12 @@ "@tiptap/extension-floating-menu" "^2.0.0-beta.56" prosemirror-view "1.26.2" -"@tiptap/starter-kit@2.0.0-beta.190": - version "2.0.0-beta.190" - resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.190.tgz#fe0021e29d070fc5707722513a398c8884e15f71" - integrity sha512-jaFMkE6mjCHmCJsXUyLiXGYRVDcHF+PbH/5hEu1riUIAT0Hmm7uak5TYsPeuoCVN7P/tmDEBbBRASZ5CzEQpvw== +"@tiptap/starter-kit@2.0.0-beta.191": + version "2.0.0-beta.191" + resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.191.tgz#3f549367f6dbb8cf83f63aa0941722d91d0fd8e7" + integrity sha512-YRrBCi9W4jiH/xLTJJOCdD7pL4Wb98Ip8qCJ94RElShDj0O1i5tT9wWlgVWoGIU+CRAds5XENRwZ97sJ+YfYyg== dependencies: - "@tiptap/core" "^2.0.0-beta.181" + "@tiptap/core" "^2.0.0-beta.182" "@tiptap/extension-blockquote" "^2.0.0-beta.29" "@tiptap/extension-bold" "^2.0.0-beta.28" "@tiptap/extension-bullet-list" "^2.0.0-beta.29" From e5777f02d8af68ed3f7960b2fe84cac4db39644e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 00:00:44 -0500 Subject: [PATCH 068/279] Expand notifications by default if <= 3 items --- web/pages/notifications.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 0fe3b179..ca5e1827 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -200,7 +200,9 @@ function IncomeNotificationGroupItem(props: { const { notificationGroup, className } = props const { notifications } = notificationGroup const numSummaryLines = 3 - const [expanded, setExpanded] = useState(false) + const [expanded, setExpanded] = useState( + notifications.length <= numSummaryLines + ) const [highlighted, setHighlighted] = useState( notifications.some((n) => !n.isSeen) ) @@ -524,7 +526,7 @@ function IncomeNotificationItem(props: { </span> </div> </Row> - <div className={'mt-4 border-b border-gray-300'} /> + <div className={'border-b border-gray-300 pt-4'} /> </div> </div> ) @@ -541,7 +543,9 @@ function NotificationGroupItem(props: { const isMobile = (width && width < 768) || false const numSummaryLines = 3 - const [expanded, setExpanded] = useState(false) + const [expanded, setExpanded] = useState( + notifications.length <= numSummaryLines + ) const [highlighted, setHighlighted] = useState( notifications.some((n) => !n.isSeen) ) From 539bfba70cea9ebad8bfa2ab5f85c615b3f620f5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 00:21:06 -0500 Subject: [PATCH 069/279] Decrease starting time window for free response graph --- web/components/answers/answers-graph.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 27152db9..cf70fc02 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -71,10 +71,10 @@ export const AnswersGraph = memo(function AnswersGraph(props: { const yTickValues = [0, 25, 50, 75, 100] const numXTickValues = isLargeWidth ? 5 : 2 - const hoursAgo = latestTime.subtract(5, 'hours') - const startDate = dayjs(contract.createdTime).isBefore(hoursAgo) + const hourAgo = latestTime.subtract(1, 'hours') + const startDate = dayjs(contract.createdTime).isBefore(hourAgo) ? new Date(contract.createdTime) - : hoursAgo.toDate() + : hourAgo.toDate() const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) From 6f2e2a3bbb60488c395de85e69fc2252c3e71ce1 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 00:23:50 -0500 Subject: [PATCH 070/279] Render graph for multiple choice embeds --- web/pages/embed/[username]/[contractSlug].tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 7ec8daeb..afec84bb 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -166,7 +166,8 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { /> )} - {outcomeType === 'FREE_RESPONSE' && ( + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && ( <AnswersGraph contract={contract} bets={bets} height={graphHeight} /> )} From 26a2eb2391ba119afab15f3fc0122f1d8d396dac Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 25 Aug 2022 22:31:03 -0700 Subject: [PATCH 071/279] Switch to a different color scheme --- web/components/answers/answers-graph.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index cf70fc02..5bd928e8 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -105,7 +105,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: { tickValues: numXTickValues, format: (time) => formatTime(+time, multiYear, lessThanAWeek, false), }} - colors={{ scheme: 'pastel1' }} + colors={[ + '#fca5a5', // red-300 + '#93c5fd', // blue-300 + '#86efac', // green-300 + '#f9a8d4', // pink-300 + '#a5b4fc', // indigo-300 + '#fcd34d', // amber-300 + ]} pointSize={0} curve="stepAfter" enableSlices="x" From 74ce98913c8dc183899c13f03ceb1bc7563fbbdf Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 01:08:16 -0500 Subject: [PATCH 072/279] Make graph start from left side for new markets --- web/components/answers/answers-graph.tsx | 20 ++++++++++++------- .../contract/contract-prob-graph.tsx | 20 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 5bd928e8..d35132be 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -71,10 +71,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: { const yTickValues = [0, 25, 50, 75, 100] const numXTickValues = isLargeWidth ? 5 : 2 - const hourAgo = latestTime.subtract(1, 'hours') - const startDate = dayjs(contract.createdTime).isBefore(hourAgo) - ? new Date(contract.createdTime) - : hourAgo.toDate() + const startDate = new Date(contract.createdTime) + const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours').toDate() + : latestTime.toDate() + const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) @@ -96,14 +97,15 @@ export const AnswersGraph = memo(function AnswersGraph(props: { xScale={{ type: 'time', min: startDate, - max: latestTime.toDate(), + max: endDate, }} xFormat={(d) => formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, - format: (time) => formatTime(+time, multiYear, lessThanAWeek, false), + format: (time) => + formatTime(+time, multiYear, lessThanAWeek, includeMinute), }} colors={[ '#fca5a5', // red-300 @@ -163,7 +165,11 @@ function formatTime( ) { const d = dayjs(time) - if (d.add(1, 'minute').isAfter(Date.now())) return 'Now' + if ( + d.add(1, 'minute').isAfter(Date.now()) && + d.subtract(1, 'minute').isBefore(Date.now()) + ) + return 'Now' let format: string if (d.isSame(Date.now(), 'day')) { diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 693befbb..ab2393f0 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -58,10 +58,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const { width } = useWindowSize() const numXTickValues = !width || width < 800 ? 2 : 5 - const hoursAgo = latestTime.subtract(1, 'hours') - const startDate = dayjs(times[0]).isBefore(hoursAgo) - ? times[0] - : hoursAgo.toDate() + const startDate = times[0] + const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours').toDate() + : latestTime.toDate() + const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 // Minimum number of points for the graph to have. For smooth tooltip movement // On first load, width is undefined, skip adding extra points to let page load faster @@ -133,14 +134,15 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { xScale={{ type: 'time', min: startDate, - max: latestTime.toDate(), + max: endDate, }} xFormat={(d) => formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, - format: (time) => formatTime(+time, multiYear, lessThanAWeek, false), + format: (time) => + formatTime(+time, multiYear, lessThanAWeek, includeMinute), }} colors={{ datum: 'color' }} curve="stepAfter" @@ -183,7 +185,11 @@ function formatTime( ) { const d = dayjs(time) - if (d.add(1, 'minute').isAfter(Date.now())) return 'Now' + if ( + d.add(1, 'minute').isAfter(Date.now()) && + d.subtract(1, 'minute').isBefore(Date.now()) + ) + return 'Now' let format: string if (d.isSame(Date.now(), 'day')) { From b1ccee73fd91f5a896ced456fc70f99aa3b6ab63 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 01:23:50 -0500 Subject: [PATCH 073/279] If there is a group for a market on market page, clicking it goes to group --- web/components/contract/contract-details.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 4b73a227..1e9d96d3 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -5,12 +5,15 @@ import { TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' +import Router from 'next/router' +import clsx from 'clsx' +import { Editor } from '@tiptap/react' +import dayjs from 'dayjs' import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' import { Contract, updateContract } from 'web/lib/firebase/contracts' -import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' import { Avatar } from '../avatar' @@ -21,7 +24,6 @@ import NewContractBadge from '../new-contract-badge' import { UserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' import { useUser } from 'web/hooks/use-user' -import { Editor } from '@tiptap/react' import { exhibitExts } from 'common/util/parse' import { Button } from 'web/components/button' import { Modal } from 'web/components/layout/modal' @@ -30,7 +32,6 @@ import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' -import clsx from 'clsx' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' @@ -191,7 +192,11 @@ export function ContractDetails(props: { size={'xs'} className={'max-w-[200px]'} color={'gray-white'} - onClick={() => setOpen(!open)} + onClick={() => + groupToDisplay + ? Router.push(groupPath(groupToDisplay.slug)) + : setOpen(!open) + } > {groupInfo} </Button> From 31db33031968cad55daa13e0ce57a85837a38e4c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 11:38:08 -0500 Subject: [PATCH 074/279] Show "(max)" on streak payout if it is the max payout --- web/pages/notifications.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index ca5e1827..bfd18f7f 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -400,7 +400,8 @@ function IncomeNotificationItem(props: { } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` } else if (sourceType === 'betting_streak_bonus') { - reasonText = 'for your' + if (sourceText && +sourceText === 50) reasonText = '(max) for your' + else reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { reasonText = `of your invested bets returned as a` } From 803091db066c07b296cff1bee43fb0d8333f97b0 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 26 Aug 2022 10:31:25 -0700 Subject: [PATCH 075/279] Add tournament home page (#797) * Add tournament home page * Preload markets, follow count * organize imports * Fix card width * Make entire title clickable * plural /tournament -> /tournaments * prettier * Fix /tournaments when groupIds are invalid * Restyle /tournaments page * Reintroduce Salem, tweak styles Co-authored-by: Austin Chen <akrolsmir@gmail.com> --- web/components/contract/contract-card.tsx | 15 +- web/pages/tournaments/index.tsx | 236 ++++++++++++++++++++++ 2 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 web/pages/tournaments/index.tsx diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 090020e0..056801eb 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -38,6 +38,7 @@ export function ContractCard(props: { showHotVolume?: boolean showTime?: ShowTime className?: string + questionClass?: string onClick?: () => void hideQuickBet?: boolean hideGroupLink?: boolean @@ -46,6 +47,7 @@ export function ContractCard(props: { showHotVolume, showTime, className, + questionClass, onClick, hideQuickBet, hideGroupLink, @@ -68,7 +70,7 @@ export function ContractCard(props: { return ( <Row className={clsx( - 'relative gap-3 self-start rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100', + 'relative gap-3 rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100', className )} > @@ -105,7 +107,10 @@ export function ContractCard(props: { className={'hidden md:inline-flex'} /> <p - className="break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2" + className={clsx( + 'break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2', + questionClass + )} style={{ /* For iOS safari */ wordBreak: 'break-word' }} > {question} @@ -165,11 +170,7 @@ export function ContractCard(props: { showQuickBet ? 'w-[85%]' : 'w-full' )} > - <AvatarDetails - contract={contract} - short={true} - className={'block md:hidden'} - /> + <AvatarDetails contract={contract} short={true} className="md:hidden" /> <MiscDetails contract={contract} showHotVolume={showHotVolume} diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx new file mode 100644 index 00000000..b93a9725 --- /dev/null +++ b/web/pages/tournaments/index.tsx @@ -0,0 +1,236 @@ +import { ClockIcon } from '@heroicons/react/outline' +import { + ChevronLeftIcon, + ChevronRightIcon, + UsersIcon, +} from '@heroicons/react/solid' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { Group } from 'common/group' +import dayjs, { Dayjs } from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import timezone from 'dayjs/plugin/timezone' +import utc from 'dayjs/plugin/utc' +import { keyBy, mapValues, throttle } from 'lodash' +import Link from 'next/link' +import { ReactNode, useEffect, useRef, useState } from 'react' +import { ContractCard } from 'web/components/contract/contract-card' +import { DateTimeTooltip } from 'web/components/datetime-tooltip' +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 { getGroup, groupPath } from 'web/lib/firebase/groups' + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(customParseFormat) +const toDate = (d: string) => dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles') + +type Tourney = { + title: string + url?: string + blurb: string // actual description in the click-through + award?: string + endTime?: Dayjs + groupId: string +} + +const Salem = { + title: 'CSPI/Salem Forecasting Tournament', + blurb: 'Top 5 traders qualify for a UT Austin research fellowship.', + url: 'https://salemcenter.manifold.markets/', + award: '$25,000', + endTime: toDate('Jul 31, 2023'), +} as const + +const tourneys: Tourney[] = [ + { + title: 'Cause Exploration Prizes', + blurb: + 'Which new charity ideas will Open Philanthropy find most promising?', + award: 'M$100k', + endTime: toDate('Sep 9, 2022'), + groupId: 'cMcpBQ2p452jEcJD2SFw', + }, + { + title: 'Fantasy Football Stock Exchange', + blurb: 'How many points will each NFL player score this season?', + award: '$2,500', + endTime: toDate('Jan 6, 2023'), + groupId: 'SxGRqXRpV3RAQKudbcNb', + }, + // { + // title: 'Clearer Thinking Regrant Project', + // blurb: 'Something amazing', + // award: '$10,000', + // endTime: toDate('Sep 22, 2022'), + // groupId: '2VsVVFGhKtIdJnQRAXVb', + // }, +] + +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?.memberIds.length) + const slugs = mapValues(groupMap, 'slug') + + return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 } +} + +export default function TournamentPage(props: { + markets: { [groupId: string]: Contract[] } + numPeople: { [groupId: string]: number } + slugs: { [groupId: string]: string } +}) { + const { markets = {}, numPeople = {}, slugs = {} } = props + + return ( + <Page> + <SEO + title="Tournaments" + description="Win money by betting in forecasting touraments on current events, sports, science, and more" + /> + <Col className="mx-4 mt-4 gap-20 sm:mx-10 xl:w-[125%]"> + {tourneys.map(({ groupId, ...data }) => ( + <Section + key={groupId} + {...data} + url={groupPath(slugs[groupId])} + ppl={numPeople[groupId] ?? 0} + markets={markets[groupId] ?? []} + /> + ))} + <Section {...Salem} markets={[]} /> + </Col> + </Page> + ) +} + +function Section(props: { + title: string + url: string + blurb: string + award?: string + ppl?: number + endTime?: Dayjs + markets: Contract[] +}) { + const { title, url, blurb, award, ppl, endTime, markets } = props + + return ( + <div> + <Link href={url}> + <a className="group mb-3 flex flex-wrap justify-between"> + <h2 className="text-xl font-semibold group-hover:underline md:text-3xl"> + {title} + </h2> + <Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6"> + {!!award && <span className="flex items-center">🏆 {award}</span>} + {!!ppl && ( + <span className="flex items-center gap-1"> + <UsersIcon className="h-4" /> + {ppl} + </span> + )} + {endTime && ( + <DateTimeTooltip time={endTime} text="Ends"> + <span className="flex items-center gap-1"> + <ClockIcon className="h-4" /> + {endTime.format('MMM D')} + </span> + </DateTimeTooltip> + )} + </Row> + </a> + </Link> + <span>{blurb}</span> + <Carousel className="-mx-4 mt-2 sm:-mx-10"> + <div className="shrink-0 sm:w-6" /> + {markets.length ? ( + markets.map((m) => ( + <ContractCard + contract={m} + showHotVolume + hideGroupLink + className="max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + /> + )) + ) : ( + <div className="flex h-32 w-80 items-center justify-center rounded bg-white text-lg text-gray-700 shadow-md"> + Coming Soon... + </div> + )} + </Carousel> + </div> + ) +} + +function Carousel(props: { children: ReactNode; className?: string }) { + const { children, className } = props + + const ref = useRef<HTMLDivElement>(null) + + const th = (f: () => any) => throttle(f, 500, { trailing: false }) + const scrollLeft = th(() => + ref.current?.scrollBy({ left: -ref.current.clientWidth }) + ) + const scrollRight = th(() => + ref.current?.scrollBy({ left: ref.current.clientWidth }) + ) + + const [atFront, setAtFront] = useState(true) + const [atBack, setAtBack] = useState(false) + const onScroll = throttle(() => { + if (ref.current) { + const { scrollLeft, clientWidth, scrollWidth } = ref.current + setAtFront(scrollLeft < 80) + setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80) + } + }, 500) + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(onScroll, []) + + return ( + <div className={clsx('relative', className)}> + <Row + className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth" + ref={ref} + onScroll={onScroll} + > + {children} + </Row> + {!atFront && ( + <div + className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" + onMouseDown={scrollLeft} + > + <ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> + </div> + )} + {!atBack && ( + <div + className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" + onMouseDown={scrollRight} + > + <ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> + </div> + )} + </div> + ) +} From 490115d8901fd4ea60720a94a01b80afd848d3b1 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 26 Aug 2022 10:45:01 -0700 Subject: [PATCH 076/279] Add tournaments to sidebar (#802) * Add tournaments to sidebar * Remove unused import * Reposition tournaments tab Co-authored-by: Austin Chen <akrolsmir@gmail.com> --- web/components/nav/sidebar.tsx | 29 +++++++++++------------------ web/lib/icons/trophy-icon.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 18 deletions(-) create mode 100644 web/lib/icons/trophy-icon.tsx diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index e16a502e..915ceea1 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -6,7 +6,6 @@ import { CashIcon, HeartIcon, UserGroupIcon, - TrendingUpIcon, ChatIcon, } from '@heroicons/react/outline' import clsx from 'clsx' @@ -29,6 +28,7 @@ import { Spacer } from '../layout/spacer' import { useWindowSize } from 'web/hooks/use-window-size' import { CHALLENGES_ENABLED } from 'common/challenge' import { buildArray } from 'common/util/array' +import TrophyIcon from 'web/lib/icons/trophy-icon' const logout = async () => { // log out, and then reload the page, in case SSR wants to boot them out @@ -46,11 +46,12 @@ function getNavigation() { icon: NotificationsIcon, }, - { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, - ...(IS_PRIVATE_MANIFOLD ? [] - : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), + : [ + { name: 'Get M$', href: '/add-funds', icon: CashIcon }, + { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon }, + ]), ] } @@ -70,11 +71,9 @@ function getMoreNavigation(user?: User | null) { return buildArray( CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ + { name: 'Leaderboards', href: '/leaderboards' }, + { name: 'Tournaments', href: '/tournaments' }, { name: 'Charity', href: '/charity' }, - { - name: 'Salem tournament', - href: 'https://salemcenter.manifold.markets/', - }, { name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, @@ -86,12 +85,9 @@ function getMoreNavigation(user?: User | null) { CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ { name: 'Referrals', href: '/referrals' }, + { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, { name: 'Send M$', href: '/links' }, - { - name: 'Salem tournament', - href: 'https://salemcenter.manifold.markets/', - }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Help & About', href: 'https://help.manifold.markets/' }, { @@ -120,12 +116,12 @@ const signedOutMobileNavigation = [ icon: BookOpenIcon, }, { name: 'Charity', href: '/charity', icon: HeartIcon }, - { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, + { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon }, ] const signedInMobileNavigation = [ - { name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, + { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon }, ...(IS_PRIVATE_MANIFOLD ? [] : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), @@ -148,10 +144,7 @@ function getMoreMobileNav() { CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ { name: 'Referrals', href: '/referrals' }, - { - name: 'Salem tournament', - href: 'https://salemcenter.manifold.markets/', - }, + { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, diff --git a/web/lib/icons/trophy-icon.tsx b/web/lib/icons/trophy-icon.tsx new file mode 100644 index 00000000..c845a0af --- /dev/null +++ b/web/lib/icons/trophy-icon.tsx @@ -0,0 +1,27 @@ +export default function TrophyIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + stroke="currentcolor" + stroke-width="2" + {...props} + > + <g> + <path + d="m6,5c0,4 1.4,7.8 3.5,8.5l0,2c-1.2,0.7 -1.2,1 -1.6,4l8,0c-0.4,-3 -0.4,-3.3 -1.6,-4l0,-2c2.1,-0.7 3.5,-4.5 3.5,-8.5z" + stroke-linejoin="round" + fill="none" + /> + <path + d="m6.2,8.3c-2.5,-1.6 -3.5,1 -3,2.5c1,1.7 2.6,2.5 4.5,1.8" + fill="none" + /> + <path + d="m17.6,8.3c2.5,-1.6 3.5,1 3,2.5c-1,1.7 -2.6,2.5 -4.5,1.8" + fill="none" + /> + </g> + </svg> + ) +} From ba7d0f45db1427d23597feab26903ce16e3d7213 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 26 Aug 2022 12:41:29 -0700 Subject: [PATCH 077/279] Close Add Market modal on Cancel --- web/components/editor/market-modal.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index ea62b960..a81953de 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -48,8 +48,17 @@ export function MarketModal(props: { {contracts.length > 1 && 's'} </Button> )} - <Button onClick={() => setContracts([])} color="gray"> - Cancel + <Button + onClick={() => { + if (contracts.length > 0) { + setContracts([]) + } else { + setOpen(false) + } + }} + color="gray" + > + {contracts.length > 0 ? 'Reset' : 'Cancel'} </Button> </Row> )} From 325580689124048772039e1a8e9b5a240cbb062c Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 26 Aug 2022 12:41:39 -0700 Subject: [PATCH 078/279] Support Figma embeds --- web/components/editor/embed-modal.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/components/editor/embed-modal.tsx b/web/components/editor/embed-modal.tsx index 6acfd8f0..48ccbfbb 100644 --- a/web/components/editor/embed-modal.tsx +++ b/web/components/editor/embed-modal.tsx @@ -40,6 +40,11 @@ const embedPatterns: EmbedPattern[] = [ rewrite: (id) => `<iframe src="https://www.metaculus.com/questions/embed/${id}"></iframe>`, }, + { + regex: /^(https?:\/\/www\.figma\.com\/(?:file|proto)\/[^\/]+\/[^\/]+)/, + rewrite: (url) => + `<iframe src="https://www.figma.com/embed?embed_host=manifold&url=${url}"></iframe>`, + }, // Twitch is a bit annoying, since it requires the `&parent=DOMAIN` to match { // Twitch: https://www.twitch.tv/videos/1445087149 From 8903b1ef95d1a5f54075467240cb4f75517ce40a Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 26 Aug 2022 14:23:06 -0700 Subject: [PATCH 079/279] Replace style props with tailwind classes (#800) * add utility class for `word-break: break-word` * refactor visuallyHidden style out of Page * refactor out ref sizing hack in sidebar * replace style props with tailwind classes --- web/components/advanced-panel.tsx | 38 ----------------------- web/components/analytics/charts.tsx | 13 +++++--- web/components/contract/contract-card.tsx | 3 +- web/components/leaderboard.tsx | 2 +- web/components/linkify.tsx | 5 +-- web/components/nav/sidebar.tsx | 24 ++++++-------- web/components/outcome-label.tsx | 5 +-- web/components/page.tsx | 22 ++----------- web/components/site-link.tsx | 3 +- web/components/user-page.tsx | 5 +-- web/pages/home.tsx | 2 +- web/pages/simulator.tsx | 5 ++- web/pages/stats.tsx | 6 ++-- web/tailwind.config.js | 4 +++ 14 files changed, 35 insertions(+), 102 deletions(-) delete mode 100644 web/components/advanced-panel.tsx diff --git a/web/components/advanced-panel.tsx b/web/components/advanced-panel.tsx deleted file mode 100644 index 51caba67..00000000 --- a/web/components/advanced-panel.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import clsx from 'clsx' -import { useState, ReactNode } from 'react' - -export function AdvancedPanel(props: { children: ReactNode }) { - const { children } = props - const [collapsed, setCollapsed] = useState(true) - - return ( - <div - tabIndex={0} - className={clsx( - 'collapse collapse-arrow relative', - collapsed ? 'collapse-close' : 'collapse-open' - )} - > - <div - onClick={() => setCollapsed((collapsed) => !collapsed)} - className="cursor-pointer" - > - <div className="mt-4 mr-6 text-right text-sm text-gray-500"> - Advanced - </div> - <div - className="collapse-title absolute h-0 min-h-0 w-0 p-0" - style={{ - top: -2, - right: -15, - color: '#6a7280' /* gray-500 */, - }} - /> - </div> - - <div className="collapse-content m-0 !bg-transparent !p-0"> - {children} - </div> - </div> - ) -} diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx index 2f987d58..56e71257 100644 --- a/web/components/analytics/charts.tsx +++ b/web/components/analytics/charts.tsx @@ -1,4 +1,5 @@ import { Point, ResponsiveLine } from '@nivo/line' +import clsx from 'clsx' import dayjs from 'dayjs' import { zip } from 'lodash' import { useWindowSize } from 'web/hooks/use-window-size' @@ -26,8 +27,10 @@ export function DailyCountChart(props: { return ( <div - className="w-full overflow-hidden" - style={{ height: !small && (!width || width >= 800) ? 400 : 250 }} + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} > <ResponsiveLine data={data} @@ -78,8 +81,10 @@ export function DailyPercentChart(props: { return ( <div - className="w-full overflow-hidden" - style={{ height: !small && (!width || width >= 800) ? 400 : 250 }} + className={clsx( + 'h-[250px] w-full overflow-hidden', + !small && 'md:h-[400px]' + )} > <ResponsiveLine data={data} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 056801eb..6ada9b6f 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -108,10 +108,9 @@ export function ContractCard(props: { /> <p className={clsx( - 'break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2', + 'break-anywhere font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2', questionClass )} - style={{ /* For iOS safari */ wordBreak: 'break-word' }} > {question} </p> diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx index b8c725e0..a0670795 100644 --- a/web/components/leaderboard.tsx +++ b/web/components/leaderboard.tsx @@ -40,7 +40,7 @@ export function Leaderboard(props: { {users.map((user, index) => ( <tr key={user.id}> <td>{index + 1}</td> - <td style={{ maxWidth: 190 }}> + <td className="max-w-[190px]"> <SiteLink className="relative" href={`/${user.username}`}> <Row className="items-center gap-4"> <Avatar avatarUrl={user.avatarUrl} size={8} /> diff --git a/web/components/linkify.tsx b/web/components/linkify.tsx index f33b2bf5..b4f05165 100644 --- a/web/components/linkify.tsx +++ b/web/components/linkify.tsx @@ -38,10 +38,7 @@ export function Linkify(props: { text: string; gray?: boolean }) { ) }) return ( - <span - className="break-words" - style={{ /* For iOS safari */ wordBreak: 'break-word' }} - > + <span className="break-anywhere"> {text.split(regex).map((part, i) => ( <Fragment key={i}> {part} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 915ceea1..995378ee 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -17,7 +17,7 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React, { useState } from 'react' +import React from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' @@ -25,7 +25,6 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' -import { useWindowSize } from 'web/hooks/use-window-size' import { CHALLENGES_ENABLED } from 'common/challenge' import { buildArray } from 'common/util/array' import TrophyIcon from 'web/lib/icons/trophy-icon' @@ -235,19 +234,22 @@ export default function Sidebar(props: { className?: string }) { })) return ( - <nav aria-label="Sidebar" className={className}> + <nav + aria-label="Sidebar" + className={clsx('flex max-h-[100vh] flex-col', className)} + > <ManifoldLogo className="py-6" twoLine /> <CreateQuestionButton user={user} /> <Spacer h={4} /> {user && ( - <div className="w-full" style={{ minHeight: 80 }}> + <div className="min-h-[80px] w-full"> <ProfileSummary user={user} /> </div> )} {/* Mobile navigation */} - <div className="space-y-1 lg:hidden"> + <div className="flex min-h-0 shrink flex-col gap-1 lg:hidden"> {mobileNavigationOptions.map((item) => ( <SidebarItem key={item.href} item={item} currentPage={currentPage} /> ))} @@ -266,7 +268,7 @@ export default function Sidebar(props: { className?: string }) { </div> {/* Desktop navigation */} - <div className="hidden space-y-1 lg:block"> + <div className="hidden min-h-0 shrink flex-col gap-1 lg:flex"> {navigationOptions.map((item) => ( <SidebarItem key={item.href} item={item} currentPage={currentPage} /> ))} @@ -286,10 +288,6 @@ export default function Sidebar(props: { className?: string }) { function GroupsList(props: { currentPage: string; memberItems: Item[] }) { const { currentPage, memberItems } = props - const { height } = useWindowSize() - const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) - const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0) - // const preferredNotifications = useUnseenPreferredNotifications( // privateUser, // { @@ -315,11 +313,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { currentPage={currentPage} /> - <div - className="flex-1 space-y-0.5 overflow-auto" - style={{ height: remainingHeight }} - ref={setContainerRef} - > + <div className="min-h-0 shrink space-y-0.5 overflow-auto"> {memberItems.map((item) => ( <a href={item.href} diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 3260018c..94b2b878 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -164,10 +164,7 @@ export function AnswerLabel(props: { return ( <Tooltip text={truncated === text ? false : text}> - <span - style={{ wordBreak: 'break-word' }} - className={clsx('whitespace-pre-line break-words', className)} - > + <span className={clsx('break-anywhere whitespace-pre-line', className)}> {truncated} </span> </Tooltip> diff --git a/web/components/page.tsx b/web/components/page.tsx index 1913eb7a..826206f4 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -6,13 +6,11 @@ import { Toaster } from 'react-hot-toast' export function Page(props: { rightSidebar?: ReactNode - suspend?: boolean className?: string rightSidebarClassName?: string children?: ReactNode }) { - const { children, rightSidebar, suspend, className, rightSidebarClassName } = - props + const { children, rightSidebar, className, rightSidebarClassName } = props const bottomBarPadding = 'pb-[58px] lg:pb-0 ' return ( @@ -23,10 +21,9 @@ export function Page(props: { bottomBarPadding, 'mx-auto w-full lg:grid lg:grid-cols-12 lg:gap-x-2 xl:max-w-7xl xl:gap-x-8' )} - style={suspend ? visuallyHiddenStyle : undefined} > <Toaster /> - <Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" /> + <Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" /> <main className={clsx( 'lg:col-span-8 lg:pt-6', @@ -46,22 +43,7 @@ export function Page(props: { </div> </aside> </div> - <BottomNavBar /> </> ) } - -const visuallyHiddenStyle = { - clip: 'rect(0 0 0 0)', - clipPath: 'inset(50%)', - height: 1, - margin: -1, - overflow: 'hidden', - padding: 0, - position: 'absolute', - width: 1, - whiteSpace: 'nowrap', - userSelect: 'none', - visibility: 'hidden', -} as const diff --git a/web/components/site-link.tsx b/web/components/site-link.tsx index ee12d519..f395e6a9 100644 --- a/web/components/site-link.tsx +++ b/web/components/site-link.tsx @@ -3,7 +3,7 @@ import { ReactNode } from 'react' import Link from 'next/link' export const linkClass = - 'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2' + 'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2' export const SiteLink = (props: { href: string @@ -19,7 +19,6 @@ export const SiteLink = (props: { className={clsx(linkClass, className)} href={href} target={href.startsWith('http') ? '_blank' : undefined} - style={{ /* For iOS safari */ wordBreak: 'break-word' }} onClick={(e) => { e.stopPropagation() if (onClick) onClick() diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 56a041f1..a20fc58a 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -148,10 +148,7 @@ export function UserPage(props: { user: User }) { <Col className="mx-4 -mt-6"> <Row className={'flex-wrap justify-between gap-y-2'}> <Col> - <span - className="text-2xl font-bold" - style={{ wordBreak: 'break-word' }} - > + <span className="break-anywhere text-2xl font-bold"> {user.name} </span> <span className="text-gray-500">@{user.username}</span> diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 3aa791ab..5464cdbe 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -33,7 +33,7 @@ const Home = (props: { auth: { user: User } | null }) => { return ( <> - <Page suspend={!!contract}> + <Page className={contract ? 'sr-only' : ''}> <Col className="mx-auto w-full p-2"> <ContractSearch user={user} diff --git a/web/pages/simulator.tsx b/web/pages/simulator.tsx index dc6ca873..756e483b 100644 --- a/web/pages/simulator.tsx +++ b/web/pages/simulator.tsx @@ -203,8 +203,7 @@ function NewBidTable(props: { <input type="number" placeholder="0" - className="input input-bordered" - style={{ maxWidth: 100 }} + className="input input-bordered max-w-[100px]" value={newBid.toString()} onChange={(e) => setNewBid(parseInt(e.target.value) || 0)} onKeyUp={(e) => { @@ -292,7 +291,7 @@ export default function Simulator() { YES </div> </h1> - <div className="mb-10 w-full" style={{ height: 500 }}> + <div className="mb-10 h-[500px] w-full"> <ResponsiveLine data={data} yScale={{ min: 0, max: 100, type: 'linear' }} diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 57c47843..bca0525a 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -375,11 +375,10 @@ export function FirebaseAnalytics() { </p> <Spacer h={4} /> <iframe - className="w-full" + className="w-full border-0" height={2200} src="https://datastudio.google.com/embed/reporting/faeaf3a4-c8da-4275-b157-98dad017d305/page/Gg3" frameBorder="0" - style={{ border: 0 }} allowFullScreen /> </> @@ -400,11 +399,10 @@ export function WasabiCharts() { </p> <Spacer h={4} /> <iframe - className="w-full" + className="w-full border-0" height={21000} src="https://wasabipesto.com/jupyter/manifold/" frameBorder="0" - style={{ border: 0 }} allowFullScreen /> </> diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 5fbc6c15..566404fa 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -56,6 +56,10 @@ module.exports = { display: 'none', }, }, + '.break-anywhere': { + 'overflow-wrap': 'anywhere', + 'word-break': 'break-word', // for Safari + }, }) }), ], From 5735864fd18b0be4887cc1b1f3448d1134f3e282 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 17:25:03 -0500 Subject: [PATCH 080/279] Add pencil to edit group on contract page --- web/components/contract/contract-details.tsx | 34 +++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 1e9d96d3..629c046c 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -188,18 +188,28 @@ export function ContractDetails(props: { ) : !groupToDisplay && !user ? ( <div /> ) : ( - <Button - size={'xs'} - className={'max-w-[200px]'} - color={'gray-white'} - onClick={() => - groupToDisplay - ? Router.push(groupPath(groupToDisplay.slug)) - : setOpen(!open) - } - > - {groupInfo} - </Button> + <Row> + <Button + size={'xs'} + className={'max-w-[200px] pr-1'} + color={'gray-white'} + onClick={() => + groupToDisplay + ? Router.push(groupPath(groupToDisplay.slug)) + : setOpen(!open) + } + > + {groupInfo} + </Button> + <Button + size={'xs'} + className={'!px-2'} + color={'gray-white'} + onClick={() => setOpen(!open)} + > + <PencilIcon className="inline h-5 w-5 shrink-0" /> + </Button> + </Row> )} </Row> <Modal open={open} setOpen={setOpen} size={'md'}> From 99bff6b7940be05dab20f548a746c42002fbd919 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 18:17:15 -0500 Subject: [PATCH 081/279] Improve group market selection UI --- web/components/contract/contract-card.tsx | 64 ++++++++++++----------- web/components/contract/quick-bet.tsx | 4 +- web/pages/group/[...slugs]/index.tsx | 9 +++- 3 files changed, 43 insertions(+), 34 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 6ada9b6f..4464063b 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -70,41 +70,14 @@ export function ContractCard(props: { return ( <Row className={clsx( - 'relative gap-3 rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100', + 'group relative gap-3 rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100', className )} > - <Col className="group relative flex-1 gap-3 py-4 pb-12 pl-6"> - {onClick ? ( - <a - className="absolute top-0 left-0 right-0 bottom-0" - href={contractPath(contract)} - onClick={(e) => { - // Let the browser handle the link click (opens in new tab). - if (e.ctrlKey || e.metaKey) return - - e.preventDefault() - track('click market card', { - slug: contract.slug, - contractId: contract.id, - }) - onClick() - }} - /> - ) : ( - <Link href={contractPath(contract)}> - <a - onClick={trackCallback('click market card', { - slug: contract.slug, - contractId: contract.id, - })} - className="absolute top-0 left-0 right-0 bottom-0" - /> - </Link> - )} + <Col className="relative flex-1 gap-3 py-4 pb-12 pl-6"> <AvatarDetails contract={contract} - className={'hidden md:inline-flex'} + className={'z-10 hidden md:inline-flex'} /> <p className={clsx( @@ -128,7 +101,7 @@ export function ContractCard(props: { ))} </Col> {showQuickBet ? ( - <QuickBet contract={contract} user={user} /> + <QuickBet contract={contract} user={user} className="z-10" /> ) : ( <> {outcomeType === 'BINARY' && ( @@ -177,6 +150,35 @@ export function ContractCard(props: { hideGroupLink={hideGroupLink} /> </Row> + + {/* Add click layer */} + {onClick ? ( + <a + className="absolute top-0 left-0 right-0 bottom-0" + href={contractPath(contract)} + onClick={(e) => { + // Let the browser handle the link click (opens in new tab). + if (e.ctrlKey || e.metaKey) return + + e.preventDefault() + track('click market card', { + slug: contract.slug, + contractId: contract.id, + }) + onClick() + }} + /> + ) : ( + <Link href={contractPath(contract)}> + <a + onClick={trackCallback('click market card', { + slug: contract.slug, + contractId: contract.id, + })} + className="absolute top-0 left-0 right-0 bottom-0" + /> + </Link> + )} </Row> ) } diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 7ef371f0..7b19306f 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -41,8 +41,9 @@ const BET_SIZE = 10 export function QuickBet(props: { contract: BinaryContract | PseudoNumericContract user: User + className?: string }) { - const { contract, user } = props + const { contract, user, className } = props const { mechanism, outcomeType } = contract const isCpmm = mechanism === 'cpmm-1' @@ -139,6 +140,7 @@ export function QuickBet(props: { return ( <Col className={clsx( + className, 'relative min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle' // Use this for colored QuickBet panes // `bg-opacity-10 bg-${color}` diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 6ce3e7c3..0e89529b 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -546,8 +546,13 @@ function AddContractButton(props: { group: Group; user: User }) { </Button> </div> - <Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}> - <Col className={' w-full gap-4 rounded-md bg-white'}> + <Modal + open={open} + setOpen={setOpen} + className={'max-w-4xl sm:p-0'} + size={'xl'} + > + <Col className={'min-h-screen w-full gap-4 rounded-md bg-white'}> <Col className="p-8 pb-0"> <div className={'text-xl text-indigo-700'}> Add a market to your group From 59aa76a474d6119b224bfad2bd7aa389cfafdfdd Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 26 Aug 2022 16:23:44 -0700 Subject: [PATCH 082/279] Add Salem Center tournaments (card screenshots) (#804) --- ...Chinese_Military_Action_against_Taiwan.png | Bin 0 -> 41005 bytes .../tournaments/_cspi/Monkeypox_Cases.png | Bin 0 -> 43701 bytes ...e_Court_Ban_Race_in_College_Admissions.png | Bin 0 -> 39943 bytes .../_cspi/Will_Elon_Buy_Twitter.png | Bin 0 -> 34259 bytes web/pages/tournaments/index.tsx | 69 ++++++++++++++++-- 5 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 web/pages/tournaments/_cspi/Chinese_Military_Action_against_Taiwan.png create mode 100644 web/pages/tournaments/_cspi/Monkeypox_Cases.png create mode 100644 web/pages/tournaments/_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png create mode 100644 web/pages/tournaments/_cspi/Will_Elon_Buy_Twitter.png diff --git a/web/pages/tournaments/_cspi/Chinese_Military_Action_against_Taiwan.png b/web/pages/tournaments/_cspi/Chinese_Military_Action_against_Taiwan.png new file mode 100644 index 0000000000000000000000000000000000000000..8acde90c8451b97dcad08fcc85e804542e1fe62f GIT binary patch literal 41005 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 literal 0 HcmV?d00001 diff --git a/web/pages/tournaments/_cspi/Monkeypox_Cases.png b/web/pages/tournaments/_cspi/Monkeypox_Cases.png new file mode 100644 index 0000000000000000000000000000000000000000..0efa3b1e8f4d2e37a527851075a2d14ee4674b46 GIT binary patch literal 43701 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%0AEdRCcntN8tD2aowC&PvlMfejOvkQCR_BgoE%gd2 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�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 a^P714!S*rYR#kYwM^jY?UVYmt?0*1h-uO!Z literal 0 HcmV?d00001 diff --git a/web/pages/tournaments/_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png b/web/pages/tournaments/_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png new file mode 100644 index 0000000000000000000000000000000000000000..09d3341f810ff1d334cb802e4eeda6a63033d6ff GIT binary patch literal 39943 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 literal 0 HcmV?d00001 diff --git a/web/pages/tournaments/_cspi/Will_Elon_Buy_Twitter.png b/web/pages/tournaments/_cspi/Will_Elon_Buy_Twitter.png new file mode 100644 index 0000000000000000000000000000000000000000..64ca4ff01c9b92ea9d34edaa34a7517ad6575d12 GIT binary patch literal 34259 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 literal 0 HcmV?d00001 diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index b93a9725..f30549f7 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -12,6 +12,7 @@ import customParseFormat from 'dayjs/plugin/customParseFormat' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { keyBy, mapValues, throttle } from 'lodash' +import Image, { ImageProps, StaticImageData } from 'next/image' import Link from 'next/link' import { ReactNode, useEffect, useRef, useState } from 'react' import { ContractCard } from 'web/components/contract/contract-card' @@ -22,6 +23,11 @@ import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' import { listContractsByGroupSlug } 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' dayjs.extend(utc) dayjs.extend(timezone) @@ -43,7 +49,30 @@ const Salem = { url: 'https://salemcenter.manifold.markets/', award: '$25,000', endTime: toDate('Jul 31, 2023'), -} as const + markets: [], + images: [ + { + marketUrl: + 'https://salemcenter.manifold.markets/SalemCenter/will-elon-musk-buy-twitter', + image: elon_pic, + }, + { + marketUrl: + 'https://salemcenter.manifold.markets/SalemCenter/chinese-military-action-against-tai', + image: china_pic, + }, + { + marketUrl: + 'https://salemcenter.manifold.markets/SalemCenter/over-100000-monkeypox-cases-in-2022', + image: mpox_pic, + }, + { + marketUrl: + 'https://salemcenter.manifold.markets/SalemCenter/over-100000-monkeypox-cases-in-2022', + image: race_pic, + }, + ], +} const tourneys: Tourney[] = [ { @@ -114,7 +143,7 @@ export default function TournamentPage(props: { markets={markets[groupId] ?? []} /> ))} - <Section {...Salem} markets={[]} /> + <Section {...Salem} /> </Col> </Page> ) @@ -128,8 +157,9 @@ function Section(props: { ppl?: number endTime?: Dayjs markets: Contract[] + images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi }) { - const { title, url, blurb, award, ppl, endTime, markets } = props + const { title, url, blurb, award, ppl, endTime, markets, images } = props return ( <div> @@ -171,15 +201,42 @@ function Section(props: { /> )) ) : ( - <div className="flex h-32 w-80 items-center justify-center rounded bg-white text-lg text-gray-700 shadow-md"> - Coming Soon... - </div> + <> + {images?.map(({ marketUrl, image }) => ( + <a href={marketUrl} className="hover:brightness-95"> + <NaturalImage src={image} /> + </a> + ))} + <SiteLink + className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700" + href={url} + > + See more + </SiteLink> + </> )} </Carousel> </div> ) } +// stole: https://stackoverflow.com/questions/66845889/next-js-image-how-to-maintain-aspect-ratio-and-add-letterboxes-when-needed +const NaturalImage = (props: ImageProps) => { + const [ratio, setRatio] = useState(4 / 1) + + return ( + <Image + {...props} + width={148 * ratio} + height={148} + layout="fixed" + onLoadingComplete={({ naturalWidth, naturalHeight }) => + setRatio(naturalWidth / naturalHeight) + } + /> + ) +} + function Carousel(props: { children: ReactNode; className?: string }) { const { children, className } = props From 1dbef921b02a6e6963973ad3e816b6e9a09de19d Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 26 Aug 2022 17:13:49 -0700 Subject: [PATCH 083/279] Sort markets on /tournaments by % --- web/pages/tournaments/index.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index f30549f7..8d500989 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -5,13 +5,18 @@ import { UsersIcon, } from '@heroicons/react/solid' import clsx from 'clsx' -import { Contract } from 'common/contract' +import { + BinaryContract, + Contract, + PseudoNumeric, + PseudoNumericContract, +} from 'common/contract' import { Group } from 'common/group' import dayjs, { Dayjs } from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { keyBy, mapValues, throttle } from 'lodash' +import { keyBy, mapValues, sortBy, throttle } from 'lodash' import Image, { ImageProps, StaticImageData } from 'next/image' import Link from 'next/link' import { ReactNode, useEffect, useRef, useState } from 'react' @@ -28,6 +33,7 @@ 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' dayjs.extend(utc) dayjs.extend(timezone) @@ -159,7 +165,13 @@ function Section(props: { markets: Contract[] images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi }) { - const { title, url, blurb, award, ppl, endTime, markets, images } = props + 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) return ( <div> From a2da319e7c729d3d2d5f94eace807ab9ab2f5493 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 26 Aug 2022 17:35:59 -0700 Subject: [PATCH 084/279] Remove unused import --- web/pages/tournaments/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 8d500989..feb4dd8d 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -8,7 +8,6 @@ import clsx from 'clsx' import { BinaryContract, Contract, - PseudoNumeric, PseudoNumericContract, } from 'common/contract' import { Group } from 'common/group' From 9698895c224da188ce64d8b76e9b9b22b22fe549 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 26 Aug 2022 17:39:46 -0700 Subject: [PATCH 085/279] Update fr chart colors --- web/components/answers/answers-graph.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index d35132be..dae3a8b5 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -109,11 +109,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: { }} colors={[ '#fca5a5', // red-300 - '#93c5fd', // blue-300 - '#86efac', // green-300 - '#f9a8d4', // pink-300 '#a5b4fc', // indigo-300 - '#fcd34d', // amber-300 + '#86efac', // green-300 + '#fef08a', // yellow-200 + '#fdba74', // orange-300 + '#c084fc', // purple-400 ]} pointSize={0} curve="stepAfter" From 902d9e140c68d3e2bc1f248f1dd189c3c29eb666 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 26 Aug 2022 20:18:08 -0700 Subject: [PATCH 086/279] Create and use new `usePagination` hook for paginating loading (#769) * Create and use new `usePagination` hook for paginating loading * Fix index for new comment list code --- firestore.indexes.json | 4 ++ web/components/comments-list.tsx | 119 ++++++++++++++++++------------- web/components/pagination.tsx | 59 ++++++++++----- web/hooks/use-pagination.ts | 109 ++++++++++++++++++++++++++++ web/lib/firebase/comments.ts | 9 ++- 5 files changed, 227 insertions(+), 73 deletions(-) create mode 100644 web/hooks/use-pagination.ts diff --git a/firestore.indexes.json b/firestore.indexes.json index 874344be..80b08996 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -40,6 +40,10 @@ "collectionGroup": "comments", "queryScope": "COLLECTION_GROUP", "fields": [ + { + "fieldPath": "commentType", + "order": "ASCENDING" + }, { "fieldPath": "userId", "order": "ASCENDING" diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 280787dd..12ae0649 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -1,8 +1,7 @@ -import { useEffect, useState } from 'react' - -import { Comment, ContractComment } from 'common/comment' +import { ContractComment } from 'common/comment' import { groupConsecutive } from 'common/util/array' -import { getUsersComments } from 'web/lib/firebase/comments' +import { getUserCommentsQuery } from 'web/lib/firebase/comments' +import { usePagination } from 'web/hooks/use-pagination' import { SiteLink } from './site-link' import { Row } from './layout/row' import { Avatar } from './avatar' @@ -11,10 +10,14 @@ import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' import { Content } from './editor' -import { Pagination } from './pagination' import { LoadingIndicator } from './loading-indicator' +import { PaginationNextPrev } from 'web/components/pagination' -const COMMENTS_PER_PAGE = 50 +type ContractKey = { + contractId: string + contractSlug: string + contractQuestion: string +} function contractPath(slug: string) { // by convention this includes the contract creator username, but we don't @@ -24,67 +27,83 @@ function contractPath(slug: string) { export function UserCommentsList(props: { user: User }) { const { user } = props - const [comments, setComments] = useState<ContractComment[] | undefined>() - const [page, setPage] = useState(0) - const start = page * COMMENTS_PER_PAGE - const end = start + COMMENTS_PER_PAGE - useEffect(() => { - getUsersComments(user.id).then((cs) => { - // we don't show comments in groups here atm, just comments on contracts - setComments( - cs.filter((c) => c.commentType == 'contract') as ContractComment[] - ) - }) - }, [user.id]) + const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 }) + const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page - if (comments == null) { + const pageComments = groupConsecutive(getItems(), (c) => { + return { + contractId: c.contractId, + contractQuestion: c.contractQuestion, + contractSlug: c.contractSlug, + } + }) + + if (isLoading) { return <LoadingIndicator /> } - const pageComments = groupConsecutive(comments.slice(start, end), (c) => { - return { question: c.contractQuestion, slug: c.contractSlug } - }) + if (pageComments.length === 0) { + if (isStart && isEnd) { + return <p>This user hasn't made any comments yet.</p> + } else { + // this can happen if their comment count is a multiple of page size + return <p>No more comments to display.</p> + } + } + return ( <Col className={'bg-white'}> {pageComments.map(({ key, items }, i) => { - return ( - <div key={start + i} className="border-b p-5"> - <SiteLink - className="mb-2 block pb-2 font-medium text-indigo-700" - href={contractPath(key.slug)} - > - {key.question} - </SiteLink> - <Col className="gap-6"> - {items.map((comment) => ( - <ProfileComment - key={comment.id} - comment={comment} - className="relative flex items-start space-x-3" - /> - ))} - </Col> - </div> - ) + return <ProfileCommentGroup key={i} groupKey={key} items={items} /> })} - <Pagination - page={page} - itemsPerPage={COMMENTS_PER_PAGE} - totalItems={comments.length} - setPage={setPage} - /> + <nav + className="border-t border-gray-200 px-4 py-3 sm:px-6" + aria-label="Pagination" + > + <PaginationNextPrev + prev={!isStart ? 'Previous' : null} + next={!isEnd ? 'Next' : null} + onClickPrev={getPrev} + onClickNext={getNext} + scrollToTop={true} + /> + </nav> </Col> ) } -function ProfileComment(props: { comment: Comment; className?: string }) { - const { comment, className } = props +function ProfileCommentGroup(props: { + groupKey: ContractKey + items: ContractComment[] +}) { + const { groupKey, items } = props + const { contractSlug, contractQuestion } = groupKey + const path = contractPath(contractSlug) + return ( + <div className="border-b p-5"> + <SiteLink + className="mb-2 block pb-2 font-medium text-indigo-700" + href={path} + > + {contractQuestion} + </SiteLink> + <Col className="gap-6"> + {items.map((c) => ( + <ProfileComment key={c.id} comment={c} /> + ))} + </Col> + </div> + ) +} + +function ProfileComment(props: { comment: ContractComment }) { + const { comment } = props const { text, content, userUsername, userName, userAvatarUrl, createdTime } = comment // TODO: find and attach relevant bets by comment betId at some point return ( - <Row className={className}> + <Row className="relative flex items-start space-x-3"> <Avatar username={userUsername} avatarUrl={userAvatarUrl} /> <div className="min-w-0 flex-1"> <p className="mt-0.5 text-sm text-gray-500"> diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index 5f3d4da2..8c008ab0 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -1,6 +1,40 @@ +import { ReactNode } from 'react' import clsx from 'clsx' import { Spacer } from './layout/spacer' +import { Row } from './layout/row' +export function PaginationNextPrev(props: { + className?: string + prev?: ReactNode + next?: ReactNode + onClickPrev: () => void + onClickNext: () => void + scrollToTop?: boolean +}) { + const { className, prev, next, onClickPrev, onClickNext, scrollToTop } = props + return ( + <Row className={clsx(className, 'flex-1 justify-between sm:justify-end')}> + {prev != null && ( + <a + href={scrollToTop ? '#' : undefined} + className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={onClickPrev} + > + {prev ?? 'Previous'} + </a> + )} + {next != null && ( + <a + href={scrollToTop ? '#' : undefined} + className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={onClickNext} + > + {next ?? 'Next'} + </a> + )} + </Row> + ) +} export function Pagination(props: { page: number itemsPerPage: number @@ -44,24 +78,13 @@ export function Pagination(props: { of <span className="font-medium">{totalItems}</span> results </p> </div> - <div className="flex flex-1 justify-between sm:justify-end"> - {page > 0 && ( - <a - href={scrollToTop ? '#' : undefined} - className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page > 0 && setPage(page - 1)} - > - {prevTitle ?? 'Previous'} - </a> - )} - <a - href={scrollToTop ? '#' : undefined} - className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page < maxPage && setPage(page + 1)} - > - {nextTitle ?? 'Next'} - </a> - </div> + <PaginationNextPrev + prev={page > 0 ? prevTitle ?? 'Previous' : null} + next={page < maxPage ? nextTitle ?? 'Next' : null} + onClickPrev={() => setPage(page - 1)} + onClickNext={() => setPage(page + 1)} + scrollToTop={scrollToTop} + /> </nav> ) } diff --git a/web/hooks/use-pagination.ts b/web/hooks/use-pagination.ts new file mode 100644 index 00000000..485afca8 --- /dev/null +++ b/web/hooks/use-pagination.ts @@ -0,0 +1,109 @@ +// adapted from https://github.com/premshree/use-pagination-firestore + +import { useEffect, useReducer } from 'react' +import { + Query, + QuerySnapshot, + QueryDocumentSnapshot, + queryEqual, + limit, + onSnapshot, + query, + startAfter, +} from 'firebase/firestore' + +interface State<T> { + baseQ: Query<T> + docs: QueryDocumentSnapshot<T>[] + pageStart: number + pageEnd: number + pageSize: number + isLoading: boolean + isComplete: boolean +} + +type ActionBase<K, V = void> = V extends void ? { type: K } : { type: K } & V + +type Action<T> = + | ActionBase<'INIT', { opts: PaginationOptions<T> }> + | ActionBase<'LOAD', { snapshot: QuerySnapshot<T> }> + | ActionBase<'PREV'> + | ActionBase<'NEXT'> + +const getReducer = + <T>() => + (state: State<T>, action: Action<T>): State<T> => { + switch (action.type) { + case 'INIT': { + return getInitialState(action.opts) + } + case 'LOAD': { + const docs = state.docs.concat(action.snapshot.docs) + const isComplete = action.snapshot.docs.length < state.pageSize + return { ...state, docs, isComplete, isLoading: false } + } + case 'PREV': { + const { pageStart, pageSize } = state + const prevStart = pageStart - pageSize + const isLoading = false + return { ...state, isLoading, pageStart: prevStart, pageEnd: pageStart } + } + case 'NEXT': { + const { docs, pageEnd, isComplete, pageSize } = state + const nextEnd = pageEnd + pageSize + const isLoading = !isComplete && docs.length < nextEnd + return { ...state, isLoading, pageStart: pageEnd, pageEnd: nextEnd } + } + default: + throw new Error('Invalid action.') + } + } + +export type PaginationOptions<T> = { q: Query<T>; pageSize: number } + +const getInitialState = <T>(opts: PaginationOptions<T>): State<T> => { + return { + baseQ: opts.q, + docs: [], + pageStart: 0, + pageEnd: opts.pageSize, + pageSize: opts.pageSize, + isLoading: true, + isComplete: false, + } +} + +export const usePagination = <T>(opts: PaginationOptions<T>) => { + const [state, dispatch] = useReducer(getReducer<T>(), opts, getInitialState) + + useEffect(() => { + // save callers the effort of ref-izing their opts by checking for + // deep equality over here + if (queryEqual(opts.q, state.baseQ) && opts.pageSize === state.pageSize) { + return + } + dispatch({ type: 'INIT', opts }) + }, [opts, state.baseQ, state.pageSize]) + + useEffect(() => { + if (state.isLoading) { + const lastDoc = state.docs[state.docs.length - 1] + const nextQ = lastDoc + ? query(state.baseQ, startAfter(lastDoc), limit(state.pageSize)) + : query(state.baseQ, limit(state.pageSize)) + return onSnapshot(nextQ, (snapshot) => { + dispatch({ type: 'LOAD', snapshot }) + }) + } + }, [state.isLoading, state.baseQ, state.docs, state.pageSize]) + + return { + isLoading: state.isLoading, + isStart: state.pageStart === 0, + isEnd: state.isComplete && state.pageEnd >= state.docs.length, + getPrev: () => dispatch({ type: 'PREV' }), + getNext: () => dispatch({ type: 'NEXT' }), + getItems: () => + state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()), + } +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index f7c947fe..70785858 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -1,4 +1,5 @@ import { + Query, collection, collectionGroup, doc, @@ -148,12 +149,10 @@ export function listenForRecentComments( return listenForValues<Comment>(recentCommentsQuery, setComments) } -const getUsersCommentsQuery = (userId: string) => +export const getUserCommentsQuery = (userId: string) => query( collectionGroup(db, 'comments'), where('userId', '==', userId), + where('commentType', '==', 'contract'), orderBy('createdTime', 'desc') - ) -export async function getUsersComments(userId: string) { - return await getValues<Comment>(getUsersCommentsQuery(userId)) -} + ) as Query<ContractComment> From e4d6bb35b53032d1720b5812088b4b6a308d49a3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 23:10:10 -0500 Subject: [PATCH 087/279] Fix floating button to be on top of quick bet arrows. Switch icon. --- web/pages/home.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 5464cdbe..265fd79a 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' -import { PlusSmIcon } from '@heroicons/react/solid' +import { PencilAltIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' @@ -50,13 +50,13 @@ const Home = (props: { auth: { user: User } | null }) => { </Col> <button type="button" - className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" + className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" onClick={() => { router.push('/create') track('mobile create button') }} > - <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> + <PencilAltIcon className="h-7 w-7" aria-hidden="true" /> </button> </Page> From 86cf956894ac82907bc79d741b13118f0a5a3027 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 23:49:03 -0500 Subject: [PATCH 088/279] Add eslint plugin to remove unused imports --- common/.eslintrc.js | 3 ++- functions/.eslintrc.js | 3 ++- package.json | 10 ++++++---- web/.eslintrc.js | 3 ++- web/hooks/use-follows.ts | 2 +- yarn.lock | 12 ++++++++++++ 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/common/.eslintrc.js b/common/.eslintrc.js index c6f9703e..5212207a 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: ['eslint:recommended'], ignorePatterns: ['lib'], env: { @@ -26,6 +26,7 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], + 'unused-imports/no-unused-imports': 'error', }, }, ], diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 2c607231..55070858 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: ['eslint:recommended'], ignorePatterns: ['dist', 'lib'], env: { @@ -26,6 +26,7 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], + 'unused-imports/no-unused-imports': 'error', }, }, ], diff --git a/package.json b/package.json index 05924ef0..e90daf86 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,22 @@ "web" ], "scripts": { - "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)" + "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)", + "lint": "eslint common --fix ; eslint web --fix ; eslint functions --fix" }, "dependencies": {}, "devDependencies": { + "@types/node": "16.11.11", "@typescript-eslint/eslint-plugin": "5.25.0", "@typescript-eslint/parser": "5.25.0", - "@types/node": "16.11.11", "concurrently": "6.5.1", "eslint": "8.15.0", "eslint-plugin-lodash": "^7.4.0", + "eslint-plugin-unused-imports": "^2.0.0", + "nodemon": "2.0.19", "prettier": "2.5.0", - "typescript": "4.6.4", "ts-node": "10.9.1", - "nodemon": "2.0.19" + "typescript": "4.6.4" }, "resolutions": { "@types/react": "17.0.43" diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 0f103080..56f12813 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { parser: '@typescript-eslint/parser', - plugins: ['lodash'], + plugins: ['lodash', 'unused-imports'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', @@ -22,6 +22,7 @@ module.exports = { '@next/next/no-typos': 'off', 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], + 'unused-imports/no-unused-imports': 'error', }, env: { browser: true, diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts index 2b418658..c23543a7 100644 --- a/web/hooks/use-follows.ts +++ b/web/hooks/use-follows.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' -import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts' +import { listenForContractFollows } from 'web/lib/firebase/contracts' export const useFollows = (userId: string | null | undefined) => { const [followIds, setFollowIds] = useState<string[] | undefined>() diff --git a/yarn.lock b/yarn.lock index f49b1ccf..e0e1eefa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5865,6 +5865,18 @@ eslint-plugin-react@^7.29.4: semver "^6.3.0" string.prototype.matchall "^4.0.7" +eslint-plugin-unused-imports@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-2.0.0.tgz#d8db8c4d0cfa0637a8b51ce3fd7d1b6bc3f08520" + integrity sha512-3APeS/tQlTrFa167ThtP0Zm0vctjr4M44HMpeg1P4bK6wItarumq0Ma82xorMKdFsWpphQBlRPzw/pxiVELX1A== + dependencies: + eslint-rule-composer "^0.3.0" + +eslint-rule-composer@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" + integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== + eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" From b88f9a4fc1eba7fd2362e077919fd4c82a493d8d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 23:56:38 -0500 Subject: [PATCH 089/279] Set up github action to remove unused imports --- .github/workflows/lint.yml | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..27a54149 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Run linter (remove unused imports) + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + +env: + FORCE_COLOR: 3 + NEXT_TELEMETRY_DISABLED: 1 + +# mqp - i generated a personal token to use for these writes -- it's unclear +# why, but the default token didn't work, even when i gave it max permissions + +jobs: + lint: + name: Auto-lint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + token: ${{ secrets.FORMATTER_ACCESS_TOKEN }} + - name: Restore cached node_modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} + - name: Install missing dependencies + run: yarn install --prefer-offline --frozen-lockfile + - name: Run lint script + run: yarn lint + - name: Commit any lint changes + if: always() + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Auto-remove unused imports + branch: ${{ github.head_ref }} From 8e4dd407f653c97ed2586636ff6ecdbeed00ea2a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 26 Aug 2022 23:57:37 -0500 Subject: [PATCH 090/279] Test with unused import --- web/hooks/use-follows.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts index c23543a7..4ca61f30 100644 --- a/web/hooks/use-follows.ts +++ b/web/hooks/use-follows.ts @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' -import { listenForContractFollows } from 'web/lib/firebase/contracts' +import { + listenForContractFollows, + getActiveContracts, +} from 'web/lib/firebase/contracts' export const useFollows = (userId: string | null | undefined) => { const [followIds, setFollowIds] = useState<string[] | undefined>() From f641569bcc96fe347d6e184957f92235e74c2d2f Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@users.noreply.github.com> Date: Sat, 27 Aug 2022 05:00:37 +0000 Subject: [PATCH 091/279] Auto-remove unused imports --- web/hooks/use-follows.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts index 4ca61f30..1986b30d 100644 --- a/web/hooks/use-follows.ts +++ b/web/hooks/use-follows.ts @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' import { listenForContractFollows, - getActiveContracts, } from 'web/lib/firebase/contracts' export const useFollows = (userId: string | null | undefined) => { From 5ff847fba31de9027c34689eb840e27e56c55677 Mon Sep 17 00:00:00 2001 From: mqp <mqp@users.noreply.github.com> Date: Sat, 27 Aug 2022 05:01:29 +0000 Subject: [PATCH 092/279] Auto-prettification --- web/hooks/use-follows.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts index 1986b30d..c23543a7 100644 --- a/web/hooks/use-follows.ts +++ b/web/hooks/use-follows.ts @@ -1,8 +1,6 @@ import { useEffect, useState } from 'react' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' -import { - listenForContractFollows, -} from 'web/lib/firebase/contracts' +import { listenForContractFollows } from 'web/lib/firebase/contracts' export const useFollows = (userId: string | null | undefined) => { const [followIds, setFollowIds] = useState<string[] | undefined>() From 2e3c2d4dcb567c05b97fff9b2b482e65807d2001 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 27 Aug 2022 00:59:00 -0500 Subject: [PATCH 093/279] Tweak to add market to group UI --- web/components/contract/contract-card.tsx | 2 +- web/pages/group/[...slugs]/index.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 4464063b..34d9d4a6 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -77,7 +77,7 @@ export function ContractCard(props: { <Col className="relative flex-1 gap-3 py-4 pb-12 pl-6"> <AvatarDetails contract={contract} - className={'z-10 hidden md:inline-flex'} + className={'hidden md:inline-flex'} /> <p className={clsx( diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 0e89529b..e1679e84 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -552,7 +552,9 @@ function AddContractButton(props: { group: Group; user: User }) { className={'max-w-4xl sm:p-0'} size={'xl'} > - <Col className={'min-h-screen w-full gap-4 rounded-md bg-white'}> + <Col + className={'min-h-screen w-full max-w-4xl gap-4 rounded-md bg-white'} + > <Col className="p-8 pb-0"> <div className={'text-xl text-indigo-700'}> Add a market to your group From a040df2732439bdc8a9ac836a77fb24d87e8a97a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 27 Aug 2022 01:02:59 -0500 Subject: [PATCH 094/279] Fix console error from non-react-style attributes on trophy icon --- web/lib/icons/trophy-icon.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/icons/trophy-icon.tsx b/web/lib/icons/trophy-icon.tsx index c845a0af..93a0e05c 100644 --- a/web/lib/icons/trophy-icon.tsx +++ b/web/lib/icons/trophy-icon.tsx @@ -4,13 +4,13 @@ export default function TrophyIcon(props: React.SVGProps<SVGSVGElement>) { xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentcolor" - stroke-width="2" + strokeWidth="2" {...props} > <g> <path d="m6,5c0,4 1.4,7.8 3.5,8.5l0,2c-1.2,0.7 -1.2,1 -1.6,4l8,0c-0.4,-3 -0.4,-3.3 -1.6,-4l0,-2c2.1,-0.7 3.5,-4.5 3.5,-8.5z" - stroke-linejoin="round" + strokeLinejoin="round" fill="none" /> <path From a9ea335cd191468df47b665bd2bdf03ef95505b5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 27 Aug 2022 01:07:39 -0500 Subject: [PATCH 095/279] Fix create page serverside vs clientside render discrepancy console error --- common/util/time.ts | 3 ++- web/pages/create.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/common/util/time.ts b/common/util/time.ts index 914949e4..9afb8db4 100644 --- a/common/util/time.ts +++ b/common/util/time.ts @@ -1,2 +1,3 @@ -export const HOUR_MS = 60 * 60 * 1000 +export const MINUTE_MS = 60 * 1000 +export const HOUR_MS = 60 * MINUTE_MS export const DAY_MS = 24 * HOUR_MS diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 0c142d67..463f0e9f 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -33,6 +33,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' 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' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } @@ -427,7 +428,7 @@ export function NewContract(props: { className="input input-bordered mt-4" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)} - min={Date.now()} + min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} disabled={isSubmitting} value={closeDate} /> From 51ceb62871ee8f64719159be1467d2c261561fab Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 27 Aug 2022 01:14:24 -0500 Subject: [PATCH 096/279] Fix console error on create page --- web/pages/create.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 463f0e9f..30d22b0b 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -210,7 +210,9 @@ export function NewContract(props: { max: MAX_DESCRIPTION_LENGTH, placeholder: descriptionPlaceholder, disabled: isSubmitting, - defaultValue: JSON.parse(params?.description ?? '{}'), + defaultValue: params?.description + ? JSON.parse(params.description) + : undefined, }) const isEditorFilled = editor != null && !editor.isEmpty From 3e976eadaceaf3bcbefd029b51d26da38126cb48 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 27 Aug 2022 01:09:01 -0700 Subject: [PATCH 097/279] Make portfolio graph loading more efficient (#805) * Make portfolio graph on profile not load extra data * Clean up unused props * Tidy up markup * Enable "daily" option again on portfolio history picker --- .../portfolio/portfolio-value-graph.tsx | 26 +----- .../portfolio/portfolio-value-section.tsx | 88 +++++++++---------- web/lib/firebase/users.ts | 3 +- 3 files changed, 49 insertions(+), 68 deletions(-) diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index d1dae0bd..61a1ce8b 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -1,7 +1,6 @@ import { ResponsiveLine } from '@nivo/line' import { PortfolioMetrics } from 'common/user' import { formatMoney } from 'common/util/format' -import { DAY_MS } from 'common/util/time' import { last } from 'lodash' import { memo } from 'react' import { useWindowSize } from 'web/hooks/use-window-size' @@ -10,28 +9,12 @@ import { formatTime } from 'web/lib/util/time' export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { portfolioHistory: PortfolioMetrics[] height?: number - period?: string + includeTime?: boolean }) { - const { portfolioHistory, height, period } = props - + const { portfolioHistory, height, includeTime } = props const { width } = useWindowSize() - const portfolioHistoryFiltered = portfolioHistory.filter((p) => { - switch (period) { - case 'daily': - return p.timestamp > Date.now() - 1 * DAY_MS - case 'weekly': - return p.timestamp > Date.now() - 7 * DAY_MS - case 'monthly': - return p.timestamp > Date.now() - 30 * DAY_MS - case 'allTime': - return true - default: - return true - } - }) - - const points = portfolioHistoryFiltered.map((p) => { + const points = portfolioHistory.map((p) => { return { x: new Date(p.timestamp), y: p.balance + p.investmentValue, @@ -41,7 +24,6 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { const numXTickValues = !width || width < 800 ? 2 : 5 const numYTickValues = 4 const endDate = last(points)?.x - const includeTime = period === 'daily' return ( <div className="w-full overflow-hidden" @@ -66,7 +48,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { colors={{ datum: 'color' }} axisBottom={{ tickValues: numXTickValues, - format: (time) => formatTime(+time, includeTime), + format: (time) => formatTime(+time, !!includeTime), }} pointBorderColor="#fff" pointSize={points.length > 100 ? 0 : 6} diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index 604873e9..706630b2 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -6,70 +6,68 @@ import { Period, getPortfolioHistory } from 'web/lib/firebase/users' import { Col } from '../layout/col' import { Row } from '../layout/row' import { PortfolioValueGraph } from './portfolio-value-graph' +import { DAY_MS } from 'common/util/time' + +const periodToCutoff = (now: number, period: Period) => { + switch (period) { + case 'daily': + return now - 1 * DAY_MS + case 'weekly': + return now - 7 * DAY_MS + case 'monthly': + return now - 30 * DAY_MS + case 'allTime': + default: + return new Date(0) + } +} export const PortfolioValueSection = memo( - function PortfolioValueSection(props: { - userId: string - disableSelector?: boolean - }) { - const { disableSelector, userId } = props + function PortfolioValueSection(props: { userId: string }) { + const { userId } = props const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly') const [portfolioHistory, setUsersPortfolioHistory] = useState< PortfolioMetrics[] >([]) - useEffect(() => { - getPortfolioHistory(userId).then(setUsersPortfolioHistory) - }, [userId]) - const lastPortfolioMetrics = last(portfolioHistory) + useEffect(() => { + const cutoff = periodToCutoff(Date.now(), portfolioPeriod).valueOf() + getPortfolioHistory(userId, cutoff).then(setUsersPortfolioHistory) + }, [portfolioPeriod, userId]) + + const lastPortfolioMetrics = last(portfolioHistory) if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { return <></> } - // PATCH: If portfolio history started on June 1st, then we label it as "Since June" - // instead of "All time" - const allTimeLabel = - lastPortfolioMetrics.timestamp < Date.parse('2022-06-20T00:00:00.000Z') - ? 'Since June' - : 'All time' + const { balance, investmentValue } = lastPortfolioMetrics + const totalValue = balance + investmentValue return ( - <div> + <> <Row className="gap-8"> - <div className="mb-4 w-full"> - <Col - className={disableSelector ? 'items-center justify-center' : ''} - > - <div className="text-sm text-gray-500">Portfolio value</div> - <div className="text-lg"> - {formatMoney( - lastPortfolioMetrics.balance + - lastPortfolioMetrics.investmentValue - )} - </div> - </Col> - </div> - {!disableSelector && ( - <select - className="select select-bordered self-start" - value={portfolioPeriod} - onChange={(e) => { - setPortfolioPeriod(e.target.value as Period) - }} - > - <option value="allTime">{allTimeLabel}</option> - <option value="weekly">Last 7d</option> - {/* Note: 'daily' seems to be broken? */} - {/* <option value="daily">Last 24h</option> */} - </select> - )} + <Col className="flex-1 justify-center"> + <div className="text-sm text-gray-500">Portfolio value</div> + <div className="text-lg">{formatMoney(totalValue)}</div> + </Col> + <select + className="select select-bordered self-start" + value={portfolioPeriod} + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">All time</option> + <option value="weekly">Last 7d</option> + <option value="daily">Last 24h</option> + </select> </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} - period={portfolioPeriod} + includeTime={portfolioPeriod == 'daily'} /> - </div> + </> ) } ) diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 6cfee163..bad13c8c 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -252,11 +252,12 @@ export async function unfollow(userId: string, unfollowedUserId: string) { await deleteDoc(followDoc) } -export async function getPortfolioHistory(userId: string) { +export async function getPortfolioHistory(userId: string, since: number) { return getValues<PortfolioMetrics>( query( collectionGroup(db, 'portfolioHistory'), where('userId', '==', userId), + where('timestamp', '>=', since), orderBy('timestamp', 'asc') ) ) From 5d8f5d41fc4351618e4435df93b511e97b2aca2a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 27 Aug 2022 01:09:17 -0700 Subject: [PATCH 098/279] Fix some efficiency problems with `ContractProbGraph` (#806) * Memoize bets input to ContractOverview * Optimize a bunch of nonsense in `ContractProbGraph` --- .../contract/contract-prob-graph.tsx | 69 ++++++++----------- web/pages/[username]/[contractSlug].tsx | 11 +-- 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index ab2393f0..aad44b82 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -16,6 +16,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { }) { const { contract, height } = props const { resolutionTime, closeTime, outcomeType } = contract + const now = Date.now() const isBinary = outcomeType === 'BINARY' const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale @@ -23,10 +24,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const startProb = getInitialProbability(contract) - const times = [ - contract.createdTime, - ...bets.map((bet) => bet.createdTime), - ].map((time) => new Date(time)) + const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)] const f: (p: number) => number = isBinary ? (p) => p @@ -36,17 +34,17 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) - const isClosed = !!closeTime && Date.now() > closeTime + const isClosed = !!closeTime && now > closeTime const latestTime = dayjs( resolutionTime && isClosed ? Math.min(resolutionTime, closeTime) : isClosed ? closeTime - : resolutionTime ?? Date.now() + : resolutionTime ?? now ) // Add a fake datapoint so the line continues to the right - times.push(latestTime.toDate()) + times.push(latestTime.valueOf()) probs.push(probs[probs.length - 1]) const quartiles = [0, 25, 50, 75, 100] @@ -58,16 +56,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const { width } = useWindowSize() const numXTickValues = !width || width < 800 ? 2 : 5 - const startDate = times[0] - const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours').toDate() - : latestTime.toDate() - const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 + const startDate = dayjs(times[0]) + const endDate = startDate.add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours') + : latestTime + const includeMinute = endDate.diff(startDate, 'hours') < 2 // Minimum number of points for the graph to have. For smooth tooltip movement - // On first load, width is undefined, skip adding extra points to let page load faster + // If we aren't actually loading any data yet, skip adding extra points to let page load faster // This fn runs again once DOM is finished loading - const totalPoints = width ? (width > 800 ? 300 : 50) : 1 + const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1 const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints @@ -75,20 +73,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const s = isBinary ? 100 : 1 for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: s * probs[i] } - const numPoints: number = Math.floor( - dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep - ) + const p = probs[i] + const d0 = times[i] + const d1 = times[i + 1] + const msDiff = d1 - d0 + const numPoints = Math.floor(msDiff / timeStep) + points.push({ x: new Date(times[i]), y: s * p }) if (numPoints > 1) { - const thisTimeStep: number = - dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / numPoints + const thisTimeStep: number = msDiff / numPoints for (let n = 1; n < numPoints; n++) { - points[points.length] = { - x: dayjs(times[i]) - .add(thisTimeStep * n, 'ms') - .toDate(), - y: s * probs[i], - } + points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p }) } } } @@ -97,8 +91,8 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, ] - const multiYear = !dayjs(startDate).isSame(latestTime, 'year') - const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) + const multiYear = !startDate.isSame(latestTime, 'year') + const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime) const formatter = isBinary ? formatPercent @@ -133,16 +127,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { }} xScale={{ type: 'time', - min: startDate, - max: endDate, + min: startDate.toDate(), + max: endDate.toDate(), }} xFormat={(d) => - formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) + formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, format: (time) => - formatTime(+time, multiYear, lessThanAWeek, includeMinute), + formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), }} colors={{ datum: 'color' }} curve="stepAfter" @@ -178,23 +172,20 @@ function formatPercent(y: DatumValue) { } function formatTime( + now: number, time: number, includeYear: boolean, includeHour: boolean, includeMinute: boolean ) { const d = dayjs(time) - - if ( - d.add(1, 'minute').isAfter(Date.now()) && - d.subtract(1, 'minute').isBefore(Date.now()) - ) + if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) return 'Now' let format: string - if (d.isSame(Date.now(), 'day')) { + if (d.isSame(now, 'day')) { format = '[Today]' - } else if (d.add(1, 'day').isSame(Date.now(), 'day')) { + } else if (d.add(1, 'day').isSame(now, 'day')) { format = '[Yesterday]' } else { format = 'MMM D' diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 8250bde9..d70f711b 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { ArrowLeftIcon } from '@heroicons/react/outline' import { useContractWithPreload } from 'web/hooks/use-contract' @@ -165,6 +165,10 @@ export function ContractPageContent( }) const bets = useBets(contract.id) ?? props.bets + const nonChallengeBets = useMemo( + () => bets.filter((b) => !b.challengeSlug), + [bets] + ) // Sort for now to see if bug is fixed. comments.sort((c1, c2) => c1.createdTime - c2.createdTime) @@ -220,10 +224,7 @@ export function ContractPageContent( </button> )} - <ContractOverview - contract={contract} - bets={bets.filter((b) => !b.challengeSlug)} - /> + <ContractOverview contract={contract} bets={nonChallengeBets} /> {outcomeType === 'NUMERIC' && ( <AlertBox From 305acbb18fa8605b73f3932c94b162688e428ed4 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 27 Aug 2022 14:17:19 -0500 Subject: [PATCH 099/279] "current value" => "expected value" --- web/components/bets-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index c3058a45..1a31a900 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -520,7 +520,7 @@ export function BetsSummary(props: { ) : ( <Col> <div className="whitespace-nowrap text-sm text-gray-500"> - Current value + Expected value </div> <div className="whitespace-nowrap">{formatMoney(payout)}</div> </Col> From eeed9eef1020699f08239711d4b4ba8881b383da Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 27 Aug 2022 14:38:09 -0500 Subject: [PATCH 100/279] market resolution email: link in header image, show only non-negative investment amount --- .../src/email-templates/market-resolved.html | 575 +++++++----------- functions/src/emails.ts | 19 +- 2 files changed, 230 insertions(+), 364 deletions(-) diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index e8b090b5..c1ff3beb 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -1,84 +1,91 @@ <!DOCTYPE html> -<html - style=" +<html style=" font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; - " -> - <head> - <meta name="viewport" content="width=device-width" /> - <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> - <title>Market resolved + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - - + {{creatorName}} asked + + + - - - + {{question}} + + + - - - + Resolved {{outcome}} + + + + - - -
+ - Manifold Markets -
+ - {{creatorName}} asked -
+ - + - {{question}} -
+ -

+

- Resolved {{outcome}} -

-
+ - +
- + - - - + + + - - -
+ - Dear {{name}}, -
+ Dear {{name}}, +
-
+
- A market you bet in has been resolved! -
+ A market you bet in has been resolved! +
-
+
- Your investment was - M$ {{investment}}. -
+ Your investment was + {{investment}}. +
-
+
- Your payout is - M$ {{payout}}. -
+ Your payout is + {{payout}}. +
-
+
- Thanks, -
+ Thanks, +
- Manifold Team -
+ Manifold Team +
-
+
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + +
- - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index e6e52090..ff313794 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -53,22 +53,29 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` - // const creatorPayoutText = - // userId === creator.id - // ? ` (plus ${formatMoney(creatorPayout)} in commissions)` - // : '' + const creatorPayoutText = + creatorPayout >= 1 && userId === creator.id + ? ` (plus ${formatMoney(creatorPayout)} in commissions)` + : '' const emailType = 'market-resolved' const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const displayedInvestment = + Number.isNaN(investment) || investment < 0 + ? formatMoney(0) + : formatMoney(investment) + + const displayedPayout = formatMoney(payout) + const templateData: market_resolved_template = { userId: user.id, name: user.name, creatorName: creator.name, question: contract.question, outcome, - investment: `${Math.floor(investment)}`, - payout: `${Math.floor(payout)}`, + investment: displayedInvestment, + payout: displayedPayout + creatorPayoutText, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, unsubscribeUrl, } From 4b513a894d74cd502848e3eb5b16987ed1573e55 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sat, 27 Aug 2022 13:46:35 -0700 Subject: [PATCH 101/279] Make tooltip rendering more efficient (#807) * Don't use very slow dayjs formatter on timestamp tooltips * Kill dead code in feed-bets.tsx * Clean up tooltip markup --- web/components/contract/contract-details.tsx | 4 +- web/components/datetime-tooltip.tsx | 15 ++-- web/components/feed/copy-link-date-time.tsx | 4 +- web/components/feed/feed-bets.tsx | 81 +------------------- web/components/relative-timestamp.tsx | 2 +- web/components/tooltip.tsx | 13 +--- web/pages/tournaments/index.tsx | 2 +- 7 files changed, 16 insertions(+), 105 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 629c046c..354f4394 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -233,7 +233,7 @@ export function ContractDetails(props: { {resolvedDate} @@ -322,7 +322,7 @@ function EditableCloseDate(props: { ) : ( Date.now() ? 'Trading ends:' : 'Trading ended:'} - time={dayJsCloseTime} + time={closeTime} > {isSameYear ? dayJsCloseTime.format('MMM D') diff --git a/web/components/datetime-tooltip.tsx b/web/components/datetime-tooltip.tsx index d820e728..c81d5d88 100644 --- a/web/components/datetime-tooltip.tsx +++ b/web/components/datetime-tooltip.tsx @@ -1,15 +1,12 @@ -import dayjs, { Dayjs } from 'dayjs' -import utc from 'dayjs/plugin/utc' -import timezone from 'dayjs/plugin/timezone' -import advanced from 'dayjs/plugin/advancedFormat' import { Tooltip } from './tooltip' -dayjs.extend(utc) -dayjs.extend(timezone) -dayjs.extend(advanced) +const FORMATTER = new Intl.DateTimeFormat('default', { + dateStyle: 'medium', + timeStyle: 'long', +}) export function DateTimeTooltip(props: { - time: Dayjs + time: number text?: string className?: string children?: React.ReactNode @@ -17,7 +14,7 @@ export function DateTimeTooltip(props: { }) { const { className, time, text, noTap } = props - const formattedTime = time.format('MMM DD, YYYY hh:mm a z') + const formattedTime = FORMATTER.format(time) const toolTip = text ? `${text} ${formattedTime}` : formattedTime return ( diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index 8238d3e3..cea8300a 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -7,7 +7,6 @@ import { fromNow } from 'web/lib/util/time' import { ToastClipboard } from 'web/components/toast-clipboard' import { LinkIcon } from '@heroicons/react/outline' import clsx from 'clsx' -import dayjs from 'dayjs' export function CopyLinkDateTimeComponent(props: { prefix: string @@ -18,7 +17,6 @@ export function CopyLinkDateTimeComponent(props: { }) { const { prefix, slug, elementId, createdTime, className } = props const [showToast, setShowToast] = useState(false) - const time = dayjs(createdTime) function copyLinkToComment( event: React.MouseEvent @@ -32,7 +30,7 @@ export function CopyLinkDateTimeComponent(props: { } return (
- + copyLinkToComment(event)} diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index ffa53de3..e4200593 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -6,13 +6,10 @@ import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' -import { UsersIcon } from '@heroicons/react/solid' import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' -import React, { Fragment, useEffect } from 'react' -import { uniqBy, partition, sumBy, groupBy } from 'lodash' -import { JoinSpans } from 'web/components/join-spans' +import React, { useEffect } from 'react' import { UserLink } from '../user-page' import { formatNumericProbability } from 'common/pseudo-numeric' import { SiteLink } from 'web/components/site-link' @@ -154,79 +151,3 @@ export function BetStatusText(props: {
) } - -function BetGroupSpan(props: { - contract: Contract - bets: Bet[] - outcome?: string -}) { - const { contract, bets, outcome } = props - - const numberTraders = uniqBy(bets, (b) => b.userId).length - - const [buys, sells] = partition(bets, (bet) => bet.amount >= 0) - const buyTotal = sumBy(buys, (b) => b.amount) - const sellTotal = sumBy(sells, (b) => -b.amount) - - return ( - - {numberTraders} {numberTraders > 1 ? 'traders' : 'trader'}{' '} - - {buyTotal > 0 && <>bought {formatMoney(buyTotal)} } - {sellTotal > 0 && <>sold {formatMoney(sellTotal)} } - - {outcome && ( - <> - {' '} - of{' '} - - - )}{' '} - - ) -} - -export function FeedBetGroup(props: { - contract: Contract - bets: Bet[] - hideOutcome: boolean -}) { - const { contract, bets, hideOutcome } = props - - const betGroups = groupBy(bets, (bet) => bet.outcome) - const outcomes = Object.keys(betGroups) - - // Use the time of the last bet for the entire group - const createdTime = bets[bets.length - 1].createdTime - - return ( - <> -
-
-
-
-
-
-
-
- {outcomes.map((outcome, index) => ( - - - {index !== outcomes.length - 1 &&
} -
- ))} - -
-
- - ) -} diff --git a/web/components/relative-timestamp.tsx b/web/components/relative-timestamp.tsx index bd029cf6..53cf2a3a 100644 --- a/web/components/relative-timestamp.tsx +++ b/web/components/relative-timestamp.tsx @@ -8,7 +8,7 @@ export function RelativeTimestamp(props: { time: number }) { return ( {dayJsTime.fromNow()} diff --git a/web/components/tooltip.tsx b/web/components/tooltip.tsx index ef8f5bb8..4dd1f6e2 100644 --- a/web/components/tooltip.tsx +++ b/web/components/tooltip.tsx @@ -11,7 +11,6 @@ import { useRole, } from '@floating-ui/react-dom-interactions' import { Transition } from '@headlessui/react' -import clsx from 'clsx' import { ReactNode, useRef, useState } from 'react' // See https://floating-ui.com/docs/react-dom @@ -58,14 +57,10 @@ export function Tooltip(props: { }[placement.split('-')[0]] as string return text ? ( -
-
+ <> + {children} -
+ {/* conditionally render tooltip and fade in/out */} -
+ ) : ( <>{children} ) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index feb4dd8d..ec952356 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -188,7 +188,7 @@ function Section(props: { )} {endTime && ( - + {endTime.format('MMM D')} From d7793841d14fdde6cd406244de59ce8630858b4e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Aug 2022 17:13:29 -0500 Subject: [PATCH 102/279] Fix NaN invested (floating point error) --- common/calculate.ts | 2 ++ web/components/bets-list.tsx | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/common/calculate.ts b/common/calculate.ts index 758fc3cd..da4ce13a 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -140,6 +140,8 @@ function getCpmmInvested(yourBets: Bet[]) { const sortedBets = sortBy(yourBets, 'createdTime') for (const bet of sortedBets) { const { outcome, shares, amount } = bet + if (floatingEqual(shares, 0)) continue + if (amount > 0) { totalShares[outcome] = (totalShares[outcome] ?? 0) + shares totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 1a31a900..4ac873b4 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -405,7 +405,8 @@ export function BetsSummary(props: { const isClosed = closeTime && Date.now() > closeTime const bets = props.bets.filter((b) => !b.isAnte) - const { hasShares } = getContractBetMetrics(contract, bets) + const { hasShares, invested, profitPercent, payout, profit, totalShares } = + getContractBetMetrics(contract, bets) const excludeSalesAndAntes = bets.filter( (b) => !b.isAnte && !b.isSold && !b.sale @@ -416,8 +417,6 @@ export function BetsSummary(props: { const noWinnings = sumBy(excludeSalesAndAntes, (bet) => calculatePayout(contract, bet, 'NO') ) - const { invested, profitPercent, payout, profit, totalShares } = - getContractBetMetrics(contract, bets) const [showSellModal, setShowSellModal] = useState(false) const user = useUser() From a80d1f194c157de48ec502f61e1b2903dfb9836e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Aug 2022 17:14:41 -0500 Subject: [PATCH 103/279] Don't redeem shares if there's only epsilon shares to redeem --- functions/src/redeem-shares.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 0a69521f..055aa2dc 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' import { User } from '../../common/user' +import { floatingEqual } from 'common/util/math' export const redeemShares = async (userId: string, contractId: string) => { return await firestore.runTransaction(async (trans) => { @@ -21,7 +22,7 @@ export const redeemShares = async (userId: string, contractId: string) => { const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) - if (netAmount === 0) { + if (floatingEqual(netAmount, 0)) { return { status: 'success' } } const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) From 36fa9078f5082ed469b21d2ba1b7f3eda8a3cc4c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Aug 2022 17:18:39 -0500 Subject: [PATCH 104/279] Fix absolute import within functions --- functions/src/redeem-shares.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 055aa2dc..08cc16f2 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -5,7 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' import { User } from '../../common/user' -import { floatingEqual } from 'common/util/math' +import { floatingEqual } from '../../common/util/math' export const redeemShares = async (userId: string, contractId: string) => { return await firestore.runTransaction(async (trans) => { From ef77c7c9a3de446975b53ee6485a7b8d2f3377da Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sat, 27 Aug 2022 19:05:46 -0700 Subject: [PATCH 105/279] Clean up markup in CopyLinkDateTimeComponent (#809) --- web/components/feed/copy-link-date-time.tsx | 36 ++++++++------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index cea8300a..c5f943a1 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -6,7 +6,6 @@ import Link from 'next/link' import { fromNow } from 'web/lib/util/time' import { ToastClipboard } from 'web/components/toast-clipboard' import { LinkIcon } from '@heroicons/react/outline' -import clsx from 'clsx' export function CopyLinkDateTimeComponent(props: { prefix: string @@ -29,26 +28,19 @@ export function CopyLinkDateTimeComponent(props: { setTimeout(() => setShowToast(false), 2000) } return ( -
+ + + + {fromNow(createdTime)} + {showToast && } + + + + ) } From b21051ced57db36403ec066a23c84370f3aaf94f Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sat, 27 Aug 2022 19:15:55 -0700 Subject: [PATCH 106/279] Fix up copy link toast styling --- web/components/feed/copy-link-date-time.tsx | 2 +- web/components/toast-clipboard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index c5f943a1..c4e69655 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -37,7 +37,7 @@ export function CopyLinkDateTimeComponent(props: { } > {fromNow(createdTime)} - {showToast && } + {showToast && } diff --git a/web/components/toast-clipboard.tsx b/web/components/toast-clipboard.tsx index 7a909c51..387acaa5 100644 --- a/web/components/toast-clipboard.tsx +++ b/web/components/toast-clipboard.tsx @@ -10,7 +10,7 @@ export function ToastClipboard(props: { className?: string }) { className={clsx( 'border-base-300 absolute items-center' + 'gap-2 divide-x divide-gray-200 rounded-md border-2 bg-white ' + - 'h-15 w-[15rem] p-2 pr-3 text-gray-500', + 'h-15 z-10 w-[15rem] p-2 pr-3 text-gray-500', className )} > From f31db2f9eda71aafae413ed240636b616765faf5 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 27 Aug 2022 22:15:09 -0500 Subject: [PATCH 107/279] emails: make banner a link --- .../src/email-templates/market-answer.html | 456 ++++++-------- .../src/email-templates/market-close.html | 559 +++++++----------- .../src/email-templates/market-comment.html | 460 ++++++-------- 3 files changed, 566 insertions(+), 909 deletions(-) diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index 225436ad..1f7fa5fa 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -1,84 +1,91 @@ - - - - - Market answer + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - -
+ - Manifold Markets -
+ - +
- + - - - + {{name}} + + + + - - - + {{answer}} + + + + - - -
+ -
- +
+ - {{name}} -
-
+ -
+
- {{answer}} -
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + +
- - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 711f7ccb..01a53e98 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -1,84 +1,91 @@ - - - - - Market closed + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - - + You asked + + + - - - + {{question}} + + + - - - + Market closed + + + + - - -
+ - Manifold Markets -
+ - You asked -
+ - + - {{question}} -
+ -

+

- Market closed -

-
+ - +
- + - - - + + + - - -
+ - Hi {{name}}, -
+ Hi {{name}}, +
-
+
- A market you created has closed. It's attracted - {{volume}} - in bets — congrats! -
+ A market you created has closed. It's attracted + {{volume}} + in bets — congrats! +
-
+
- Resolve your market to earn {{creatorFee}} as the - creator commission. -
+ Resolve your market to earn {{creatorFee}} as the + creator commission. +
-
+
- Thanks, -
+ Thanks, +
- Manifold Team -
+ Manifold Team +
-
+
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + +
- - - - - - + " valign="top"> + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 84118964..0b5b9a54 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -1,84 +1,91 @@ - - - - - Market comment + "> - - - - +
- + - - + + +
+ -
+
- +
- + - - -
+ - +
- + - - - + + Manifold Markets + + + + - - -
+ - Manifold Markets -
+ - +
- + - - - + {{commentorName}} + {{betDescription}} + + + + - - - + {{comment}} + + + + - - -
+ -
- +
+ - {{commentorName}} - {{betDescription}} -
-
+ -
+
- {{comment}} -
-
-
- - +
+ -
-
-
- +
+ + + + + + + + ">unsubscribe. + + +
- - - - - - + " valign="top"> + + + + + \ No newline at end of file From e7f369e2b4afe9041a1112f9c4a4857a8a09992e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Aug 2022 21:40:22 -0500 Subject: [PATCH 108/279] Load recommended markets even when navigating from home --- web/pages/[username]/[contractSlug].tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index d70f711b..026597e3 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -154,7 +154,7 @@ export function ContractPageContent( user?: User | null } ) { - const { backToHome, comments, user, recommendedContracts } = props + const { backToHome, comments, user } = props const contract = useContractWithPreload(props.contract) ?? props.contract @@ -186,6 +186,17 @@ export function ContractPageContent( setShowConfetti(shouldSeeConfetti) }, [contract, user]) + const [recommendedContracts, setRecommendedMarkets] = useState( + props.recommendedContracts + ) + useEffect(() => { + if (recommendedContracts.length === 0) { + getRandTopCreatorContracts(contract.creatorId, 4, [contract.id]).then( + setRecommendedMarkets + ) + } + }, [contract.id, contract.creatorId, recommendedContracts]) + const { isResolved, question, outcomeType } = contract const allowTrade = tradingAllowed(contract) From cb08a114aeed8671c777fe14be032170558c7877 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 27 Aug 2022 22:26:37 -0500 Subject: [PATCH 109/279] Better recommended contracts. Include from first group. --- web/lib/firebase/contracts.ts | 42 +++++++++++++++++++++++-- web/pages/[username]/[contractSlug].tsx | 21 ++++++------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 6dc2ee3e..d3f18f54 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -12,7 +12,7 @@ import { updateDoc, where, } from 'firebase/firestore' -import { sortBy, sum } from 'lodash' +import { sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' import { BinaryContract, Contract } from 'common/contract' @@ -305,7 +305,7 @@ export const getRandTopCreatorContracts = async ( where('isResolved', '==', false), where('creatorId', '==', creatorId), orderBy('popularityScore', 'desc'), - limit(Math.max(count * 2, 15)) + limit(count * 2) ) const data = await getValues(creatorContractsQuery) const open = data @@ -315,6 +315,44 @@ export const getRandTopCreatorContracts = async ( return chooseRandomSubset(open, count) } +export const getRandTopGroupContracts = async ( + groupSlug: string, + count: number, + excluding: string[] = [] +) => { + const creatorContractsQuery = query( + contracts, + where('groupSlugs', 'array-contains', groupSlug), + where('isResolved', '==', false), + orderBy('popularityScore', 'desc'), + limit(count * 2) + ) + const data = await getValues(creatorContractsQuery) + const open = data + .filter((c) => c.closeTime && c.closeTime > Date.now()) + .filter((c) => !excluding.includes(c.id)) + + return chooseRandomSubset(open, count) +} + +export const getRecommendedContracts = async ( + contract: Contract, + count: number +) => { + const { creatorId, groupSlugs, id } = contract + + const [userContracts, groupContracts] = await Promise.all([ + getRandTopCreatorContracts(creatorId, count, [id]), + groupSlugs && groupSlugs[0] + ? getRandTopGroupContracts(groupSlugs[0], count, [id]) + : [], + ]) + + const combined = uniqBy([...userContracts, ...groupContracts], (c) => c.id) + + return chooseRandomSubset(combined, count) +} + export async function getRecentBetsAndComments(contract: Contract) { const contractDoc = doc(contracts, contract.id) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 026597e3..1561eb01 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -11,7 +11,7 @@ import { Spacer } from 'web/components/layout/spacer' import { Contract, getContractFromSlug, - getRandTopCreatorContracts, + getRecommendedContracts, tradingAllowed, } from 'web/lib/firebase/contracts' import { SEO } from 'web/components/SEO' @@ -40,8 +40,8 @@ import { ContractLeaderboard, ContractTopTrades, } from 'web/components/contract/contract-leaderboard' -import { Subtitle } from 'web/components/subtitle' import { ContractsGrid } from 'web/components/contract/contracts-grid' +import { Title } from 'web/components/title' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -54,9 +54,7 @@ export async function getStaticPropz(props: { const [bets, comments, recommendedContracts] = await Promise.all([ contractId ? listAllBets(contractId) : [], contractId ? listAllComments(contractId) : [], - contract - ? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id]) - : [], + contract ? getRecommendedContracts(contract, 6) : [], ]) return { @@ -190,12 +188,11 @@ export function ContractPageContent( props.recommendedContracts ) useEffect(() => { - if (recommendedContracts.length === 0) { - getRandTopCreatorContracts(contract.creatorId, 4, [contract.id]).then( - setRecommendedMarkets - ) + if (contract && recommendedContracts.length === 0) { + getRecommendedContracts(contract, 6).then(setRecommendedMarkets) } - }, [contract.id, contract.creatorId, recommendedContracts]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contract.id, recommendedContracts]) const { isResolved, question, outcomeType } = contract @@ -282,8 +279,8 @@ export function ContractPageContent( {recommendedContracts.length > 0 && ( - - + + <ContractsGrid contracts={recommendedContracts} /> </Col> )} From e4f46c48f1243c8faae2949d8edb6ffa173905c8 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 27 Aug 2022 22:35:46 -0500 Subject: [PATCH 110/279] Fix recommended markets not updating when navigating --- web/pages/[username]/[contractSlug].tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 1561eb01..3667511e 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -106,7 +106,9 @@ export default function ContractPage(props: { return <Custom404 /> } - return <ContractPageContent {...{ ...props, contract, user }} /> + return ( + <ContractPageContent key={contract.id} {...{ ...props, contract, user }} /> + ) } export function ContractPageSidebar(props: { From 9dd23b4a088ee75015adf2e9c95dd7e7983175c1 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 28 Aug 2022 00:11:13 -0500 Subject: [PATCH 111/279] Fix weird new crash in updateMetrics: contract.id missing? --- functions/src/update-metrics.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index a2e72053..9ef3fb10 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -55,16 +55,18 @@ export const updateMetricsCore = async () => { const now = Date.now() const betsByContract = groupBy(bets, (bet) => bet.contractId) - const contractUpdates = contracts.map((contract) => { - const contractBets = betsByContract[contract.id] ?? [] - return { - doc: firestore.collection('contracts').doc(contract.id), - fields: { - volume24Hours: computeVolume(contractBets, now - DAY_MS), - volume7Days: computeVolume(contractBets, now - DAY_MS * 7), - }, - } - }) + const contractUpdates = contracts + .filter((contract) => contract.id) + .map((contract) => { + const contractBets = betsByContract[contract.id] ?? [] + return { + doc: firestore.collection('contracts').doc(contract.id), + fields: { + volume24Hours: computeVolume(contractBets, now - DAY_MS), + volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + }, + } + }) await writeAsync(firestore, contractUpdates) log(`Updated metrics for ${contracts.length} contracts.`) From 2acc1a8433c7427977e2a380828a27dc8e1e13e8 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 28 Aug 2022 00:11:28 -0500 Subject: [PATCH 112/279] =?UTF-8?q?Double=20daily=20loans=20rate=20to=202%?= =?UTF-8?q?=20=F0=9F=92=B0=F0=9F=92=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/loans.ts | 2 +- web/components/profile/loans-modal.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/common/loans.ts b/common/loans.ts index cb956c09..05b64474 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -10,7 +10,7 @@ import { import { PortfolioMetrics, User } from './user' import { filterDefined } from './util/array' -const LOAN_DAILY_RATE = 0.01 +const LOAN_DAILY_RATE = 0.02 const calculateNewLoan = (investedValue: number, loanTotal: number) => { const netValue = investedValue - loanTotal diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 945fb6fe..07853162 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -15,7 +15,7 @@ export function LoansModal(props: { <Col className={'gap-2'}> <span className={'text-indigo-700'}>• What are daily loans?</span> <span className={'ml-2'}> - Every day at midnight PT, get 1% of your total bet amount back as a + Every day at midnight PT, get 2% of your total bet amount back as a loan. </span> <span className={'text-indigo-700'}> @@ -34,12 +34,12 @@ export function LoansModal(props: { </span> <span className={'text-indigo-700'}>• What is an example?</span> <span className={'ml-2'}> - For example, if you bet M$1000 on "Will I become a millionare?" on - Monday, you will get M$10 back on Tuesday. + For example, if you bet M$1000 on "Will I become a millionare?", you + will get M$20 back tomorrow. </span> <span className={'ml-2'}> - Previous loans count against your total bet amount. So on Wednesday, - you would get back 1% of M$990 = M$9.9. + Previous loans count against your total bet amount. So on the third + day, you would get back 2% of M$(1000 - 20) = M$19.6. </span> </Col> </Col> From 03e07037ea43d54fef0e421589c526e883ab97a8 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Sun, 28 Aug 2022 00:23:25 -0500 Subject: [PATCH 113/279] ban users from posting (#810) --- common/user.ts | 1 + web/components/answers/create-answer-panel.tsx | 2 ++ web/components/create-question-button.tsx | 3 +++ web/components/feed/feed-comments.tsx | 2 ++ web/components/groups/create-group-button.tsx | 2 ++ web/pages/create.tsx | 12 ++++++++++++ 6 files changed, 22 insertions(+) diff --git a/common/user.ts b/common/user.ts index 48a3d59c..e3c9d181 100644 --- a/common/user.ts +++ b/common/user.ts @@ -44,6 +44,7 @@ export type User = { currentBettingStreak?: number hasSeenContractFollowModal?: boolean freeMarketsCreated?: number + isBannedFromPosting?: boolean } export type PrivateUser = { diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index ce266778..6290cf44 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -115,6 +115,8 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + if (user?.isBannedFromPosting) return <></> + return ( <Col className="gap-4 rounded"> <Col className="flex-1 gap-2"> diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index 1b8ac11e..0ea28635 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -18,6 +18,9 @@ export const CreateQuestionButton = (props: { const { user, overrideText, className, query } = props const router = useRouter() + + if (user?.isBannedFromPosting) return <></> + return ( <div className={clsx('flex justify-center', className)}> {user ? ( diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 0541a7ba..c3332373 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -382,6 +382,8 @@ export function CommentInput(props: { const isNumeric = contract.outcomeType === 'NUMERIC' + if (user?.isBannedFromPosting) return <></> + return ( <> <Row className={'mb-2 gap-1 sm:gap-2'}> diff --git a/web/components/groups/create-group-button.tsx b/web/components/groups/create-group-button.tsx index 360c4ea8..435dc741 100644 --- a/web/components/groups/create-group-button.tsx +++ b/web/components/groups/create-group-button.tsx @@ -76,6 +76,8 @@ export function CreateGroupButton(props: { } } + if (user.isBannedFromPosting) return <></> + return ( <ConfirmationButton openModalBtn={{ diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 30d22b0b..26709417 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -67,6 +67,18 @@ export default function Create(props: { auth: { user: User } }) { if (!router.isReady) return <div /> + if (user.isBannedFromPosting) + return ( + <Page> + <div className="mx-auto w-full max-w-2xl"> + <div className="rounded-lg px-6 py-4 sm:py-0"> + <Title className="!mt-0" text="Create a market" /> + <p>Sorry, you are currently banned from creating a market.</p> + </div> + </div> + </Page> + ) + return ( <Page> <SEO From 7c798a063c3dc1f783fd31c7c8b90cc6b60ec6bb Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 28 Aug 2022 00:35:22 -0500 Subject: [PATCH 114/279] Improve edit close date UI --- web/components/contract/contract-details.tsx | 35 ++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 354f4394..7705b538 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -191,7 +191,7 @@ export function ContractDetails(props: { <Row> <Button size={'xs'} - className={'max-w-[200px] pr-1'} + className={'max-w-[200px] pr-2'} color={'gray-white'} onClick={() => groupToDisplay @@ -203,11 +203,10 @@ export function ContractDetails(props: { </Button> <Button size={'xs'} - className={'!px-2'} color={'gray-white'} onClick={() => setOpen(!open)} > - <PencilIcon className="inline h-5 w-5 shrink-0" /> + <PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" /> </Button> </Row> )} @@ -277,14 +276,22 @@ function EditableCloseDate(props: { const [isEditingCloseTime, setIsEditingCloseTime] = useState(false) const [closeDate, setCloseDate] = useState( - closeTime && dayJsCloseTime.format('YYYY-MM-DDTHH:mm') + closeTime && dayJsCloseTime.format('YYYY-MM-DD') ) + const [closeHoursMinutes, setCloseHoursMinutes] = useState( + closeTime && dayJsCloseTime.format('HH:mm') + ) + + const newCloseTime = closeDate + ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() + : undefined const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year') const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day') const onSave = () => { - const newCloseTime = dayjs(closeDate).valueOf() + if (!newCloseTime) return + if (newCloseTime === closeTime) setIsEditingCloseTime(false) else if (newCloseTime > Date.now()) { const content = contract.description @@ -309,16 +316,24 @@ function EditableCloseDate(props: { return ( <> {isEditingCloseTime ? ( - <div className="form-control mr-1 items-start"> + <Row className="mr-1 items-start"> <input - type="datetime-local" + type="date" className="input input-bordered" onClick={(e) => e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value || '')} + onChange={(e) => setCloseDate(e.target.value)} min={Date.now()} value={closeDate} /> - </div> + <input + type="time" + className="input input-bordered ml-2" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setCloseHoursMinutes(e.target.value)} + min="00:00" + value={closeHoursMinutes} + /> + </Row> ) : ( <DateTimeTooltip text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'} @@ -342,7 +357,7 @@ function EditableCloseDate(props: { color={'gray-white'} onClick={() => setIsEditingCloseTime(true)} > - <PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit + <PencilIcon className="!container mr-0.5 mb-0.5 inline h-4 w-4" /> </Button> ))} </> From 1e11491369be78fe00647b9b218fbffa98d0bd54 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sun, 28 Aug 2022 01:43:13 -0700 Subject: [PATCH 115/279] Tidy up rendering of info tooltips --- web/components/info-tooltip.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/info-tooltip.tsx b/web/components/info-tooltip.tsx index 1c12d8e2..b2e8e917 100644 --- a/web/components/info-tooltip.tsx +++ b/web/components/info-tooltip.tsx @@ -4,8 +4,8 @@ import { Tooltip } from './tooltip' export function InfoTooltip(props: { text: string }) { const { text } = props return ( - <Tooltip text={text}> - <InformationCircleIcon className="h-5 w-5 text-gray-500" /> + <Tooltip className="inline-block" text={text}> + <InformationCircleIcon className="-mb-1 h-5 w-5 text-gray-500" /> </Tooltip> ) } From 98861ccc192ef2ddb3ddc8edcff59fe2184c554b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 13:27:51 -0500 Subject: [PATCH 116/279] remove typo --- web/components/contract/contract-info-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 5c66aa4c..6c004a37 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -32,7 +32,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const isDev = useDev() const isAdmin = useAdmin() - const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z') + const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = contract From 133e7a9c3fc8b03c757f03cde5ca931472b52b1a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 13:30:27 -0500 Subject: [PATCH 117/279] change label to admin --- web/components/contract/contract-info-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 6c004a37..092afad3 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -150,7 +150,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { {/* Show a path to Firebase if user is an admin, or we're on localhost */} {(isAdmin || isDev) && ( <tr> - <td>[DEV] Firestore</td> + <td>[ADMIN] Firestore</td> <td> <SiteLink href={firestoreConsolePath(id)}> Console link From d63dd1205629005bf2c377a2f06d478d7dd49220 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 13:32:59 -0500 Subject: [PATCH 118/279] admin unlisted toggle --- web/components/contract/contract-info-dialog.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 092afad3..f418db06 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -170,6 +170,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { </td> </tr> )} + {isAdmin && ( + <tr> + <td>[ADMIN] Unlisted</td> + <td> + <ShortToggle + enabled={contract.visibility === 'unlisted'} + setEnabled={(b) => + updateContract(id, { + visibility: b ? 'unlisted' : 'public', + }) + } + /> + </td> + </tr> + )} </tbody> </table> From 3d073da97e7fd7c6a381f340e82abd4128054fc9 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 14:07:19 -0500 Subject: [PATCH 119/279] hide quick bet on mobile --- web/components/contract/contract-card.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 34d9d4a6..e5ea724b 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -32,6 +32,7 @@ import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' import { getMappedValue } from 'common/pseudo-numeric' import { Tooltip } from '../tooltip' +import { useWindowSize } from 'web/hooks/use-window-size' export function ContractCard(props: { contract: Contract @@ -61,7 +62,11 @@ export function ContractCard(props: { const marketClosed = (contract.closeTime || Infinity) < Date.now() || !!resolution + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 768 + const showQuickBet = + !isMobile && user && !marketClosed && (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && From 9c15d5b96c8428968de78b4d077efb0e41d384c1 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 28 Aug 2022 15:20:21 -0500 Subject: [PATCH 120/279] React-query-ify notifications (#812) * Use single react query to subscribe to notifications * Remove 'preferred' in variable names --- web/components/groups/group-chat.tsx | 23 ++++--- web/components/notifications-icon.tsx | 4 +- web/hooks/use-notifications.ts | 89 ++++++++------------------- web/lib/firebase/notifications.ts | 16 ----- web/pages/notifications.tsx | 35 +++-------- 5 files changed, 51 insertions(+), 116 deletions(-) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 781705c2..244a3ffe 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -19,7 +19,7 @@ import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' import { Content, useTextEditor } from 'web/components/editor' -import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' +import { useUnseenNotifications } from 'web/hooks/use-notifications' import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' import { usePrivateUser } from 'web/hooks/use-user' @@ -277,14 +277,18 @@ function GroupChatNotificationsIcon(props: { hidden: boolean }) { const { privateUser, group, shouldSetAsSeen, hidden } = props - const preferredNotificationsForThisGroup = useUnseenPreferredNotifications( - privateUser, - { - customHref: `/group/${group.slug}`, - } + const notificationsForThisGroup = useUnseenNotifications( + privateUser + // Disabled tracking by customHref for now. + // { + // customHref: `/group/${group.slug}`, + // } ) + useEffect(() => { - preferredNotificationsForThisGroup.forEach((notification) => { + if (!notificationsForThisGroup) return + + notificationsForThisGroup.forEach((notification) => { if ( (shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) || // old style chat notif that simply ended with the group slug @@ -293,13 +297,14 @@ function GroupChatNotificationsIcon(props: { setNotificationsAsSeen([notification]) } }) - }, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen]) + }, [group.slug, notificationsForThisGroup, shouldSetAsSeen]) return ( <div className={ !hidden && - preferredNotificationsForThisGroup.length > 0 && + notificationsForThisGroup && + notificationsForThisGroup.length > 0 && !shouldSetAsSeen ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' : 'hidden' diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index dbdad6a9..55284e96 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -4,7 +4,7 @@ import { Row } from 'web/components/layout/row' import { useEffect, useState } from 'react' import { usePrivateUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' -import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications' +import { useUnseenGroupedNotification } from 'web/hooks/use-notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { PrivateUser } from 'common/user' @@ -30,7 +30,7 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) { else setSeen(false) }, [router.pathname]) - const notifications = useUnseenPreferredNotificationGroups(privateUser) + const notifications = useUnseenGroupedNotification(privateUser) if (!notifications || notifications.length === 0 || seen) { return <div /> } diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 32500943..d02d3d30 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,13 +1,9 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { notification_subscribe_types, PrivateUser } from 'common/user' import { Notification } from 'common/notification' -import { - getNotificationsQuery, - listenForNotifications, -} from 'web/lib/firebase/notifications' +import { getNotificationsQuery } from 'web/lib/firebase/notifications' import { groupBy, map, partition } from 'lodash' import { useFirestoreQueryData } from '@react-query-firebase/firestore' -import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' export type NotificationGroup = { notifications: Notification[] @@ -17,49 +13,48 @@ export type NotificationGroup = { type: 'income' | 'normal' } -// For some reason react-query subscriptions don't actually listen for notifications -// Use useUnseenPreferredNotificationGroups to listen for new notifications -export function usePreferredGroupedNotifications( - privateUser: PrivateUser, - cachedNotifications?: Notification[] -) { +function useNotifications(privateUser: PrivateUser) { const result = useFirestoreQueryData( ['notifications-all', privateUser.id], - getNotificationsQuery(privateUser.id) + getNotificationsQuery(privateUser.id), + { subscribe: true, includeMetadataChanges: true }, + // Temporary workaround for react-query bug: + // https://github.com/invertase/react-query-firebase/issues/25 + { cacheTime: 0 } ) const notifications = useMemo(() => { - if (result.isLoading) return cachedNotifications ?? [] - if (!result.data) return cachedNotifications ?? [] + if (!result.data) return undefined const notifications = result.data as Notification[] return getAppropriateNotifications( notifications, privateUser.notificationPreferences ).filter((n) => !n.isSeenOnHref) - }, [ - cachedNotifications, - privateUser.notificationPreferences, - result.data, - result.isLoading, - ]) + }, [privateUser.notificationPreferences, result.data]) + return notifications +} + +export function useUnseenNotifications(privateUser: PrivateUser) { + const notifications = useNotifications(privateUser) + return useMemo( + () => notifications && notifications.filter((n) => !n.isSeen), + [notifications] + ) +} + +export function useGroupedNotifications(privateUser: PrivateUser) { + const notifications = useNotifications(privateUser) return useMemo(() => { if (notifications) return groupNotifications(notifications) }, [notifications]) } -export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) { - const notifications = useUnseenPreferredNotifications(privateUser, {}) - const [notificationGroups, setNotificationGroups] = useState< - NotificationGroup[] | undefined - >(undefined) - useEffect(() => { - if (!notifications) return - - const groupedNotifications = groupNotifications(notifications) - setNotificationGroups(groupedNotifications) +export function useUnseenGroupedNotification(privateUser: PrivateUser) { + const notifications = useUnseenNotifications(privateUser) + return useMemo(() => { + if (notifications) return groupNotifications(notifications) }, [notifications]) - return notificationGroups } export function groupNotifications(notifications: Notification[]) { @@ -114,36 +109,6 @@ export function groupNotifications(notifications: Notification[]) { return notificationGroups } -export function useUnseenPreferredNotifications( - privateUser: PrivateUser, - options: { customHref?: string }, - limit: number = NOTIFICATIONS_PER_PAGE -) { - const { customHref } = options - const [notifications, setNotifications] = useState<Notification[]>([]) - const [userAppropriateNotifications, setUserAppropriateNotifications] = - useState<Notification[]>([]) - - useEffect(() => { - return listenForNotifications(privateUser.id, setNotifications, { - unseenOnly: true, - limit, - }) - }, [limit, privateUser.id]) - - useEffect(() => { - const notificationsToShow = getAppropriateNotifications( - notifications, - privateUser.notificationPreferences - ).filter((n) => - customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref - ) - setUserAppropriateNotifications(notificationsToShow) - }, [notifications, customHref, privateUser.notificationPreferences]) - - return userAppropriateNotifications -} - const lessPriorityReasons = [ 'on_contract_with_users_comment', 'on_contract_with_users_answer', diff --git a/web/lib/firebase/notifications.ts b/web/lib/firebase/notifications.ts index d2db3665..38d93402 100644 --- a/web/lib/firebase/notifications.ts +++ b/web/lib/firebase/notifications.ts @@ -1,7 +1,5 @@ import { collection, limit, orderBy, query, where } from 'firebase/firestore' -import { Notification } from 'common/notification' import { db } from 'web/lib/firebase/init' -import { listenForValues } from 'web/lib/firebase/utils' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' export function getNotificationsQuery( @@ -23,17 +21,3 @@ export function getNotificationsQuery( limit(NOTIFICATIONS_PER_PAGE * 10) ) } - -export function listenForNotifications( - userId: string, - setNotifications: (notifs: Notification[]) => void, - unseenOnlyOptions?: { unseenOnly: boolean; limit: number } -) { - return listenForValues<Notification>( - getNotificationsQuery(userId, unseenOnlyOptions), - (notifs) => { - notifs.sort((n1, n2) => n2.createdTime - n1.createdTime) - setNotifications(notifs) - } - ) -} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index bfd18f7f..85cbcbae 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -26,7 +26,7 @@ import { } from 'web/components/outcome-label' import { NotificationGroup, - usePreferredGroupedNotifications, + useGroupedNotifications, } from 'web/hooks/use-notifications' import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' @@ -45,6 +45,7 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { useUser } from 'web/hooks/use-user' +import { LoadingIndicator } from 'web/components/loading-indicator' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -58,16 +59,6 @@ export default function Notifications(props: { auth: { privateUser: PrivateUser } }) { const { privateUser } = props.auth - const local = safeLocalStorage() - let localNotifications = [] as Notification[] - const localSavedNotificationGroups = local?.getItem('notification-groups') - let localNotificationGroups = [] as NotificationGroup[] - if (localSavedNotificationGroups) { - localNotificationGroups = JSON.parse(localSavedNotificationGroups) - localNotifications = localNotificationGroups - .map((g) => g.notifications) - .flat() - } return ( <Page> @@ -84,12 +75,7 @@ export default function Notifications(props: { tabs={[ { title: 'Notifications', - content: ( - <NotificationsList - privateUser={privateUser} - cachedNotifications={localNotifications} - /> - ), + content: <NotificationsList privateUser={privateUser} />, }, { title: 'Settings', @@ -135,16 +121,10 @@ function RenderNotificationGroups(props: { ) } -function NotificationsList(props: { - privateUser: PrivateUser - cachedNotifications: Notification[] -}) { - const { privateUser, cachedNotifications } = props +function NotificationsList(props: { privateUser: PrivateUser }) { + const { privateUser } = props const [page, setPage] = useState(0) - const allGroupedNotifications = usePreferredGroupedNotifications( - privateUser, - cachedNotifications - ) + const allGroupedNotifications = useGroupedNotifications(privateUser) const paginatedGroupedNotifications = useMemo(() => { if (!allGroupedNotifications) return const start = page * NOTIFICATIONS_PER_PAGE @@ -163,7 +143,8 @@ function NotificationsList(props: { return maxNotificationsToShow }, [allGroupedNotifications, page]) - if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div /> + if (!paginatedGroupedNotifications || !allGroupedNotifications) + return <LoadingIndicator /> return ( <div className={'min-h-[100vh] text-sm'}> From 926929880abf3fada476bc4ad5e0e243b7f8543b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 15:44:22 -0500 Subject: [PATCH 121/279] "Sign up to bet" => "Place my bet"; "Sign in to comment" => "Add my comment"; rename button to BetSignUpPrompt --- web/components/answers/answer-bet-panel.tsx | 4 ++-- web/components/bet-button.tsx | 21 +++++++++++-------- web/components/bet-inline.tsx | 4 ++-- web/components/bet-panel.tsx | 6 +++--- .../challenges/accept-challenge-button.tsx | 4 ++-- web/components/feed/feed-comments.tsx | 2 +- web/components/feed/feed-items.tsx | 4 ++-- web/components/numeric-bet-panel.tsx | 4 ++-- web/components/sign-up-prompt.tsx | 4 ++-- 9 files changed, 28 insertions(+), 25 deletions(-) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 238c7783..f04d752f 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -24,7 +24,7 @@ import { } from 'common/calculate-dpm' import { Bet } from 'common/bet' import { track } from 'web/lib/service/analytics' -import { SignUpPrompt } from '../sign-up-prompt' +import { BetSignUpPrompt } from '../sign-up-prompt' import { isIOS } from 'web/lib/util/device' import { AlertBox } from '../alert-box' @@ -204,7 +204,7 @@ export function AnswerBetPanel(props: { {isSubmitting ? 'Submitting...' : 'Submit trade'} </button> ) : ( - <SignUpPrompt /> + <BetSignUpPrompt /> )} </Col> ) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 2aca1772..578d9cc0 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -10,6 +10,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' import { Button } from 'web/components/button' import { firebaseLogin } from 'web/lib/firebase/users' +import { BetSignUpPrompt } from './sign-up-prompt' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -32,15 +33,17 @@ export default function BetButton(props: { return ( <> <Col className={clsx('items-center', className)}> - <Button - size={'lg'} - className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} - onClick={() => { - !user ? firebaseLogin() : setOpen(true) - }} - > - {user ? 'Bet' : 'Sign up to Bet'} - </Button> + {user ? ( + <Button + size="lg" + className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} + onClick={() => setOpen(true)} + > + Bet + </Button> + ) : ( + <BetSignUpPrompt /> + )} {user && ( <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> diff --git a/web/components/bet-inline.tsx b/web/components/bet-inline.tsx index 64780f4b..af75ff7c 100644 --- a/web/components/bet-inline.tsx +++ b/web/components/bet-inline.tsx @@ -12,7 +12,7 @@ import { Row } from './layout/row' import { YesNoSelector } from './yes-no-selector' import { useUnfilledBets } from 'web/hooks/use-bets' import { useUser } from 'web/hooks/use-user' -import { SignUpPrompt } from './sign-up-prompt' +import { BetSignUpPrompt } from './sign-up-prompt' import { getCpmmProbability } from 'common/calculate-cpmm' import { Col } from './layout/col' import { XIcon } from '@heroicons/react/solid' @@ -112,7 +112,7 @@ export function BetInline(props: { : 'Submit'} </Button> )} - <SignUpPrompt size="xs" /> + <BetSignUpPrompt size="xs" /> <button onClick={() => { setProbAfter(undefined) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 03bd3898..3b1d404e 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -31,7 +31,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveBinaryShares } from './use-save-binary-shares' -import { SignUpPrompt } from './sign-up-prompt' +import { BetSignUpPrompt } from './sign-up-prompt' import { isIOS } from 'web/lib/util/device' import { ProbabilityOrNumericInput } from './probability-input' import { track } from 'web/lib/service/analytics' @@ -86,7 +86,7 @@ export function BetPanel(props: { unfilledBets={unfilledBets} /> - <SignUpPrompt /> + <BetSignUpPrompt /> {!user && <PlayMoneyDisclaimer />} </Col> @@ -146,7 +146,7 @@ export function SimpleBetPanel(props: { onBuySuccess={onBetSuccess} /> - <SignUpPrompt /> + <BetSignUpPrompt /> {!user && <PlayMoneyDisclaimer />} </Col> diff --git a/web/components/challenges/accept-challenge-button.tsx b/web/components/challenges/accept-challenge-button.tsx index 62a45a7a..fcf64b30 100644 --- a/web/components/challenges/accept-challenge-button.tsx +++ b/web/components/challenges/accept-challenge-button.tsx @@ -2,7 +2,7 @@ import { User } from 'common/user' import { Contract } from 'common/contract' import { Challenge } from 'common/challenge' import { useEffect, useState } from 'react' -import { SignUpPrompt } from 'web/components/sign-up-prompt' +import { BetSignUpPrompt } from 'web/components/sign-up-prompt' import { acceptChallenge, APIError } from 'web/lib/firebase/api' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' @@ -27,7 +27,7 @@ export function AcceptChallengeButton(props: { setErrorText('') }, [open]) - if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" /> + if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" /> const iAcceptChallenge = () => { setLoading(true) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index c3332373..d987caf5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -537,7 +537,7 @@ export function CommentInputTextArea(props: { className={'btn btn-outline btn-sm mt-2 normal-case'} onClick={() => submitComment(presetId)} > - Sign in to comment + Add my comment </button> )} </Row> diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index 62673428..4a121120 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -34,7 +34,7 @@ import { import { FeedBet } from 'web/components/feed/feed-bets' import { CPMMBinaryContract, NumericContract } from 'common/contract' import { FeedLiquidity } from './feed-liquidity' -import { SignUpPrompt } from '../sign-up-prompt' +import { BetSignUpPrompt } from '../sign-up-prompt' import { User } from 'common/user' import { PlayMoneyDisclaimer } from '../play-money-disclaimer' import { contractMetrics } from 'common/contract-details' @@ -70,7 +70,7 @@ export function FeedItems(props: { {!user ? ( <Col className="mt-4 max-w-sm items-center xl:hidden"> - <SignUpPrompt /> + <BetSignUpPrompt /> <PlayMoneyDisclaimer /> </Col> ) : ( diff --git a/web/components/numeric-bet-panel.tsx b/web/components/numeric-bet-panel.tsx index e3b4bc29..e747b78d 100644 --- a/web/components/numeric-bet-panel.tsx +++ b/web/components/numeric-bet-panel.tsx @@ -18,7 +18,7 @@ import { BucketInput } from './bucket-input' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' -import { SignUpPrompt } from './sign-up-prompt' +import { BetSignUpPrompt } from './sign-up-prompt' import { track } from 'web/lib/service/analytics' export function NumericBetPanel(props: { @@ -34,7 +34,7 @@ export function NumericBetPanel(props: { <NumericBuyPanel contract={contract} user={user} /> - <SignUpPrompt /> + <BetSignUpPrompt /> </Col> ) } diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 6a55fc28..2c6f7b54 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -4,7 +4,7 @@ import { firebaseLogin } from 'web/lib/firebase/users' import { withTracking } from 'web/lib/service/analytics' import { Button, SizeType } from './button' -export function SignUpPrompt(props: { +export function BetSignUpPrompt(props: { label?: string className?: string size?: SizeType @@ -19,7 +19,7 @@ export function SignUpPrompt(props: { size={size} color="gradient" > - {label ?? 'Sign up to bet!'} + {label ?? 'Place my bet!'} </Button> ) : null } From cae21548931e8428e9975b7148e1f93aede9ac0d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 15:56:35 -0500 Subject: [PATCH 122/279] sign in button --- web/components/button.tsx | 2 +- web/components/create-question-button.tsx | 33 +++++++---------------- web/components/nav/sidebar.tsx | 4 ++- web/components/sign-in-button.tsx | 25 +++++++++++++++++ 4 files changed, 38 insertions(+), 26 deletions(-) create mode 100644 web/components/sign-in-button.tsx diff --git a/web/components/button.tsx b/web/components/button.tsx index 843f74ca..f2d13022 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -52,7 +52,7 @@ export function Button(props: { color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', - color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200', + color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200', color === 'gradient' && 'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index 0ea28635..30be2276 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -1,8 +1,8 @@ -import Link from 'next/link' -import { useRouter } from 'next/router' -import clsx from 'clsx' -import { firebaseLogin, User } from 'web/lib/firebase/users' import React from 'react' +import Link from 'next/link' +import clsx from 'clsx' + +import { User } from 'web/lib/firebase/users' export const createButtonStyle = 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' @@ -17,31 +17,16 @@ export const CreateQuestionButton = (props: { 'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700' const { user, overrideText, className, query } = props - const router = useRouter() - if (user?.isBannedFromPosting) return <></> + if (!user || user?.isBannedFromPosting) return <></> return ( <div className={clsx('flex justify-center', className)}> - {user ? ( - <Link href={`/create${query ? query : ''}`} passHref> - <button className={clsx(gradient, createButtonStyle)}> - {overrideText ? overrideText : 'Create a market'} - </button> - </Link> - ) : ( - <button - onClick={async () => { - // login, and then reload the page, to hit any SSR redirect (e.g. - // redirecting from / to /home for logged in users) - await firebaseLogin() - router.replace(router.asPath) - }} - className={clsx(gradient, createButtonStyle)} - > - Sign in + <Link href={`/create${query ? query : ''}`} passHref> + <button className={clsx(gradient, createButtonStyle)}> + {overrideText ? overrideText : 'Create a market'} </button> - )} + </Link> </div> ) } diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 995378ee..f40cd80e 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -28,6 +28,7 @@ import { Spacer } from '../layout/spacer' import { CHALLENGES_ENABLED } from 'common/challenge' import { buildArray } from 'common/util/array' import TrophyIcon from 'web/lib/icons/trophy-icon' +import { SignInButton } from '../sign-in-button' const logout = async () => { // log out, and then reload the page, in case SSR wants to boot them out @@ -240,7 +241,8 @@ export default function Sidebar(props: { className?: string }) { > <ManifoldLogo className="py-6" twoLine /> - <CreateQuestionButton user={user} /> + {user ? <CreateQuestionButton user={user} /> : <SignInButton />} + <Spacer h={4} /> {user && ( <div className="min-h-[80px] w-full"> diff --git a/web/components/sign-in-button.tsx b/web/components/sign-in-button.tsx new file mode 100644 index 00000000..d4682cb5 --- /dev/null +++ b/web/components/sign-in-button.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { useRouter } from 'next/router' + +import { firebaseLogin, User } from 'web/lib/firebase/users' +import { Button } from './button' + +export const SignInButton = (props: { +}) => { + const router = useRouter() + + return ( + <Button + size='lg' + color='gray' + onClick={async () => { + // login, and then reload the page, to hit any SSR redirect (e.g. + // redirecting from / to /home for logged in users) + await firebaseLogin() + router.replace(router.asPath) + }} + > + Sign in + </Button> + ) +} From 0a5fb4752a4d97b03a63196d8ce3b62c1a3366c5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 16:09:42 -0500 Subject: [PATCH 123/279] CreateQuestionButton: use Button component --- web/components/button.tsx | 6 +++--- web/components/create-question-button.tsx | 13 ++++--------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/web/components/button.tsx b/web/components/button.tsx index f2d13022..dbb28122 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -37,8 +37,8 @@ export function Button(props: { sm: 'px-3 py-2 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-4 py-2 text-base', - xl: 'px-6 py-3 text-base', - '2xl': 'px-6 py-3 text-xl', + xl: 'px-6 py-2.5 text-base font-semibold', + '2xl': 'px-6 py-3 text-xl font-semibold', }[size] return ( @@ -54,7 +54,7 @@ export function Button(props: { color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200', color === 'gradient' && - 'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', + 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', color === 'gray-white' && 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', className diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index 30be2276..c7299904 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -3,9 +3,7 @@ import Link from 'next/link' import clsx from 'clsx' import { User } from 'web/lib/firebase/users' - -export const createButtonStyle = - 'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11' +import { Button } from './button' export const CreateQuestionButton = (props: { user: User | null | undefined @@ -13,9 +11,6 @@ export const CreateQuestionButton = (props: { className?: string query?: string }) => { - const gradient = - 'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700' - const { user, overrideText, className, query } = props if (!user || user?.isBannedFromPosting) return <></> @@ -23,9 +18,9 @@ export const CreateQuestionButton = (props: { return ( <div className={clsx('flex justify-center', className)}> <Link href={`/create${query ? query : ''}`} passHref> - <button className={clsx(gradient, createButtonStyle)}> - {overrideText ? overrideText : 'Create a market'} - </button> + <Button color="gradient" size="xl" className="mt-4"> + {overrideText ?? 'Create a market'} + </Button> </Link> </div> ) From 2e96721a5cc38b92bc0ddcaea020425cb071b37d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 16:12:03 -0500 Subject: [PATCH 124/279] "sign in" => "add my answer" --- web/components/answers/create-answer-panel.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 6290cf44..cef60138 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -25,6 +25,7 @@ import { Bet } from 'common/bet' import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' import { lowerCase } from 'lodash' +import { Button } from '../button' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -203,12 +204,14 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { </button> ) : ( text && ( - <button - className="btn self-end whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600" + <Button + color="green" + size="lg" + className="self-end whitespace-nowrap " onClick={withTracking(firebaseLogin, 'answer panel sign in')} > - Sign in - </button> + Add my answer + </Button> ) )} </Col> From c88621de1942f5174396160c10e5ab7c9b8bedeb Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 16:13:30 -0500 Subject: [PATCH 125/279] hide group edit dialog when signed out --- web/components/contract/contract-details.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7705b538..175b36b5 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -201,13 +201,15 @@ export function ContractDetails(props: { > {groupInfo} </Button> - <Button - size={'xs'} - color={'gray-white'} - onClick={() => setOpen(!open)} - > - <PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" /> - </Button> + {user && ( + <Button + size={'xs'} + color={'gray-white'} + onClick={() => setOpen(!open)} + > + <PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" /> + </Button> + )} </Row> )} </Row> From eb070f0b07d535da2dc44b245947554fa3271984 Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Sun, 28 Aug 2022 21:14:44 +0000 Subject: [PATCH 126/279] Auto-remove unused imports --- web/components/bet-button.tsx | 1 - web/components/sign-in-button.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index 578d9cc0..7d84bbc0 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -9,7 +9,6 @@ import { useUserContractBets } from 'web/hooks/use-user-bets' import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' import { Button } from 'web/components/button' -import { firebaseLogin } from 'web/lib/firebase/users' import { BetSignUpPrompt } from './sign-up-prompt' /** Button that opens BetPanel in a new modal */ diff --git a/web/components/sign-in-button.tsx b/web/components/sign-in-button.tsx index d4682cb5..f0de663d 100644 --- a/web/components/sign-in-button.tsx +++ b/web/components/sign-in-button.tsx @@ -1,7 +1,7 @@ import React from 'react' import { useRouter } from 'next/router' -import { firebaseLogin, User } from 'web/lib/firebase/users' +import { firebaseLogin } from 'web/lib/firebase/users' import { Button } from './button' export const SignInButton = (props: { From 3fd07da1b06ec05421b0bf63834670e25cb01bf9 Mon Sep 17 00:00:00 2001 From: mqp <mqp@users.noreply.github.com> Date: Sun, 28 Aug 2022 21:15:31 +0000 Subject: [PATCH 127/279] Auto-prettification --- web/components/sign-in-button.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/components/sign-in-button.tsx b/web/components/sign-in-button.tsx index f0de663d..a7a6bc29 100644 --- a/web/components/sign-in-button.tsx +++ b/web/components/sign-in-button.tsx @@ -4,14 +4,13 @@ import { useRouter } from 'next/router' import { firebaseLogin } from 'web/lib/firebase/users' import { Button } from './button' -export const SignInButton = (props: { -}) => { +export const SignInButton = (props: {}) => { const router = useRouter() return ( <Button - size='lg' - color='gray' + size="lg" + color="gray" onClick={async () => { // login, and then reload the page, to hit any SSR redirect (e.g. // redirecting from / to /home for logged in users) From e4c66e08f5088f82e57e197b2bc60f050c46c1a4 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 16:26:29 -0500 Subject: [PATCH 128/279] clean up add markets dialog --- web/pages/group/[...slugs]/index.tsx | 41 ++++++++++++---------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index e1679e84..28658a16 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,4 +1,9 @@ +import React, { useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' import { debounce, sortBy, take } from 'lodash' +import { SearchIcon } from '@heroicons/react/outline' +import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' @@ -17,7 +22,6 @@ import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' -import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' @@ -27,15 +31,11 @@ import { SEO } from 'web/components/SEO' import { Linkify } from 'web/components/linkify' import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' -import { CreateQuestionButton } from 'web/components/create-question-button' -import React, { useState } from 'react' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { toast } from 'react-hot-toast' import { ContractSearch } from 'web/components/contract-search' import { FollowList } from 'web/components/follow-list' -import { SearchIcon } from '@heroicons/react/outline' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' @@ -538,8 +538,8 @@ function AddContractButton(props: { group: Group; user: User }) { <div className={'flex justify-center'}> <Button className="whitespace-nowrap" - size="sm" - color="gradient" + size="md" + color="indigo" onClick={() => setOpen(true)} > Add market @@ -556,24 +556,19 @@ function AddContractButton(props: { group: Group; user: User }) { className={'min-h-screen w-full max-w-4xl gap-4 rounded-md bg-white'} > <Col className="p-8 pb-0"> - <div className={'text-xl text-indigo-700'}> - Add a market to your group + <div className={'text-xl text-indigo-700'}>Add markets</div> + + <div className={'text-md my-4 text-gray-600'}> + Add pre-existing markets to this group, or{' '} + <Link href={`/create?groupId=${group.id}`}> + <span className="cursor-pointer font-semibold underline"> + create a new one + </span> + </Link> + . </div> - {contracts.length === 0 ? ( - <Col className="items-center justify-center"> - <CreateQuestionButton - user={user} - overrideText={'New market'} - className={'w-48 flex-shrink-0 '} - query={`?groupId=${group.id}`} - /> - - <div className={'mt-1 text-lg text-gray-600'}> - (or select old markets) - </div> - </Col> - ) : ( + {contracts.length > 0 && ( <Col className={'w-full '}> {!loading ? ( <Row className={'justify-end gap-4'}> From 1ff453d64c0818cc02f763f928a548b30227992d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 16:38:59 -0500 Subject: [PATCH 129/279] eslint --- web/components/sign-in-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/sign-in-button.tsx b/web/components/sign-in-button.tsx index a7a6bc29..8378134f 100644 --- a/web/components/sign-in-button.tsx +++ b/web/components/sign-in-button.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { firebaseLogin } from 'web/lib/firebase/users' import { Button } from './button' -export const SignInButton = (props: {}) => { +export const SignInButton = () => { const router = useRouter() return ( From 7e00f29189effa094073355379d1029b21f66f02 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 28 Aug 2022 16:55:29 -0500 Subject: [PATCH 130/279] back to "sign up to bet" --- web/components/sign-up-prompt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 2c6f7b54..5ade4c1f 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 ?? 'Place my bet!'} + {label ?? 'Sign up to bet!'} </Button> ) : null } From 996b4795eaa5040b26de562140108db29b045847 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 28 Aug 2022 18:03:00 -0500 Subject: [PATCH 131/279] =?UTF-8?q?=E2=9A=A1=20Cache=20user=20bets=20tab?= =?UTF-8?q?=20with=20react=20query!!=20=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Convert useUserBets to react query * Fix duplicate key warnings * Fix react-query workaround to use refetchOnMount: always' * Use react query for portfolio history * Fix useUserBet workaround * Script to back fill unique bettors in all contracts * React query for user bet contracts, using uniqueBettorsId! * Prefetch user bets / portfolio data --- .../src/scripts/backfill-unique-bettors.ts | 39 +++++++++++++ web/components/bets-list.tsx | 36 ++++-------- web/components/nav/sidebar.tsx | 2 +- .../portfolio/portfolio-value-section.tsx | 39 ++++--------- web/hooks/use-contracts.ts | 14 +++++ web/hooks/use-notifications.ts | 3 +- web/hooks/use-portfolio-history.ts | 32 +++++++++++ web/hooks/use-prefetch.ts | 11 ++++ web/hooks/use-user-bets.ts | 55 ++++--------------- web/lib/firebase/bets.ts | 19 ++----- web/lib/firebase/contracts.ts | 8 +++ web/lib/firebase/users.ts | 17 +++--- web/pages/[username]/[contractSlug].tsx | 2 + web/pages/home.tsx | 2 + 14 files changed, 157 insertions(+), 122 deletions(-) create mode 100644 functions/src/scripts/backfill-unique-bettors.ts create mode 100644 web/hooks/use-portfolio-history.ts create mode 100644 web/hooks/use-prefetch.ts diff --git a/functions/src/scripts/backfill-unique-bettors.ts b/functions/src/scripts/backfill-unique-bettors.ts new file mode 100644 index 00000000..35faa54a --- /dev/null +++ b/functions/src/scripts/backfill-unique-bettors.ts @@ -0,0 +1,39 @@ +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { getValues, log, writeAsync } from '../utils' +import { Bet } from '../../../common/bet' +import { groupBy, mapValues, sortBy, uniq } from 'lodash' + +initAdmin() +const firestore = admin.firestore() + +const getBettorsByContractId = async () => { + const bets = await getValues<Bet>(firestore.collectionGroup('bets')) + log(`Loaded ${bets.length} bets.`) + const betsByContractId = groupBy(bets, 'contractId') + return mapValues(betsByContractId, (bets) => + uniq(sortBy(bets, 'createdTime').map((bet) => bet.userId)) + ) +} + +const updateUniqueBettors = async () => { + const bettorsByContractId = await getBettorsByContractId() + + const updates = Object.entries(bettorsByContractId).map( + ([contractId, userIds]) => { + const update = { + uniqueBettorIds: userIds, + uniqueBettorCount: userIds.length, + } + const docRef = firestore.collection('contracts').doc(contractId) + return { doc: docRef, fields: update } + } + ) + log(`Updating ${updates.length} contracts.`) + await writeAsync(firestore, updates) + log(`Updated all contracts.`) +} + +if (require.main === module) { + updateUniqueBettors() +} diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 4ac873b4..3270408b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,14 +1,5 @@ import Link from 'next/link' -import { - Dictionary, - keyBy, - groupBy, - mapValues, - sortBy, - partition, - sumBy, - uniq, -} from 'lodash' +import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' @@ -28,7 +19,6 @@ import { Contract, contractPath, getBinaryProbPercent, - getContractFromId, } from 'web/lib/firebase/contracts' import { Row } from './layout/row' import { UserLink } from './user-page' @@ -56,9 +46,9 @@ import { SellSharesModal } from './sell-modal' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' -import { filterDefined } from 'common/util/array' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' +import { useUserBetContracts } from 'web/hooks/use-contracts' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' @@ -72,26 +62,22 @@ export function BetsList(props: { user: User }) { const signedInUser = useUser() const isYourBets = user.id === signedInUser?.id const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022 - const userBets = useUserBets(user.id, { includeRedemptions: true }) - const [contractsById, setContractsById] = useState< - Dictionary<Contract> | undefined - >() + const userBets = useUserBets(user.id) // Hide bets before 06-01-2022 if this isn't your own profile // NOTE: This means public profits also begin on 06-01-2022 as well. const bets = useMemo( - () => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)), + () => + userBets?.filter( + (bet) => !bet.isAnte && bet.createdTime >= (hideBetsBefore ?? 0) + ), [userBets, hideBetsBefore] ) - useEffect(() => { - if (bets) { - const contractIds = uniq(bets.map((b) => b.contractId)) - Promise.all(contractIds.map(getContractFromId)).then((contracts) => { - setContractsById(keyBy(filterDefined(contracts), 'id')) - }) - } - }, [bets]) + const contractList = useUserBetContracts(user.id) + const contractsById = useMemo(() => { + return contractList ? keyBy(contractList, 'id') : undefined + }, [contractList]) const [sort, setSort] = useState<BetSort>('newest') const [filter, setFilter] = useState<BetFilter>('open') diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index f40cd80e..3e1ecb83 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -319,7 +319,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { {memberItems.map((item) => ( <a href={item.href} - key={item.name} + key={item.href} onClick={trackCallback('click sidebar group', { name: item.name })} className={clsx( 'cursor-pointer truncate', diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index 706630b2..ab4bef0c 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -1,43 +1,26 @@ -import { PortfolioMetrics } from 'common/user' import { formatMoney } from 'common/util/format' import { last } from 'lodash' -import { memo, useEffect, useState } from 'react' -import { Period, getPortfolioHistory } from 'web/lib/firebase/users' +import { memo, useRef, useState } from 'react' +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 { PortfolioValueGraph } from './portfolio-value-graph' -import { DAY_MS } from 'common/util/time' - -const periodToCutoff = (now: number, period: Period) => { - switch (period) { - case 'daily': - return now - 1 * DAY_MS - case 'weekly': - return now - 7 * DAY_MS - case 'monthly': - return now - 30 * DAY_MS - case 'allTime': - default: - return new Date(0) - } -} export const PortfolioValueSection = memo( function PortfolioValueSection(props: { userId: string }) { const { userId } = props const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly') - const [portfolioHistory, setUsersPortfolioHistory] = useState< - PortfolioMetrics[] - >([]) + const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod) - useEffect(() => { - const cutoff = periodToCutoff(Date.now(), portfolioPeriod).valueOf() - getPortfolioHistory(userId, cutoff).then(setUsersPortfolioHistory) - }, [portfolioPeriod, userId]) + // Remember the last defined portfolio history. + const portfolioRef = useRef(portfolioHistory) + if (portfolioHistory) portfolioRef.current = portfolioHistory + const currPortfolioHistory = portfolioRef.current - const lastPortfolioMetrics = last(portfolioHistory) - if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { + const lastPortfolioMetrics = last(currPortfolioHistory) + if (!currPortfolioHistory || !lastPortfolioMetrics) { return <></> } @@ -64,7 +47,7 @@ export const PortfolioValueSection = memo( </select> </Row> <PortfolioValueGraph - portfolioHistory={portfolioHistory} + portfolioHistory={currPortfolioHistory} includeTime={portfolioPeriod == 'daily'} /> </> diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index efe30d38..f277a209 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -1,3 +1,4 @@ +import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { isEqual } from 'lodash' import { useEffect, useRef, useState } from 'react' import { @@ -8,6 +9,7 @@ import { listenForHotContracts, listenForInactiveContracts, listenForNewContracts, + getUserBetContractsQuery, } from 'web/lib/firebase/contracts' export const useContracts = () => { @@ -89,3 +91,15 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => { ? contracts.map((c) => contractDict.current[c.id]) : undefined } + +export const useUserBetContracts = (userId: string) => { + const result = useFirestoreQueryData( + ['contracts', 'bets', userId], + getUserBetContractsQuery(userId), + { subscribe: true, includeMetadataChanges: true }, + // Temporary workaround for react-query bug: + // https://github.com/invertase/react-query-firebase/issues/25 + { refetchOnMount: 'always' } + ) + return result.data +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index d02d3d30..b2f1701f 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -20,8 +20,9 @@ function useNotifications(privateUser: PrivateUser) { { subscribe: true, includeMetadataChanges: true }, // Temporary workaround for react-query bug: // https://github.com/invertase/react-query-firebase/issues/25 - { cacheTime: 0 } + { refetchOnMount: 'always' } ) + const notifications = useMemo(() => { if (!result.data) return undefined const notifications = result.data as Notification[] diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts new file mode 100644 index 00000000..d5919783 --- /dev/null +++ b/web/hooks/use-portfolio-history.ts @@ -0,0 +1,32 @@ +import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { DAY_MS, HOUR_MS } from 'common/util/time' +import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users' + +export const usePortfolioHistory = (userId: string, period: Period) => { + const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS + const cutoff = periodToCutoff(nowRounded, period).valueOf() + + const result = useFirestoreQueryData( + ['portfolio-history', userId, cutoff], + getPortfolioHistoryQuery(userId, cutoff), + { subscribe: true, includeMetadataChanges: true }, + // Temporary workaround for react-query bug: + // https://github.com/invertase/react-query-firebase/issues/25 + { refetchOnMount: 'always' } + ) + return result.data +} + +const periodToCutoff = (now: number, period: Period) => { + switch (period) { + case 'daily': + return now - 1 * DAY_MS + case 'weekly': + return now - 7 * DAY_MS + case 'monthly': + return now - 30 * DAY_MS + case 'allTime': + default: + return new Date(0) + } +} diff --git a/web/hooks/use-prefetch.ts b/web/hooks/use-prefetch.ts new file mode 100644 index 00000000..e22e13eb --- /dev/null +++ b/web/hooks/use-prefetch.ts @@ -0,0 +1,11 @@ +import { useUserBetContracts } from './use-contracts' +import { usePortfolioHistory } from './use-portfolio-history' +import { useUserBets } from './use-user-bets' + +export function usePrefetch(userId: string | undefined) { + const maybeUserId = userId ?? '' + + useUserBets(maybeUserId) + useUserBetContracts(maybeUserId) + usePortfolioHistory(maybeUserId, 'weekly') +} diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index b260a406..72a4e5bf 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -1,22 +1,21 @@ -import { uniq } from 'lodash' +import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useEffect, useState } from 'react' import { Bet, - listenForUserBets, + getUserBetsQuery, listenForUserContractBets, } from 'web/lib/firebase/bets' -export const useUserBets = ( - userId: string | undefined, - options: { includeRedemptions: boolean } -) => { - const [bets, setBets] = useState<Bet[] | undefined>(undefined) - - useEffect(() => { - if (userId) return listenForUserBets(userId, setBets, options) - }, [userId]) - - return bets +export const useUserBets = (userId: string) => { + const result = useFirestoreQueryData( + ['bets', userId], + getUserBetsQuery(userId), + { subscribe: true, includeMetadataChanges: true }, + // Temporary workaround for react-query bug: + // https://github.com/invertase/react-query-firebase/issues/25 + { refetchOnMount: 'always' } + ) + return result.data } export const useUserContractBets = ( @@ -33,36 +32,6 @@ export const useUserContractBets = ( return bets } -export const useUserBetContracts = ( - userId: string | undefined, - options: { includeRedemptions: boolean } -) => { - const [contractIds, setContractIds] = useState<string[] | undefined>() - - useEffect(() => { - if (userId) { - const key = `user-bet-contractIds-${userId}` - - const userBetContractJson = localStorage.getItem(key) - if (userBetContractJson) { - setContractIds(JSON.parse(userBetContractJson)) - } - - return listenForUserBets( - userId, - (bets) => { - const contractIds = uniq(bets.map((bet) => bet.contractId)) - setContractIds(contractIds) - localStorage.setItem(key, JSON.stringify(contractIds)) - }, - options - ) - } - }, [userId]) - - return contractIds -} - export const useGetUserBetContractIds = (userId: string | undefined) => { const [contractIds, setContractIds] = useState<string[] | undefined>() diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index ef0ab55d..2a095d32 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -11,6 +11,7 @@ import { getDocs, getDoc, DocumentSnapshot, + Query, } from 'firebase/firestore' import { uniq } from 'lodash' @@ -131,24 +132,12 @@ export async function getContractsOfUserBets(userId: string) { return filterDefined(contracts) } -export function listenForUserBets( - userId: string, - setBets: (bets: Bet[]) => void, - options: { includeRedemptions: boolean } -) { - const { includeRedemptions } = options - const userQuery = query( +export function getUserBetsQuery(userId: string) { + return query( collectionGroup(db, 'bets'), where('userId', '==', userId), orderBy('createdTime', 'desc') - ) - return listenForValues<Bet>(userQuery, (bets) => { - setBets( - bets.filter( - (bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte - ) - ) - }) + ) as Query<Bet> } export function listenForUserContractBets( diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index d3f18f54..2751e9bb 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -6,6 +6,7 @@ import { getDocs, limit, orderBy, + Query, query, setDoc, startAfter, @@ -156,6 +157,13 @@ export function listenForUserContracts( return listenForValues<Contract>(q, setContracts) } +export function getUserBetContractsQuery(userId: string) { + return query( + contracts, + where('uniqueBettorIds', 'array-contains', userId) + ) as Query<Contract> +} + const activeContractsQuery = query( contracts, where('isResolved', '==', false), diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index bad13c8c..c0764f0a 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -12,6 +12,7 @@ import { deleteDoc, collectionGroup, onSnapshot, + Query, } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' @@ -252,15 +253,13 @@ export async function unfollow(userId: string, unfollowedUserId: string) { await deleteDoc(followDoc) } -export async function getPortfolioHistory(userId: string, since: number) { - return getValues<PortfolioMetrics>( - query( - collectionGroup(db, 'portfolioHistory'), - where('userId', '==', userId), - where('timestamp', '>=', since), - orderBy('timestamp', 'asc') - ) - ) +export function getPortfolioHistoryQuery(userId: string, since: number) { + return query( + collectionGroup(db, 'portfolioHistory'), + where('userId', '==', userId), + where('timestamp', '>=', since), + orderBy('timestamp', 'asc') + ) as Query<PortfolioMetrics> } export function listenForFollows( diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 3667511e..2ef472fc 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -42,6 +42,7 @@ import { } from 'web/components/contract/contract-leaderboard' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { Title } from 'web/components/title' +import { usePrefetch } from 'web/hooks/use-prefetch' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -157,6 +158,7 @@ export function ContractPageContent( const { backToHome, comments, user } = props const contract = useContractWithPreload(props.contract) ?? props.contract + usePrefetch(user?.id) useTracking('view market', { slug: contract.slug, diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 265fd79a..5b6c445c 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -15,6 +15,7 @@ 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 { usePrefetch } from 'web/hooks/use-prefetch' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -30,6 +31,7 @@ const Home = (props: { auth: { user: User } | null }) => { useTracking('view home') useSaveReferral() + usePrefetch(user?.id) return ( <> From cf58fc9fd43d37b908ef931da7f3245a3557691a Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 28 Aug 2022 20:59:14 -0700 Subject: [PATCH 132/279] Remove Groups from sidebar --- web/components/nav/sidebar.tsx | 73 +++------------------------------- 1 file changed, 5 insertions(+), 68 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 3e1ecb83..7e9b7941 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -20,10 +20,7 @@ import NotificationsIcon from 'web/components/notifications-icon' import React from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' -import { useMemberGroups } from 'web/hooks/use-group' -import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' -import { Group } from 'common/group' import { Spacer } from '../layout/spacer' import { CHALLENGES_ENABLED } from 'common/challenge' import { buildArray } from 'common/util/array' @@ -68,9 +65,11 @@ function getMoreNavigation(user?: User | null) { } if (!user) { + // Signed out "More" return buildArray( CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ + { name: 'Groups', href: '/groups' }, { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Tournaments', href: '/tournaments' }, { name: 'Charity', href: '/charity' }, @@ -81,9 +80,11 @@ function getMoreNavigation(user?: User | null) { ) } + // Signed in "More" return buildArray( CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ + { name: 'Groups', href: '/groups' }, { name: 'Referrals', href: '/referrals' }, { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, @@ -143,6 +144,7 @@ function getMoreMobileNav() { return buildArray<Item>( CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ + { name: 'Groups', href: '/groups' }, { name: 'Referrals', href: '/referrals' }, { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, @@ -225,15 +227,6 @@ export default function Sidebar(props: { className?: string }) { ? signedOutMobileNavigation : signedInMobileNavigation - const memberItems = ( - useMemberGroups(user?.id, undefined, { - by: 'mostRecentContractAddedTime', - }) ?? [] - ).map((group: Group) => ({ - name: group.name, - href: `${groupPath(group.slug)}`, - })) - return ( <nav aria-label="Sidebar" @@ -262,11 +255,6 @@ export default function Sidebar(props: { className?: string }) { buttonContent={<MoreButton />} /> )} - {/* Spacer if there are any groups */} - {memberItems.length > 0 && ( - <hr className="!my-4 mr-2 border-gray-300" /> - )} - <GroupsList currentPage={router.asPath} memberItems={memberItems} /> </div> {/* Desktop navigation */} @@ -278,58 +266,7 @@ export default function Sidebar(props: { className?: string }) { menuItems={getMoreNavigation(user)} buttonContent={<MoreButton />} /> - - {/* Spacer if there are any groups */} - {memberItems.length > 0 && <hr className="!my-4 border-gray-300" />} - <GroupsList currentPage={router.asPath} memberItems={memberItems} /> </div> </nav> ) } - -function GroupsList(props: { currentPage: string; memberItems: Item[] }) { - const { currentPage, memberItems } = props - - // const preferredNotifications = useUnseenPreferredNotifications( - // privateUser, - // { - // customHref: '/group/', - // }, - // memberItems.length > 0 ? memberItems.length : undefined - // ) - // const notifIsForThisItem = useMemo( - // () => (itemHref: string) => - // preferredNotifications.some( - // (n) => - // !n.isSeen && - // (n.isSeenOnHref === itemHref || - // n.isSeenOnHref?.replace('/chat', '') === itemHref) - // ), - // [preferredNotifications] - // ) - - return ( - <> - <SidebarItem - item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }} - currentPage={currentPage} - /> - - <div className="min-h-0 shrink space-y-0.5 overflow-auto"> - {memberItems.map((item) => ( - <a - href={item.href} - key={item.href} - onClick={trackCallback('click sidebar group', { name: item.name })} - className={clsx( - 'cursor-pointer truncate', - 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900' - )} - > - {item.name} - </a> - ))} - </div> - </> - ) -} From c7be2278651032187847ef570b753a1a9ad2b715 Mon Sep 17 00:00:00 2001 From: akrolsmir <akrolsmir@users.noreply.github.com> Date: Mon, 29 Aug 2022 04:00:14 +0000 Subject: [PATCH 133/279] Auto-remove unused imports --- web/components/nav/sidebar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 7e9b7941..11051ea0 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -5,7 +5,6 @@ import { DotsHorizontalIcon, CashIcon, HeartIcon, - UserGroupIcon, ChatIcon, } from '@heroicons/react/outline' import clsx from 'clsx' From f0727a65fc3c5cf0f578420ec36e771c3123a0cd Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 28 Aug 2022 21:33:11 -0700 Subject: [PATCH 134/279] Add SF 2022 Ballot to /tournaments --- web/pages/tournaments/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index ec952356..fd5a4feb 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -95,6 +95,13 @@ const tourneys: Tourney[] = [ endTime: toDate('Jan 6, 2023'), groupId: 'SxGRqXRpV3RAQKudbcNb', }, + { + title: 'SF 2022 Ballot', + blurb: 'Which ballot initiatives will pass this year in SF and CA?', + award: '', + endTime: toDate('Nov 8, 2022'), + groupId: 'VkWZyS5yxs8XWUJrX9eq', + }, // { // title: 'Clearer Thinking Regrant Project', // blurb: 'Something amazing', From 4dad954820b8d62dfdacb08e53fb6b54cdc8a181 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 28 Aug 2022 23:47:11 -0500 Subject: [PATCH 135/279] Change limit order label "at" => "up to" or "down to" --- web/components/bet-panel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 3b1d404e..f15a7445 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -560,7 +560,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 />} at + Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to </div> <ProbabilityOrNumericInput contract={contract} @@ -571,7 +571,7 @@ function LimitOrderPanel(props: { </Col> <Col className="gap-2"> <div className="ml-1 text-sm text-gray-500"> - Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at + Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to </div> <ProbabilityOrNumericInput contract={contract} From 62e72b2091b1475653b4d892a3aaee6752dbcb39 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 28 Aug 2022 23:51:43 -0500 Subject: [PATCH 136/279] Loan dialog wording tweak --- web/components/profile/loans-modal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/profile/loans-modal.tsx b/web/components/profile/loans-modal.tsx index 07853162..46be649a 100644 --- a/web/components/profile/loans-modal.tsx +++ b/web/components/profile/loans-modal.tsx @@ -34,11 +34,11 @@ export function LoansModal(props: { </span> <span className={'text-indigo-700'}>• What is an example?</span> <span className={'ml-2'}> - For example, if you bet M$1000 on "Will I become a millionare?", you - will get M$20 back tomorrow. + For example, if you bet M$1000 on "Will I become a millionare?" + today, you will get M$20 back tomorrow. </span> <span className={'ml-2'}> - Previous loans count against your total bet amount. So on the third + Previous loans count against your total bet amount. So on the next day, you would get back 2% of M$(1000 - 20) = M$19.6. </span> </Col> From 6facf3b7a703f2119479202487db92d7e2657048 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 29 Aug 2022 00:00:58 -0500 Subject: [PATCH 137/279] sidebar ordering --- web/components/nav/sidebar.tsx | 14 +++++++------- web/components/sign-in-button.tsx | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 11051ea0..1b030098 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -20,7 +20,6 @@ import React from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { trackCallback, withTracking } from 'web/lib/service/analytics' -import { Spacer } from '../layout/spacer' import { CHALLENGES_ENABLED } from 'common/challenge' import { buildArray } from 'common/util/array' import TrophyIcon from 'web/lib/icons/trophy-icon' @@ -66,10 +65,10 @@ function getMoreNavigation(user?: User | null) { if (!user) { // Signed out "More" return buildArray( + { name: 'Leaderboards', href: '/leaderboards' }, + { name: 'Groups', href: '/groups' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ - { name: 'Groups', href: '/groups' }, - { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Tournaments', href: '/tournaments' }, { name: 'Charity', href: '/charity' }, { name: 'Blog', href: 'https://news.manifold.markets' }, @@ -81,11 +80,11 @@ function getMoreNavigation(user?: User | null) { // Signed in "More" return buildArray( + { name: 'Leaderboards', href: '/leaderboards' }, + { name: 'Groups', href: '/groups' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, [ - { name: 'Groups', href: '/groups' }, { name: 'Referrals', href: '/referrals' }, - { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Charity', href: '/charity' }, { name: 'Send M$', href: '/links' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, @@ -233,9 +232,8 @@ export default function Sidebar(props: { className?: string }) { > <ManifoldLogo className="py-6" twoLine /> - {user ? <CreateQuestionButton user={user} /> : <SignInButton />} + {!user && <SignInButton className="mb-4" />} - <Spacer h={4} /> {user && ( <div className="min-h-[80px] w-full"> <ProfileSummary user={user} /> @@ -265,6 +263,8 @@ export default function Sidebar(props: { className?: string }) { menuItems={getMoreNavigation(user)} buttonContent={<MoreButton />} /> + + {user && <CreateQuestionButton user={user} />} </div> </nav> ) diff --git a/web/components/sign-in-button.tsx b/web/components/sign-in-button.tsx index 8378134f..48afb6c7 100644 --- a/web/components/sign-in-button.tsx +++ b/web/components/sign-in-button.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { firebaseLogin } from 'web/lib/firebase/users' import { Button } from './button' -export const SignInButton = () => { +export const SignInButton = (props: { className?: string }) => { const router = useRouter() return ( @@ -17,6 +17,7 @@ export const SignInButton = () => { await firebaseLogin() router.replace(router.asPath) }} + className={props.className} > Sign in </Button> From 6c64c9f1cd544dc940191ab4acee2cf23915b46d Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 28 Aug 2022 21:55:22 -0700 Subject: [PATCH 138/279] Remove hot volume from /tournaments --- web/pages/tournaments/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index fd5a4feb..65f5597f 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -212,7 +212,6 @@ function Section(props: { markets.map((m) => ( <ContractCard contract={m} - showHotVolume hideGroupLink className="max-h-[200px] w-96 shrink-0" questionClass="line-clamp-3" From 71dfcc4dd9d5795ce3fc1a8e59cc9639f49e8e31 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 29 Aug 2022 00:15:21 -0500 Subject: [PATCH 139/279] Add tracking for clicking recommended card & tournament card --- web/components/contract/contract-card.tsx | 15 ++++++++++----- web/components/contract/contracts-grid.tsx | 3 +++ web/pages/[username]/[contractSlug].tsx | 5 ++++- web/pages/tournaments/index.tsx | 1 + 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index e5ea724b..ef23b4be 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -43,6 +43,7 @@ export function ContractCard(props: { onClick?: () => void hideQuickBet?: boolean hideGroupLink?: boolean + trackingPostfix?: string }) { const { showHotVolume, @@ -52,6 +53,7 @@ export function ContractCard(props: { onClick, hideQuickBet, hideGroupLink, + trackingPostfix, } = props const contract = useContractWithPreload(props.contract) ?? props.contract const { question, outcomeType } = contract @@ -166,7 +168,7 @@ export function ContractCard(props: { if (e.ctrlKey || e.metaKey) return e.preventDefault() - track('click market card', { + track('click market card' + (trackingPostfix ?? ''), { slug: contract.slug, contractId: contract.id, }) @@ -176,10 +178,13 @@ export function ContractCard(props: { ) : ( <Link href={contractPath(contract)}> <a - onClick={trackCallback('click market card', { - slug: contract.slug, - contractId: contract.id, - })} + onClick={trackCallback( + 'click market card' + (trackingPostfix ?? ''), + { + slug: contract.slug, + contractId: contract.id, + } + )} className="absolute top-0 left-0 right-0 bottom-0" /> </Link> diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 603173f6..2f804644 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -26,6 +26,7 @@ export function ContractsGrid(props: { hideGroupLink?: boolean } highlightOptions?: ContractHighlightOptions + trackingPostfix?: string }) { const { contracts, @@ -34,6 +35,7 @@ export function ContractsGrid(props: { onContractClick, cardHideOptions, highlightOptions, + trackingPostfix, } = props const { hideQuickBet, hideGroupLink } = cardHideOptions || {} const { contractIds, highlightClassName } = highlightOptions || {} @@ -79,6 +81,7 @@ export function ContractsGrid(props: { } hideQuickBet={hideQuickBet} hideGroupLink={hideGroupLink} + trackingPostfix={trackingPostfix} className={clsx( 'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox) contractIds?.includes(contract.id) && highlightClassName diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2ef472fc..f9f45144 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -285,7 +285,10 @@ export function ContractPageContent( {recommendedContracts.length > 0 && ( <Col className="mt-2 gap-2 px-2 sm:px-0"> <Title className="text-gray-700" text="Recommended" /> - <ContractsGrid contracts={recommendedContracts} /> + <ContractsGrid + contracts={recommendedContracts} + trackingPostfix=" recommended" + /> </Col> )} </Page> diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 65f5597f..c089226d 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -215,6 +215,7 @@ function Section(props: { hideGroupLink className="max-h-[200px] w-96 shrink-0" questionClass="line-clamp-3" + trackingPostfix=" tournament" /> )) ) : ( From ecacce07963742bf88a28f4f9d7b1f7f395d4fa5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 29 Aug 2022 00:26:12 -0500 Subject: [PATCH 140/279] Remove console.log. Log onIdTokenChanged error. --- web/components/auth-context.tsx | 49 ++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 6957d062..ea01ce1e 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -46,30 +46,35 @@ export function AuthProvider(props: { }, [setAuthUser, serverUser]) useEffect(() => { - return onIdTokenChanged(auth, async (fbUser) => { - console.log('onIdTokenChanged', fbUser) - if (fbUser) { - setTokenCookies({ - id: await fbUser.getIdToken(), - refresh: fbUser.refreshToken, - }) - let current = await getUserAndPrivateUser(fbUser.uid) - if (!current.user || !current.privateUser) { - const deviceToken = ensureDeviceToken() - current = (await createUser({ deviceToken })) as UserAndPrivateUser + return onIdTokenChanged( + auth, + async (fbUser) => { + if (fbUser) { + setTokenCookies({ + id: await fbUser.getIdToken(), + refresh: fbUser.refreshToken, + }) + let current = await getUserAndPrivateUser(fbUser.uid) + if (!current.user || !current.privateUser) { + const deviceToken = ensureDeviceToken() + current = (await createUser({ deviceToken })) as UserAndPrivateUser + } + setAuthUser(current) + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current)) + setCachedReferralInfoForUser(current.user) + } else { + // User logged out; reset to null + deleteTokenCookies() + setAuthUser(null) + localStorage.removeItem(CACHED_USER_KEY) } - setAuthUser(current) - // Persist to local storage, to reduce login blink next time. - // Note: Cap on localStorage size is ~5mb - localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current)) - setCachedReferralInfoForUser(current.user) - } else { - // User logged out; reset to null - deleteTokenCookies() - setAuthUser(null) - localStorage.removeItem(CACHED_USER_KEY) + }, + (e) => { + console.error(e) } - }) + ) }, [setAuthUser]) const uid = authUser?.user.id From 7ea6777d6b085f9c489562eb282b9126c4fc718a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 29 Aug 2022 00:29:59 -0500 Subject: [PATCH 141/279] Add margin bottom to tournament cards to reveal shadow --- web/pages/tournaments/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index c089226d..f3fcbfce 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -213,7 +213,7 @@ function Section(props: { <ContractCard contract={m} hideGroupLink - className="max-h-[200px] w-96 shrink-0" + className="mb-2 max-h-[200px] w-96 shrink-0" questionClass="line-clamp-3" trackingPostfix=" tournament" /> From 8f338a8d882cb7bc927674e65e6ed6ab18299446 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 28 Aug 2022 22:40:57 -0700 Subject: [PATCH 142/279] Prevent embeds from breaking in Chrome incognito (#814) --- web/components/auth-context.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index ea01ce1e..7347d039 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -19,6 +19,15 @@ import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' type AuthUser = undefined | null | UserAndPrivateUser const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' +// Proxy localStorage in case it's not available (eg in incognito iframe) +const localStorage = + typeof window !== 'undefined' + ? window.localStorage + : { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + } const ensureDeviceToken = () => { let deviceToken = localStorage.getItem('device-token') From 1d1b09c9382e8b912fd53097f2f2ca5fb8438b34 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 28 Aug 2022 23:23:40 -0700 Subject: [PATCH 143/279] Append question changed text to end of description (instead of start) --- web/components/contract/contract-description.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 9bf2114b..9bffed9b 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -128,6 +128,7 @@ function EditQuestion(props: { function joinContent(oldContent: ContentType, newContent: string) { const editor = new Editor({ content: oldContent, extensions: exhibitExts }) + editor.commands.focus('end') insertContent(editor, newContent) return editor.getJSON() } From 851cffd73ee0ad33872cd3c8f4aaa8164878f54e Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 29 Aug 2022 16:06:17 +0100 Subject: [PATCH 144/279] Dashboards (#791) * Create backend for Dashboards * Rm lastupdatetime for now * Added a create-dashboard and sharable view dashboard page * Various nit fixes. * Renamed Dashboards to Posts * Fix nits --- common/post.ts | 12 ++++ firestore.rules | 9 +++ functions/src/create-post.ts | 83 +++++++++++++++++++++++ functions/src/index.ts | 3 + functions/src/serve.ts | 2 + functions/src/utils.ts | 5 ++ web/components/file-upload-button.tsx | 6 +- web/components/share-post-modal.tsx | 46 +++++++++++++ web/lib/firebase/api.ts | 5 ++ web/lib/firebase/posts.ts | 34 ++++++++++ web/pages/create-post.tsx | 93 +++++++++++++++++++++++++ web/pages/post/[...slugs]/index.tsx | 97 +++++++++++++++++++++++++++ 12 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 common/post.ts create mode 100644 functions/src/create-post.ts create mode 100644 web/components/share-post-modal.tsx create mode 100644 web/lib/firebase/posts.ts create mode 100644 web/pages/create-post.tsx create mode 100644 web/pages/post/[...slugs]/index.tsx diff --git a/common/post.ts b/common/post.ts new file mode 100644 index 00000000..05eab685 --- /dev/null +++ b/common/post.ts @@ -0,0 +1,12 @@ +import { JSONContent } from '@tiptap/core' + +export type Post = { + id: string + title: string + content: JSONContent + creatorId: string // User id + createdTime: number + slug: string +} + +export const MAX_POST_TITLE_LENGTH = 480 diff --git a/firestore.rules b/firestore.rules index 4cd718d3..fe45071b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -175,5 +175,14 @@ service cloud.firestore { allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); } } + + match /posts/{postId} { + allow read; + allow update: if request.auth.uid == resource.data.creatorId + && request.resource.data.diff(resource.data) + .affectedKeys() + .hasOnly(['name', 'content']); + allow delete: if request.auth.uid == resource.data.creatorId; + } } } diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts new file mode 100644 index 00000000..40d39bba --- /dev/null +++ b/functions/src/create-post.ts @@ -0,0 +1,83 @@ +import * as admin from 'firebase-admin' + +import { getUser } from './utils' +import { slugify } from '../../common/util/slugify' +import { randomString } from '../../common/util/random' +import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' +import { APIError, newEndpoint, validate } from './api' +import { JSONContent } from '@tiptap/core' +import { z } from 'zod' + +const contentSchema: z.ZodType<JSONContent> = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) + +const postSchema = z.object({ + title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), + content: contentSchema, +}) + +export const createpost = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() + const { title, content } = validate(postSchema, req.body) + + const creator = await getUser(auth.uid) + if (!creator) + throw new APIError(400, 'No user exists with the authenticated user ID.') + + console.log('creating post owned by', creator.username, 'titled', title) + + const slug = await getSlug(title) + + const postRef = firestore.collection('posts').doc() + + const post: Post = { + id: postRef.id, + creatorId: creator.id, + slug, + title, + createdTime: Date.now(), + content: content, + } + + await postRef.create(post) + + return { status: 'success', post } +}) + +export const getSlug = async (title: string) => { + const proposedSlug = slugify(title) + + const preexistingPost = await getPostFromSlug(proposedSlug) + + return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug +} + +export async function getPostFromSlug(slug: string) { + const firestore = admin.firestore() + const snap = await firestore + .collection('posts') + .where('slug', '==', slug) + .get() + + return snap.empty ? undefined : (snap.docs[0].data() as Post) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 012ba241..32bc16c4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -72,6 +72,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -97,6 +98,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const getCustomTokenFunction = toCloudFunction(getcustomtoken) +const createPostFunction = toCloudFunction(createpost) export { healthFunction as health, @@ -120,4 +122,5 @@ export { getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, getCustomTokenFunction as getcustomtoken, + createPostFunction as createpost, } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 8d848f7f..db847a70 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) +addEndpointRoute('/createpost', createpost) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 2d620728..a0878e4f 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -4,6 +4,7 @@ import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' +import { Post } from 'common/post' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => { return getDoc<Group>('groups', groupId) } +export const getPost = (postId: string) => { + return getDoc<Post>('posts', postId) +} + export const getUser = (userId: string) => { return getDoc<User>('users', userId) } diff --git a/web/components/file-upload-button.tsx b/web/components/file-upload-button.tsx index 3ff15d91..0872fc1b 100644 --- a/web/components/file-upload-button.tsx +++ b/web/components/file-upload-button.tsx @@ -10,7 +10,11 @@ export function FileUploadButton(props: { const ref = useRef<HTMLInputElement>(null) return ( <> - <button className={className} onClick={() => ref.current?.click()}> + <button + type={'button'} + className={className} + onClick={() => ref.current?.click()} + > {children} </button> <input diff --git a/web/components/share-post-modal.tsx b/web/components/share-post-modal.tsx new file mode 100644 index 00000000..fa8658c6 --- /dev/null +++ b/web/components/share-post-modal.tsx @@ -0,0 +1,46 @@ +import { LinkIcon } from '@heroicons/react/outline' +import toast from 'react-hot-toast' +import { copyToClipboard } from 'web/lib/util/copy' +import { track } from 'web/lib/service/analytics' +import { Modal } from './layout/modal' +import { Col } from './layout/col' +import { Title } from './title' +import { Button } from './button' +import { TweetButton } from './tweet-button' +import { Row } from './layout/row' + +export function SharePostModal(props: { + shareUrl: string + isOpen: boolean + setOpen: (open: boolean) => void +}) { + const { isOpen, setOpen, shareUrl } = props + + const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> + + return ( + <Modal open={isOpen} setOpen={setOpen} size="md"> + <Col className="gap-4 rounded bg-white p-4"> + <Title className="!mt-0 !mb-2" text="Share this post" /> + <Button + size="2xl" + color="gradient" + className={'mb-2 flex max-w-xs self-center'} + onClick={() => { + copyToClipboard(shareUrl) + toast.success('Link copied!', { + icon: linkIcon, + }) + track('copy share post link') + }} + > + {linkIcon} Copy link + </Button> + + <Row className="z-0 justify-start gap-4 self-center"> + <TweetButton className="self-start" tweetText={shareUrl} /> + </Row> + </Col> + </Modal> + ) +} diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 5f250ce7..6b1b43d8 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -1,5 +1,6 @@ import { auth } from './users' import { APIError, getFunctionUrl } from 'common/api' +import { JSONContent } from '@tiptap/core' export { APIError } from 'common/api' export async function call(url: string, method: string, params: any) { @@ -88,3 +89,7 @@ export function acceptChallenge(params: any) { export function getCurrentUser(params: any) { return call(getFunctionUrl('getcurrentuser'), 'GET', params) } + +export function createPost(params: { title: string; content: JSONContent }) { + return call(getFunctionUrl('createpost'), 'POST', params) +} diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts new file mode 100644 index 00000000..10bea499 --- /dev/null +++ b/web/lib/firebase/posts.ts @@ -0,0 +1,34 @@ +import { + deleteDoc, + doc, + getDocs, + query, + updateDoc, + where, +} from 'firebase/firestore' +import { Post } from 'common/post' +import { coll, getValue } from './utils' + +export const posts = coll<Post>('posts') + +export function postPath(postSlug: string) { + return `/post/${postSlug}` +} + +export function updatePost(post: Post, updates: Partial<Post>) { + return updateDoc(doc(posts, post.id), updates) +} + +export function deletePost(post: Post) { + return deleteDoc(doc(posts, post.id)) +} + +export function getPost(postId: string) { + return getValue<Post>(doc(posts, postId)) +} + +export async function getPostBySlug(slug: string) { + const q = query(posts, where('slug', '==', slug)) + const docs = (await getDocs(q)).docs + return docs.length === 0 ? null : docs[0].data() +} diff --git a/web/pages/create-post.tsx b/web/pages/create-post.tsx new file mode 100644 index 00000000..f88f56a5 --- /dev/null +++ b/web/pages/create-post.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react' +import { Spacer } from 'web/components/layout/spacer' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import Textarea from 'react-expanding-textarea' + +import { TextEditor, useTextEditor } from 'web/components/editor' +import { createPost } from 'web/lib/firebase/api' +import clsx from 'clsx' +import Router from 'next/router' +import { MAX_POST_TITLE_LENGTH } from 'common/post' +import { postPath } from 'web/lib/firebase/posts' + +export default function CreatePost() { + const [title, setTitle] = useState('') + const [error, setError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const { editor, upload } = useTextEditor({ + disabled: isSubmitting, + }) + + const isValid = editor && title.length > 0 && editor.isEmpty === false + + async function savePost(title: string) { + if (!editor) return + const newPost = { + title: title, + content: editor.getJSON(), + } + + const result = await createPost(newPost).catch((e) => { + console.log(e) + setError('There was an error creating the post, please try again') + return e + }) + if (result.post) { + await Router.push(postPath(result.post.slug)) + } + } + + return ( + <Page> + <div className="mx-auto w-full max-w-2xl"> + <div className="rounded-lg px-6 py-4 sm:py-0"> + <Title className="!mt-0" text="Create a post" /> + <form> + <div className="form-control w-full"> + <label className="label"> + <span className="mb-1"> + Title<span className={'text-red-700'}> *</span> + </span> + </label> + <Textarea + placeholder="e.g. Elon Mania Post" + className="input input-bordered resize-none" + autoFocus + maxLength={MAX_POST_TITLE_LENGTH} + value={title} + onChange={(e) => setTitle(e.target.value || '')} + /> + <Spacer h={6} /> + <label className="label"> + <span className="mb-1"> + Content<span className={'text-red-700'}> *</span> + </span> + </label> + <TextEditor editor={editor} upload={upload} /> + <Spacer h={6} /> + + <button + type="submit" + className={clsx( + 'btn btn-primary normal-case', + isSubmitting && 'loading disabled' + )} + disabled={isSubmitting || !isValid || upload.isLoading} + onClick={async () => { + setIsSubmitting(true) + await savePost(title) + setIsSubmitting(false) + }} + > + {isSubmitting ? 'Creating...' : 'Create a post'} + </button> + {error !== '' && <div className="text-red-700">{error}</div>} + </div> + </form> + </div> + </div> + </Page> + ) +} diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx new file mode 100644 index 00000000..41c0d775 --- /dev/null +++ b/web/pages/post/[...slugs]/index.tsx @@ -0,0 +1,97 @@ +import { Page } from 'web/components/page' + +import { postPath, getPostBySlug } 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 { UserLink } from 'web/components/user-page' +import { getUser, User } from 'web/lib/firebase/users' +import { ShareIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { Button } from 'web/components/button' +import { useState } from 'react' +import { SharePostModal } from 'web/components/share-post-modal' +import { Row } from 'web/components/layout/row' +import { Col } from 'web/components/layout/col' +import { ENV_CONFIG } from 'common/envs/constants' +import Custom404 from 'web/pages/404' + +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 + + return { + props: { + post: post, + creator: creator, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function PostPage(props: { post: Post; creator: User }) { + const [isShareOpen, setShareOpen] = useState(false) + + if (props.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} /> + <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} + /> + </div> + </Col> + <Col> + <Button + size="lg" + color="gray-white" + className={'flex'} + onClick={() => { + setShareOpen(true) + }} + > + <ShareIcon + className={clsx('mr-2 h-[24px] w-5')} + aria-hidden="true" + /> + Share + <SharePostModal + isOpen={isShareOpen} + setOpen={setShareOpen} + shareUrl={shareUrl} + /> + </Button> + </Col> + </Row> + + <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} /> + </div> + </div> + </div> + </Page> + ) +} From 84432e5ac470edd3e7d1d0ed47c3c69eee6b4fe7 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 29 Aug 2022 14:25:58 -0500 Subject: [PATCH 145/279] Add creatorId to lite market --- web/pages/api/v0/_types.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index 968b770e..3aa15901 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -14,6 +14,7 @@ export type LiteMarket = { id: string // Attributes about the creator + creatorId: string creatorUsername: string creatorName: string createdTime: number @@ -75,6 +76,7 @@ export class ValidationError { export function toLiteMarket(contract: Contract): LiteMarket { const { id, + creatorId, creatorUsername, creatorName, createdTime, @@ -108,6 +110,7 @@ export function toLiteMarket(contract: Contract): LiteMarket { return removeUndefinedProps({ id, + creatorId, creatorUsername, creatorName, createdTime, From 0318f7a12bc196f09f778878f38d1bd2a6ed5954 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 29 Aug 2022 13:47:24 -0600 Subject: [PATCH 146/279] Add missing parentheses --- functions/src/reset-betting-streaks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index 924f5c22..56e450fa 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -28,7 +28,7 @@ const resetBettingStreakForUser = async (user: User) => { const betStreakResetTime = Date.now() - DAY_MS // if they made a bet within the last day, don't reset their streak if ( - (user.lastBetTime ?? 0 > betStreakResetTime) || + (user?.lastBetTime ?? 0) > betStreakResetTime || !user.currentBettingStreak || user.currentBettingStreak === 0 ) From 1d948821cab578df3f3cbe95cb6626b233e88432 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 29 Aug 2022 16:47:19 -0500 Subject: [PATCH 147/279] Turn off react query subscription for user bets and portfolio history --- web/hooks/use-contracts.ts | 6 +----- web/hooks/use-portfolio-history.ts | 6 +----- web/hooks/use-user-bets.ts | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index f277a209..3ec1c56c 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -95,11 +95,7 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => { export const useUserBetContracts = (userId: string) => { const result = useFirestoreQueryData( ['contracts', 'bets', userId], - getUserBetContractsQuery(userId), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getUserBetContractsQuery(userId) ) return result.data } diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts index d5919783..d01ca29b 100644 --- a/web/hooks/use-portfolio-history.ts +++ b/web/hooks/use-portfolio-history.ts @@ -8,11 +8,7 @@ export const usePortfolioHistory = (userId: string, period: Period) => { const result = useFirestoreQueryData( ['portfolio-history', userId, cutoff], - getPortfolioHistoryQuery(userId, cutoff), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getPortfolioHistoryQuery(userId, cutoff) ) return result.data } diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index 72a4e5bf..ff1b23b3 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -9,11 +9,7 @@ import { export const useUserBets = (userId: string) => { const result = useFirestoreQueryData( ['bets', userId], - getUserBetsQuery(userId), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getUserBetsQuery(userId) ) return result.data } From 1369f3b967c59ca83d806afb7f0ffe1414114092 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Mon, 29 Aug 2022 21:56:11 -0700 Subject: [PATCH 148/279] WIP persistence work (#762) * WIP persistence work * Fix up close date filter, kill custom scroll restoration * Use built-in Next.js scroll restoration machinery * Tweaking stuff * Implement 'history state' idea * Clean up and unify persistent state stores * Respect options for persisting contract search * Fix typing in common lib * Clean up console logging --- common/util/object.ts | 3 +- web/components/contract-search.tsx | 260 +++++++++++++----------- web/hooks/use-persistent-state.ts | 106 ++++++++++ web/hooks/use-preserve-scroll.ts | 41 ---- web/hooks/use-sort-and-query-params.tsx | 65 ------ web/lib/util/local.ts | 15 +- web/next.config.js | 1 + web/pages/_app.tsx | 3 - web/pages/contract-search-firestore.tsx | 18 +- web/pages/experimental/home.tsx | 2 +- web/pages/home.tsx | 92 +-------- 11 files changed, 280 insertions(+), 326 deletions(-) create mode 100644 web/hooks/use-persistent-state.ts delete mode 100644 web/hooks/use-preserve-scroll.ts delete mode 100644 web/hooks/use-sort-and-query-params.tsx diff --git a/common/util/object.ts b/common/util/object.ts index 5596286e..41d2cd70 100644 --- a/common/util/object.ts +++ b/common/util/object.ts @@ -1,6 +1,6 @@ import { union } from 'lodash' -export const removeUndefinedProps = <T>(obj: T): T => { +export const removeUndefinedProps = <T extends object>(obj: T): T => { const newObj: any = {} for (const key of Object.keys(obj)) { @@ -37,4 +37,3 @@ export const subtractObjects = <T extends { [key: string]: number }>( return newObj as T } - diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 34e1ff0d..fa6ea204 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -1,44 +1,35 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import algoliasearch, { SearchIndex } from 'algoliasearch/lite' +import algoliasearch from 'algoliasearch/lite' import { SearchOptions } from '@algolia/client-search' - +import { useRouter } from 'next/router' import { Contract } from 'common/contract' import { User } from 'common/user' -import { Sort, useQuery, useSort } from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, } from './contract/contracts-grid' import { ShowTime } from './contract/contract-details' import { Row } from './layout/row' -import { useEffect, useRef, useMemo, useState } from 'react' -import { unstable_batchedUpdates } from 'react-dom' +import { useEffect, useLayoutEffect, useRef, useMemo } from 'react' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' +import { + storageStore, + historyStore, + urlParamStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' import { track, trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' import { NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' -import { debounce, sortBy } from 'lodash' +import { debounce, isEqual, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { Col } from './layout/col' -import { safeLocalStorage } from 'web/lib/util/local' import clsx from 'clsx' -// TODO: this obviously doesn't work with SSR, common sense would suggest -// that we should save things like this in cookies so the server has them - -const MARKETS_SORT = 'markets_sort' - -function setSavedSort(s: Sort) { - safeLocalStorage()?.setItem(MARKETS_SORT, s) -} - -function getSavedSort() { - return safeLocalStorage()?.getItem(MARKETS_SORT) as Sort | null | undefined -} - const searchClient = algoliasearch( 'GJQPAYENIF', '75c28fc084a80e1129d427d470cf41a3' @@ -47,7 +38,7 @@ const searchClient = algoliasearch( const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' -const sortOptions = [ +const SORTS = [ { label: 'Newest', value: 'newest' }, { label: 'Trending', value: 'score' }, { label: 'Most traded', value: 'most-traded' }, @@ -56,16 +47,17 @@ const sortOptions = [ { label: 'Subsidy', value: 'liquidity' }, { label: 'Close date', value: 'close-date' }, { label: 'Resolve date', value: 'resolve-date' }, -] +] as const + +export type Sort = typeof SORTS[number]['value'] type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type SearchParameters = { - index: SearchIndex query: string - numericFilters: SearchOptions['numericFilters'] + sort: Sort + openClosedFilter: 'open' | 'closed' | undefined facetFilters: SearchOptions['facetFilters'] - showTime?: ShowTime } type AdditionalFilter = { @@ -88,8 +80,8 @@ export function ContractSearch(props: { hideQuickBet?: boolean } headerClassName?: string - useQuerySortLocalStorage?: boolean - useQuerySortUrlParams?: boolean + persistPrefix?: string + useQueryUrlParam?: boolean isWholePage?: boolean maxItems?: number noControls?: boolean @@ -104,66 +96,94 @@ export function ContractSearch(props: { cardHideOptions, highlightOptions, headerClassName, - useQuerySortLocalStorage, - useQuerySortUrlParams, + persistPrefix, + useQueryUrlParam, isWholePage, maxItems, noControls, } = props - const [numPages, setNumPages] = useState(1) - const [pages, setPages] = useState<Contract[][]>([]) - const [showTime, setShowTime] = useState<ShowTime | undefined>() + const [state, setState] = usePersistentState( + { + numPages: 1, + pages: [] as Contract[][], + showTime: null as ShowTime | null, + }, + !persistPrefix + ? undefined + : { key: `${persistPrefix}-search`, store: historyStore() } + ) - const searchParameters = useRef<SearchParameters | undefined>() + const searchParams = useRef<SearchParameters | null>(null) + const searchParamsStore = historyStore<SearchParameters>() const requestId = useRef(0) + useLayoutEffect(() => { + if (persistPrefix) { + const params = searchParamsStore.get(`${persistPrefix}-params`) + if (params !== undefined) { + searchParams.current = params + } + } + }, []) + + const searchIndex = useMemo( + () => searchClient.initIndex(searchIndexName), + [searchIndexName] + ) + const performQuery = async (freshQuery?: boolean) => { - if (searchParameters.current === undefined) { + if (searchParams.current == null) { return } - const params = searchParameters.current + const { query, sort, openClosedFilter, facetFilters } = searchParams.current const id = ++requestId.current - const requestedPage = freshQuery ? 0 : pages.length - if (freshQuery || requestedPage < numPages) { - const results = await params.index.search(params.query, { - facetFilters: params.facetFilters, - numericFilters: params.numericFilters, + const requestedPage = freshQuery ? 0 : state.pages.length + if (freshQuery || requestedPage < state.numPages) { + const index = query + ? searchIndex + : searchClient.initIndex(`${indexPrefix}contracts-${sort}`) + const numericFilters = query + ? [] + : [ + openClosedFilter === 'open' ? `closeTime > ${Date.now()}` : '', + openClosedFilter === 'closed' ? `closeTime <= ${Date.now()}` : '', + ].filter((f) => f) + const results = await index.search(query, { + facetFilters, + numericFilters, page: requestedPage, hitsPerPage: 20, }) // if there's a more recent request, forget about this one if (id === requestId.current) { const newPage = results.hits as any as Contract[] - // this spooky looking function is the easiest way to get react to - // batch this and not do multiple renders. we can throw it out in react 18. - // see https://github.com/reactwg/react-18/discussions/21 - unstable_batchedUpdates(() => { - setShowTime(params.showTime) - setNumPages(results.nbPages) - if (freshQuery) { - setPages([newPage]) - if (isWholePage) window.scrollTo(0, 0) - } else { - setPages((pages) => [...pages, newPage]) - } - }) + const showTime = + sort === 'close-date' || sort === 'resolve-date' ? sort : null + const pages = freshQuery ? [newPage] : [...state.pages, newPage] + setState({ numPages: results.nbPages, pages, showTime }) + if (freshQuery && isWholePage) window.scrollTo(0, 0) } } } const onSearchParametersChanged = useRef( debounce((params) => { - searchParameters.current = params - performQuery(true) + if (!isEqual(searchParams.current, params)) { + if (persistPrefix) { + searchParamsStore.set(`${persistPrefix}-params`, params) + } + searchParams.current = params + performQuery(true) + } }, 100) ).current - const contracts = pages + const contracts = state.pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) const renderedContracts = - pages.length === 0 ? undefined : contracts.slice(0, maxItems) + state.pages.length === 0 ? undefined : contracts.slice(0, maxItems) if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return <ContractSearchFirestore additionalFilter={additionalFilter} /> @@ -177,8 +197,8 @@ export function ContractSearch(props: { defaultFilter={defaultFilter} additionalFilter={additionalFilter} hideOrderSelector={hideOrderSelector} - useQuerySortLocalStorage={useQuerySortLocalStorage} - useQuerySortUrlParams={useQuerySortUrlParams} + persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined} + useQueryUrlParam={useQueryUrlParam} user={user} onSearchParametersChanged={onSearchParametersChanged} noControls={noControls} @@ -186,7 +206,7 @@ export function ContractSearch(props: { <ContractsGrid contracts={renderedContracts} loadMore={noControls ? undefined : performQuery} - showTime={showTime} + showTime={state.showTime ?? undefined} onContractClick={onContractClick} highlightOptions={highlightOptions} cardHideOptions={cardHideOptions} @@ -202,8 +222,8 @@ function ContractSearchControls(props: { additionalFilter?: AdditionalFilter hideOrderSelector?: boolean onSearchParametersChanged: (params: SearchParameters) => void - useQuerySortLocalStorage?: boolean - useQuerySortUrlParams?: boolean + persistPrefix?: string + useQueryUrlParam?: boolean user?: User | null noControls?: boolean }) { @@ -214,25 +234,36 @@ function ContractSearchControls(props: { additionalFilter, hideOrderSelector, onSearchParametersChanged, - useQuerySortLocalStorage, - useQuerySortUrlParams, + persistPrefix, + useQueryUrlParam, user, noControls, } = props - const savedSort = useQuerySortLocalStorage ? getSavedSort() : null - const initialSort = savedSort ?? defaultSort ?? 'score' - const querySortOpts = { useUrl: !!useQuerySortUrlParams } - const [sort, setSort] = useSort(initialSort, querySortOpts) - const [query, setQuery] = useQuery('', querySortOpts) - const [filter, setFilter] = useState<filter>(defaultFilter ?? 'open') - const [pillFilter, setPillFilter] = useState<string | undefined>(undefined) + const router = useRouter() + const [query, setQuery] = usePersistentState( + '', + !useQueryUrlParam + ? undefined + : { + key: 'q', + store: urlParamStore(router), + } + ) - useEffect(() => { - if (useQuerySortLocalStorage) { - setSavedSort(sort) - } - }, [sort]) + const [state, setState] = usePersistentState( + { + sort: defaultSort ?? 'score', + filter: defaultFilter ?? 'open', + pillFilter: null as string | null, + }, + !persistPrefix + ? undefined + : { + key: `${persistPrefix}-params`, + store: storageStore(safeLocalStorage()), + } + ) const follows = useFollows(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter( @@ -266,14 +297,16 @@ function ContractSearchControls(props: { ...additionalFilters, additionalFilter ? '' : 'visibility:public', - filter === 'open' ? 'isResolved:false' : '', - filter === 'closed' ? 'isResolved:false' : '', - filter === 'resolved' ? 'isResolved:true' : '', + state.filter === 'open' ? 'isResolved:false' : '', + state.filter === 'closed' ? 'isResolved:false' : '', + state.filter === 'resolved' ? 'isResolved:true' : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' - ? `groupLinks.slug:${pillFilter}` + state.pillFilter && + state.pillFilter !== 'personal' && + state.pillFilter !== 'your-bets' + ? `groupLinks.slug:${state.pillFilter}` : '', - pillFilter === 'personal' + state.pillFilter === 'personal' ? // Show contracts in groups that the user is a member of memberGroupSlugs .map((slug) => `groupLinks.slug:${slug}`) @@ -285,22 +318,24 @@ function ContractSearchControls(props: { ) : '', // Subtract contracts you bet on from For you. - pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', - pillFilter === 'your-bets' && user + state.pillFilter === 'personal' && user + ? `uniqueBettorIds:-${user.id}` + : '', + state.pillFilter === 'your-bets' && user ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` : '', ].filter((f) => f) - const numericFilters = query - ? [] - : [ - filter === 'open' ? `closeTime > ${Date.now()}` : '', - filter === 'closed' ? `closeTime <= ${Date.now()}` : '', - ].filter((f) => f) + const openClosedFilter = + state.filter === 'open' + ? 'open' + : state.filter === 'closed' + ? 'closed' + : undefined - const selectPill = (pill: string | undefined) => () => { - setPillFilter(pill) + const selectPill = (pill: string | null) => () => { + setState({ ...state, pillFilter: pill }) track('select search category', { category: pill ?? 'all' }) } @@ -309,34 +344,25 @@ function ContractSearchControls(props: { } const selectFilter = (newFilter: filter) => { - if (newFilter === filter) return - setFilter(newFilter) + if (newFilter === state.filter) return + setState({ ...state, filter: newFilter }) track('select search filter', { filter: newFilter }) } const selectSort = (newSort: Sort) => { - if (newSort === sort) return - setSort(newSort) + if (newSort === state.sort) return + setState({ ...state, sort: newSort }) track('select search sort', { sort: newSort }) } - const indexName = `${indexPrefix}contracts-${sort}` - const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) - const searchIndex = useMemo( - () => searchClient.initIndex(searchIndexName), - [searchIndexName] - ) - useEffect(() => { onSearchParametersChanged({ - index: query ? searchIndex : index, query: query, - numericFilters: numericFilters, + sort: state.sort, + openClosedFilter: openClosedFilter, facetFilters: facetFilters, - showTime: - sort === 'close-date' || sort === 'resolve-date' ? sort : undefined, }) - }, [query, index, searchIndex, filter, JSON.stringify(facetFilters)]) + }, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)]) if (noControls) { return <></> @@ -351,14 +377,14 @@ function ContractSearchControls(props: { type="text" value={query} onChange={(e) => updateQuery(e.target.value)} - onBlur={trackCallback('search', { query })} + onBlur={trackCallback('search', { query: query })} placeholder={'Search'} className="input input-bordered w-full" /> {!query && ( <select className="select select-bordered" - value={filter} + value={state.filter} onChange={(e) => selectFilter(e.target.value as filter)} > <option value="open">Open</option> @@ -370,10 +396,10 @@ function ContractSearchControls(props: { {!hideOrderSelector && !query && ( <select className="select select-bordered" - value={sort} + value={state.sort} onChange={(e) => selectSort(e.target.value as Sort)} > - {sortOptions.map((option) => ( + {SORTS.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> @@ -386,14 +412,14 @@ function ContractSearchControls(props: { <Row className="scrollbar-hide items-start gap-2 overflow-x-auto"> <PillButton key={'all'} - selected={pillFilter === undefined} - onSelect={selectPill(undefined)} + selected={state.pillFilter === undefined} + onSelect={selectPill(null)} > All </PillButton> <PillButton key={'personal'} - selected={pillFilter === 'personal'} + selected={state.pillFilter === 'personal'} onSelect={selectPill('personal')} > {user ? 'For you' : 'Featured'} @@ -402,7 +428,7 @@ function ContractSearchControls(props: { {user && ( <PillButton key={'your-bets'} - selected={pillFilter === 'your-bets'} + selected={state.pillFilter === 'your-bets'} onSelect={selectPill('your-bets')} > Your bets @@ -413,7 +439,7 @@ function ContractSearchControls(props: { return ( <PillButton key={slug} - selected={pillFilter === slug} + selected={state.pillFilter === slug} onSelect={selectPill(slug)} > {name} diff --git a/web/hooks/use-persistent-state.ts b/web/hooks/use-persistent-state.ts new file mode 100644 index 00000000..090aa264 --- /dev/null +++ b/web/hooks/use-persistent-state.ts @@ -0,0 +1,106 @@ +import { useEffect } from 'react' +import { useStateCheckEquality } from './use-state-check-equality' +import { NextRouter } from 'next/router' + +export type PersistenceOptions<T> = { key: string; store: PersistentStore<T> } + +export interface PersistentStore<T> { + get: (k: string) => T | undefined + set: (k: string, v: T | undefined) => void +} + +const withURLParam = (location: Location, k: string, v?: string) => { + const newParams = new URLSearchParams(location.search) + if (!v) { + newParams.delete(k) + } else { + newParams.set(k, v) + } + const newUrl = new URL(location.href) + newUrl.search = newParams.toString() + return newUrl +} + +export const storageStore = <T>(storage?: Storage): PersistentStore<T> => ({ + get: (k: string) => { + if (!storage) { + return undefined + } + const saved = storage.getItem(k) + if (typeof saved === 'string') { + try { + return JSON.parse(saved) as T + } catch (e) { + console.error(e) + } + } else { + return undefined + } + }, + set: (k: string, v: T | undefined) => { + if (storage) { + if (v === undefined) { + storage.removeItem(k) + } else { + storage.setItem(k, JSON.stringify(v)) + } + } + }, +}) + +export const urlParamStore = (router: NextRouter): PersistentStore<string> => ({ + get: (k: string) => { + const v = router.query[k] + return typeof v === 'string' ? v : undefined + }, + set: (k: string, v: string | undefined) => { + if (typeof window !== 'undefined') { + // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 + const url = withURLParam(window.location, k, v).toString() + const updatedState = { ...window.history.state, as: url, url } + window.history.replaceState(updatedState, '', url) + } + }, +}) + +export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({ + get: (k: string) => { + if (typeof window !== 'undefined') { + return window.history.state?.options?.[prefix]?.[k] as T | undefined + } else { + return undefined + } + }, + set: (k: string, v: T | undefined) => { + if (typeof window !== 'undefined') { + const state = window.history.state ?? {} + const options = state.options ?? {} + const inner = options[prefix] ?? {} + window.history.replaceState( + { + ...state, + options: { ...options, [prefix]: { ...inner, [k]: v } }, + }, + '' + ) + } + }, +}) + +export const usePersistentState = <T>( + initial: T, + persist?: PersistenceOptions<T> +) => { + const store = persist?.store + const key = persist?.key + // note that it's important in some cases to get the state correct during the + // first render, or scroll restoration won't take into account the saved state + const savedValue = key != null && store != null ? store.get(key) : undefined + const [state, setState] = useStateCheckEquality(savedValue ?? initial) + useEffect(() => { + if (key != null && store != null) { + store.set(key, state) + } + }, [key, state]) + return [state, setState] as const +} diff --git a/web/hooks/use-preserve-scroll.ts b/web/hooks/use-preserve-scroll.ts deleted file mode 100644 index e314d11f..00000000 --- a/web/hooks/use-preserve-scroll.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useRouter } from 'next/router' -import { useEffect, useRef } from 'react' - -// From: https://jak-ch-ll.medium.com/next-js-preserve-scroll-history-334cf699802a -export const usePreserveScroll = () => { - const router = useRouter() - - const scrollPositions = useRef<{ [url: string]: number }>({}) - const isBack = useRef(false) - - useEffect(() => { - router.beforePopState(() => { - isBack.current = true - return true - }) - - const onRouteChangeStart = () => { - const url = router.pathname - scrollPositions.current[url] = window.scrollY - } - - const onRouteChangeComplete = (url: any) => { - if (isBack.current && scrollPositions.current[url]) { - window.scroll({ - top: scrollPositions.current[url], - behavior: 'auto', - }) - } - - isBack.current = false - } - - router.events.on('routeChangeStart', onRouteChangeStart) - router.events.on('routeChangeComplete', onRouteChangeComplete) - - return () => { - router.events.off('routeChangeStart', onRouteChangeStart) - router.events.off('routeChangeComplete', onRouteChangeComplete) - } - }, [router]) -} diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx deleted file mode 100644 index 0a2834d0..00000000 --- a/web/hooks/use-sort-and-query-params.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from 'react' -import { NextRouter, useRouter } from 'next/router' - -export type Sort = - | 'newest' - | 'oldest' - | 'most-traded' - | '24-hour-vol' - | 'close-date' - | 'resolve-date' - | 'last-updated' - | 'score' - -type UpdatedQueryParams = { [k: string]: string } -type QuerySortOpts = { useUrl: boolean } - -function withURLParams(location: Location, params: UpdatedQueryParams) { - const newParams = new URLSearchParams(location.search) - for (const [k, v] of Object.entries(params)) { - if (!v) { - newParams.delete(k) - } else { - newParams.set(k, v) - } - } - const newUrl = new URL(location.href) - newUrl.search = newParams.toString() - return newUrl -} - -function updateURL(params: UpdatedQueryParams) { - // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 - const url = withURLParams(window.location, params).toString() - const updatedState = { ...window.history.state, as: url, url } - window.history.replaceState(updatedState, '', url) -} - -function getStringURLParam(router: NextRouter, k: string) { - const v = router.query[k] - return typeof v === 'string' ? v : null -} - -export function useQuery(defaultQuery: string, opts?: QuerySortOpts) { - const useUrl = opts?.useUrl ?? false - const router = useRouter() - const initialQuery = useUrl ? getStringURLParam(router, 'q') : null - const [query, setQuery] = useState(initialQuery ?? defaultQuery) - if (!useUrl) { - return [query, setQuery] as const - } else { - return [query, (q: string) => (setQuery(q), updateURL({ q }))] as const - } -} - -export function useSort(defaultSort: Sort, opts?: QuerySortOpts) { - const useUrl = opts?.useUrl ?? false - const router = useRouter() - const initialSort = useUrl ? (getStringURLParam(router, 's') as Sort) : null - const [sort, setSort] = useState(initialSort ?? defaultSort) - if (!useUrl) { - return [sort, setSort] as const - } else { - return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const - } -} diff --git a/web/lib/util/local.ts b/web/lib/util/local.ts index 0778c0ac..d533e345 100644 --- a/web/lib/util/local.ts +++ b/web/lib/util/local.ts @@ -1,4 +1,7 @@ -export const safeLocalStorage = () => (isLocalStorage() ? localStorage : null) +export const safeLocalStorage = () => + isLocalStorage() ? localStorage : undefined +export const safeSessionStorage = () => + isSessionStorage() ? sessionStorage : undefined const isLocalStorage = () => { try { @@ -9,3 +12,13 @@ const isLocalStorage = () => { return false } } + +const isSessionStorage = () => { + try { + sessionStorage.getItem('test') + sessionStorage.setItem('hi', 'mom') + return true + } catch (e) { + return false + } +} diff --git a/web/next.config.js b/web/next.config.js index 5a418016..6ade8674 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -8,6 +8,7 @@ module.exports = { reactStrictMode: true, optimizeFonts: false, experimental: { + scrollRestoration: true, externalDir: true, modularizeImports: { '@heroicons/react/solid/?(((\\w*)?/?)*)': { diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index bb620950..d5a38272 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -3,7 +3,6 @@ import type { AppProps } from 'next/app' import { useEffect } from 'react' import Head from 'next/head' import Script from 'next/script' -import { usePreserveScroll } from 'web/hooks/use-preserve-scroll' import { QueryClient, QueryClientProvider } from 'react-query' import { AuthProvider } from 'web/components/auth-context' import Welcome from 'web/components/onboarding/welcome' @@ -26,8 +25,6 @@ function printBuildInfo() { } function MyApp({ Component, pageProps }: AppProps) { - usePreserveScroll() - useEffect(printBuildInfo, []) return ( diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index ec480269..4691030c 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -1,9 +1,13 @@ +import { useRouter } from 'next/router' import { Answer } from 'common/answer' import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' -import { Sort, useQuery, useSort } from 'web/hooks/use-sort-and-query-params' +import { + usePersistentState, + urlParamStore, +} from 'web/hooks/use-persistent-state' const MAX_CONTRACTS_RENDERED = 100 @@ -15,10 +19,12 @@ export default function ContractSearchFirestore(props: { groupSlug?: string } }) { - const contracts = useContracts() const { additionalFilter } = props - const [query, setQuery] = useQuery('', { useUrl: true }) - const [sort, setSort] = useSort('score', { useUrl: true }) + const contracts = useContracts() + const router = useRouter() + const store = urlParamStore(router) + const [query, setQuery] = usePersistentState('', { key: 'q', store }) + const [sort, setSort] = usePersistentState('score', { key: 'sort', store }) let matches = (contracts ?? []).filter((c) => searchInAny( @@ -34,8 +40,6 @@ export default function ContractSearchFirestore(props: { matches.sort((a, b) => b.createdTime - a.createdTime) } else if (sort === 'resolve-date') { matches = sortBy(matches, (contract) => -1 * (contract.resolutionTime ?? 0)) - } else if (sort === 'oldest') { - matches.sort((a, b) => a.createdTime - b.createdTime) } else if (sort === 'close-date') { matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) matches = sortBy(matches, (contract) => contract.closeTime ?? Infinity) @@ -93,7 +97,7 @@ export default function ContractSearchFirestore(props: { <select className="select select-bordered" value={sort} - onChange={(e) => setSort(e.target.value as Sort)} + onChange={(e) => setSort(e.target.value)} > <option value="score">Trending</option> <option value="newest">Newest</option> diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx index 380f4286..607c54d0 100644 --- a/web/pages/experimental/home.tsx +++ b/web/pages/experimental/home.tsx @@ -12,7 +12,7 @@ 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/hooks/use-sort-and-query-params' +import { Sort } from 'web/components/contract-search' import { Button } from 'web/components/button' import { Spacer } from 'web/components/layout/spacer' import { useMemberGroups } from 'web/hooks/use-group' diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 5b6c445c..65161398 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,14 +1,10 @@ -import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { PencilAltIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { ContractSearch } from 'web/components/contract-search' -import { Contract } from 'common/contract' import { User } from 'common/user' -import { ContractPageContent } from './[username]/[contractSlug]' -import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' @@ -25,8 +21,6 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const Home = (props: { auth: { user: User } | null }) => { const user = props.auth ? props.auth.user : null - const [contract, setContract] = useContractPage() - const router = useRouter() useTracking('view home') @@ -35,19 +29,12 @@ const Home = (props: { auth: { user: User } | null }) => { return ( <> - <Page className={contract ? 'sr-only' : ''}> + <Page> <Col className="mx-auto w-full p-2"> <ContractSearch user={user} - useQuerySortLocalStorage={true} - useQuerySortUrlParams={true} - onContractClick={(c) => { - // Show contract without navigating to contract page. - setContract(c) - // Update the url without switching pages in Nextjs. - history.pushState(null, '', `/${c.creatorUsername}/${c.slug}`) - }} - isWholePage + persistPrefix="home-search" + useQueryUrlParam={true} /> </Col> <button @@ -61,81 +48,8 @@ const Home = (props: { auth: { user: User } | null }) => { <PencilAltIcon className="h-7 w-7" aria-hidden="true" /> </button> </Page> - - {contract && ( - <ContractPageContent - contract={contract} - user={user} - username={contract.creatorUsername} - slug={contract.slug} - bets={[]} - comments={[]} - backToHome={() => { - history.back() - }} - recommendedContracts={[]} - /> - )} </> ) } -const useContractPage = () => { - const [contract, setContract] = useState<Contract | undefined>() - - useEffect(() => { - const updateContract = () => { - const path = location.pathname.split('/').slice(1) - if (path[0] === 'home') setContract(undefined) - else { - const [username, contractSlug] = path - if (!username || !contractSlug) setContract(undefined) - else { - // Show contract if route is to a contract: '/[username]/[contractSlug]'. - getContractFromSlug(contractSlug).then((contract) => { - const path = location.pathname.split('/').slice(1) - const [_username, contractSlug] = path - // Make sure we're still on the same contract. - if (contract?.slug === contractSlug) setContract(contract) - }) - } - } - } - - addEventListener('popstate', updateContract) - - const { pushState, replaceState } = window.history - - window.history.pushState = function () { - // eslint-disable-next-line prefer-rest-params - const args = [...(arguments as any)] as any - // Discard NextJS router state. - args[0] = null - pushState.apply(history, args) - updateContract() - } - - window.history.replaceState = function () { - // eslint-disable-next-line prefer-rest-params - const args = [...(arguments as any)] as any - // Discard NextJS router state. - args[0] = null - replaceState.apply(history, args) - updateContract() - } - - return () => { - removeEventListener('popstate', updateContract) - window.history.pushState = pushState - window.history.replaceState = replaceState - } - }, []) - - useEffect(() => { - if (contract) window.scrollTo(0, 0) - }, [contract]) - - return [contract, setContract] as const -} - export default Home From c7452796f018d8702b26a30b38736f39596fb313 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 30 Aug 2022 00:22:11 -0500 Subject: [PATCH 149/279] Recommend contracts you haven't bet on --- web/lib/firebase/contracts.ts | 49 +++++++++++++------------ web/pages/[username]/[contractSlug].tsx | 18 ++++----- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 2751e9bb..0fea53a0 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -13,7 +13,7 @@ import { updateDoc, where, } from 'firebase/firestore' -import { sortBy, sum, uniqBy } from 'lodash' +import { partition, sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' import { BinaryContract, Contract } from 'common/contract' @@ -303,62 +303,63 @@ export async function getClosingSoonContracts() { return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime) } -export const getRandTopCreatorContracts = async ( +export const getTopCreatorContracts = async ( creatorId: string, - count: number, - excluding: string[] = [] + count: number ) => { const creatorContractsQuery = query( contracts, where('isResolved', '==', false), where('creatorId', '==', creatorId), orderBy('popularityScore', 'desc'), - limit(count * 2) + limit(count) ) - const data = await getValues<Contract>(creatorContractsQuery) - const open = data - .filter((c) => c.closeTime && c.closeTime > Date.now()) - .filter((c) => !excluding.includes(c.id)) - - return chooseRandomSubset(open, count) + return await getValues<Contract>(creatorContractsQuery) } -export const getRandTopGroupContracts = async ( +export const getTopGroupContracts = async ( groupSlug: string, - count: number, - excluding: string[] = [] + count: number ) => { const creatorContractsQuery = query( contracts, where('groupSlugs', 'array-contains', groupSlug), where('isResolved', '==', false), orderBy('popularityScore', 'desc'), - limit(count * 2) + limit(count) ) - const data = await getValues<Contract>(creatorContractsQuery) - const open = data - .filter((c) => c.closeTime && c.closeTime > Date.now()) - .filter((c) => !excluding.includes(c.id)) - - return chooseRandomSubset(open, count) + return await getValues<Contract>(creatorContractsQuery) } export const getRecommendedContracts = async ( contract: Contract, + excludeBettorId: string, count: number ) => { const { creatorId, groupSlugs, id } = contract const [userContracts, groupContracts] = await Promise.all([ - getRandTopCreatorContracts(creatorId, count, [id]), + getTopCreatorContracts(creatorId, count * 2), groupSlugs && groupSlugs[0] - ? getRandTopGroupContracts(groupSlugs[0], count, [id]) + ? getTopGroupContracts(groupSlugs[0], count * 2) : [], ]) const combined = uniqBy([...userContracts, ...groupContracts], (c) => c.id) - return chooseRandomSubset(combined, count) + const open = combined + .filter((c) => c.closeTime && c.closeTime > Date.now()) + .filter((c) => c.id !== id) + + const [betOnContracts, nonBetOnContracts] = partition( + open, + (c) => c.uniqueBettorIds && c.uniqueBettorIds.includes(excludeBettorId) + ) + const chosen = chooseRandomSubset(nonBetOnContracts, count) + if (chosen.length < count) + chosen.push(...chooseRandomSubset(betOnContracts, count - chosen.length)) + + return chosen } export async function getRecentBetsAndComments(contract: Contract) { diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index f9f45144..f7a5c5c5 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -52,10 +52,9 @@ export async function getStaticPropz(props: { const contract = (await getContractFromSlug(contractSlug)) || null const contractId = contract?.id - const [bets, comments, recommendedContracts] = await Promise.all([ + const [bets, comments] = await Promise.all([ contractId ? listAllBets(contractId) : [], contractId ? listAllComments(contractId) : [], - contract ? getRecommendedContracts(contract, 6) : [], ]) return { @@ -66,7 +65,6 @@ export async function getStaticPropz(props: { // Limit the data sent to the client. Client will still load all bets and comments directly. bets: bets.slice(0, 5000), comments: comments.slice(0, 1000), - recommendedContracts, }, revalidate: 60, // regenerate after a minute @@ -83,7 +81,6 @@ export default function ContractPage(props: { bets: Bet[] comments: ContractComment[] slug: string - recommendedContracts: Contract[] backToHome?: () => void }) { props = usePropz(props, getStaticPropz) ?? { @@ -91,7 +88,6 @@ export default function ContractPage(props: { username: '', comments: [], bets: [], - recommendedContracts: [], slug: '', } @@ -188,15 +184,17 @@ export function ContractPageContent( setShowConfetti(shouldSeeConfetti) }, [contract, user]) - const [recommendedContracts, setRecommendedMarkets] = useState( - props.recommendedContracts + const [recommendedContracts, setRecommendedContracts] = useState<Contract[]>( + [] ) useEffect(() => { - if (contract && recommendedContracts.length === 0) { - getRecommendedContracts(contract, 6).then(setRecommendedMarkets) + if (contract && user) { + getRecommendedContracts(contract, user.id, 6).then( + setRecommendedContracts + ) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contract.id, recommendedContracts]) + }, [contract.id, user?.id]) const { isResolved, question, outcomeType } = contract From 1e3a0ca3d96f4d635c788e570dfde4a743aae786 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 30 Aug 2022 01:44:45 -0700 Subject: [PATCH 150/279] Upgrade Typescript, ESLint, Prettier (#817) * Bump Typescript to 4.8.2, eslint, prettier * Fix some loose typing * Fix prettier complaint --- package.json | 10 +- web/components/feed/feed-comments.tsx | 6 +- web/lib/firebase/server-auth.ts | 4 +- yarn.lock | 180 +++++++++++++++++--------- 4 files changed, 133 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index e90daf86..dd60d92b 100644 --- a/package.json +++ b/package.json @@ -14,16 +14,16 @@ "dependencies": {}, "devDependencies": { "@types/node": "16.11.11", - "@typescript-eslint/eslint-plugin": "5.25.0", - "@typescript-eslint/parser": "5.25.0", + "@typescript-eslint/eslint-plugin": "5.36.0", + "@typescript-eslint/parser": "5.36.0", "concurrently": "6.5.1", - "eslint": "8.15.0", + "eslint": "8.23.0", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-unused-imports": "^2.0.0", "nodemon": "2.0.19", - "prettier": "2.5.0", + "prettier": "2.7.1", "ts-node": "10.9.1", - "typescript": "4.6.4" + "typescript": "4.8.2" }, "resolutions": { "@types/react": "17.0.43" diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index d987caf5..d74be119 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -40,8 +40,10 @@ export function FeedCommentThread(props: { }) { const { contract, comments, bets, tips, smallAvatar, parentComment } = props const [showReply, setShowReply] = useState(false) - const [replyToUser, setReplyToUser] = - useState<{ id: string; username: string }>() + const [replyToUser, setReplyToUser] = useState<{ + id: string + username: string + }>() const betsByUserId = groupBy(bets, (bet) => bet.userId) const user = useUser() const commentsList = comments.filter( diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index 7ce8a814..ff6592e2 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -170,7 +170,7 @@ type GetServerSidePropsAuthed<P> = ( creds: UserCredential ) => Promise<GetServerSidePropsResult<P>> -export const redirectIfLoggedIn = <P>( +export const redirectIfLoggedIn = <P extends { [k: string]: any }>( dest: string, fn?: GetServerSideProps<P> ) => { @@ -191,7 +191,7 @@ export const redirectIfLoggedIn = <P>( } } -export const redirectIfLoggedOut = <P>( +export const redirectIfLoggedOut = <P extends { [k: string]: any }>( dest: string, fn?: GetServerSidePropsAuthed<P> ) => { diff --git a/yarn.lock b/yarn.lock index e0e1eefa..07755708 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1744,14 +1744,14 @@ url-loader "^4.1.1" webpack "^5.69.1" -"@eslint/eslintrc@^1.2.3": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" - integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw== +"@eslint/eslintrc@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.3.2" + espree "^9.4.0" globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.2.1" @@ -2317,15 +2317,25 @@ resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d" integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg== -"@humanwhocodes/config-array@^0.9.2": - version "0.9.5" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" - integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw== +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" @@ -3484,14 +3494,14 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.25.0.tgz#e8ce050990e4d36cc200f2de71ca0d3eb5e77a31" - integrity sha512-icYrFnUzvm+LhW0QeJNKkezBu6tJs9p/53dpPLFH8zoM9w1tfaKzVurkPotEpAqQ8Vf8uaFyL5jHd0Vs6Z0ZQg== +"@typescript-eslint/eslint-plugin@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.0.tgz#8f159c4cdb3084eb5d4b72619a2ded942aa109e5" + integrity sha512-X3In41twSDnYRES7hO2xna4ZC02SY05UN9sGW//eL1P5k4CKfvddsdC2hOq0O3+WU1wkCPQkiTY9mzSnXKkA0w== dependencies: - "@typescript-eslint/scope-manager" "5.25.0" - "@typescript-eslint/type-utils" "5.25.0" - "@typescript-eslint/utils" "5.25.0" + "@typescript-eslint/scope-manager" "5.36.0" + "@typescript-eslint/type-utils" "5.36.0" + "@typescript-eslint/utils" "5.36.0" debug "^4.3.4" functional-red-black-tree "^1.0.1" ignore "^5.2.0" @@ -3499,7 +3509,17 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@5.25.0", "@typescript-eslint/parser@^5.21.0": +"@typescript-eslint/parser@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.0.tgz#c08883073fb65acaafd268a987fd2314ce80c789" + integrity sha512-dlBZj7EGB44XML8KTng4QM0tvjI8swDh8MdpE5NX5iHWgWEfIuqSfSE+GPeCrCdj7m4tQLuevytd57jNDXJ2ZA== + dependencies: + "@typescript-eslint/scope-manager" "5.36.0" + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/typescript-estree" "5.36.0" + debug "^4.3.4" + +"@typescript-eslint/parser@^5.21.0": version "5.25.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.25.0.tgz#fb533487147b4b9efd999a4d2da0b6c263b64f7f" integrity sha512-r3hwrOWYbNKP1nTcIw/aZoH+8bBnh/Lh1iDHoFpyG4DnCpvEdctrSl6LOo19fZbzypjQMHdajolxs6VpYoChgA== @@ -3517,12 +3537,21 @@ "@typescript-eslint/types" "5.25.0" "@typescript-eslint/visitor-keys" "5.25.0" -"@typescript-eslint/type-utils@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.25.0.tgz#5750d26a5db4c4d68d511611e0ada04e56f613bc" - integrity sha512-B6nb3GK3Gv1Rsb2pqalebe/RyQoyG/WDy9yhj8EE0Ikds4Xa8RR28nHz+wlt4tMZk5bnAr0f3oC8TuDAd5CPrw== +"@typescript-eslint/scope-manager@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.0.tgz#f4f859913add160318c0a5daccd3a030d1311530" + integrity sha512-PZUC9sz0uCzRiuzbkh6BTec7FqgwXW03isumFVkuPw/Ug/6nbAqPUZaRy4w99WCOUuJTjhn3tMjsM94NtEj64g== dependencies: - "@typescript-eslint/utils" "5.25.0" + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/visitor-keys" "5.36.0" + +"@typescript-eslint/type-utils@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.0.tgz#5d2f94a36a298ae240ceca54b3bc230be9a99f0a" + integrity sha512-W/E3yJFqRYsjPljJ2gy0YkoqLJyViWs2DC6xHkXcWyhkIbCDdaVnl7mPLeQphVI+dXtY05EcXFzWLXhq8Mm/lQ== + dependencies: + "@typescript-eslint/typescript-estree" "5.36.0" + "@typescript-eslint/utils" "5.36.0" debug "^4.3.4" tsutils "^3.21.0" @@ -3531,6 +3560,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.25.0.tgz#dee51b1855788b24a2eceeae54e4adb89b088dd8" integrity sha512-7fWqfxr0KNHj75PFqlGX24gWjdV/FDBABXL5dyvBOWHpACGyveok8Uj4ipPX/1fGU63fBkzSIycEje4XsOxUFA== +"@typescript-eslint/types@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.0.tgz#cde7b94d1c09a4f074f46db99e7bd929fb0a5559" + integrity sha512-3JJuLL1r3ljRpFdRPeOtgi14Vmpx+2JcR6gryeORmW3gPBY7R1jNYoq4yBN1L//ONZjMlbJ7SCIwugOStucYiQ== + "@typescript-eslint/typescript-estree@5.25.0": version "5.25.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.25.0.tgz#a7ab40d32eb944e3fb5b4e3646e81b1bcdd63e00" @@ -3544,15 +3578,28 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.25.0.tgz#272751fd737733294b4ab95e16c7f2d4a75c2049" - integrity sha512-qNC9bhnz/n9Kba3yI6HQgQdBLuxDoMgdjzdhSInZh6NaDnFpTUlwNGxplUFWfY260Ya0TRPvkg9dd57qxrJI9g== +"@typescript-eslint/typescript-estree@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.0.tgz#0acce61b4850bdb0e578f0884402726680608789" + integrity sha512-EW9wxi76delg/FS9+WV+fkPdwygYzRrzEucdqFVWXMQWPOjFy39mmNNEmxuO2jZHXzSQTXzhxiU1oH60AbIw9A== + dependencies: + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/visitor-keys" "5.36.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.0.tgz#104c864ecc1448417606359275368bf3872bbabb" + integrity sha512-wAlNhXXYvAAUBbRmoJDywF/j2fhGLBP4gnreFvYvFbtlsmhMJ4qCKVh/Z8OP4SgGR3xbciX2nmG639JX0uw1OQ== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.25.0" - "@typescript-eslint/types" "5.25.0" - "@typescript-eslint/typescript-estree" "5.25.0" + "@typescript-eslint/scope-manager" "5.36.0" + "@typescript-eslint/types" "5.36.0" + "@typescript-eslint/typescript-estree" "5.36.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" @@ -3564,6 +3611,14 @@ "@typescript-eslint/types" "5.25.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@5.36.0": + version "5.36.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.0.tgz#565d35a5ca00d00a406a942397ead2cb190663ba" + integrity sha512-pdqSJwGKueOrpjYIex0T39xarDt1dn4p7XJ+6FqBWugNQwXlNGC5h62qayAIYZ/RPPtD+ButDWmpXT1eGtiaYg== + dependencies: + "@typescript-eslint/types" "5.36.0" + eslint-visitor-keys "^3.3.0" + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -3749,11 +3804,16 @@ acorn@^7.0.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.1: +acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: version "8.7.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== +acorn@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== + address@^1.0.1, address@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/address/-/address-1.2.0.tgz#d352a62c92fee90f89a693eccd2a8b2139ab02d9" @@ -5910,13 +5970,15 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.15.0: - version "8.15.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.15.0.tgz#fea1d55a7062da48d82600d2e0974c55612a11e9" - integrity sha512-GG5USZ1jhCu8HJkzGgeK8/+RGnHaNYZGrGDzUtigK3BsGESW/rs2az23XqE0WVwDxy1VRvvjSSGu5nB0Bu+6SA== +eslint@8.23.0: + version "8.23.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" + integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== dependencies: - "@eslint/eslintrc" "^1.2.3" - "@humanwhocodes/config-array" "^0.9.2" + "@eslint/eslintrc" "^1.3.1" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -5926,14 +5988,17 @@ eslint@8.15.0: eslint-scope "^7.1.1" eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.3.2" + espree "^9.4.0" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" + find-up "^5.0.0" functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" - globals "^13.6.0" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" @@ -5949,14 +6014,13 @@ eslint@8.15.0: strip-ansi "^6.0.1" strip-json-comments "^3.1.0" text-table "^0.2.0" - v8-compile-cache "^2.0.3" -espree@^9.3.2: - version "9.3.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596" - integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA== +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== dependencies: - acorn "^8.7.1" + acorn "^8.8.0" acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" @@ -6653,7 +6717,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.15.0, globals@^13.6.0: +globals@^13.15.0: version "13.15.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac" integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog== @@ -6752,6 +6816,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== + gray-matter@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798" @@ -9455,10 +9524,10 @@ prettier-plugin-tailwindcss@^0.1.5: resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.1.11.tgz#6112da68d9d022b7f896d35c070464931c99c35f" integrity sha512-a28+1jvpIZQdZ/W97wOXb6VqI762MKE/TxMMuibMEHhyYsSxQA8Ek30KObd5kJI2HF1ldtSYprFayXJXi3pz8Q== -prettier@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.0.tgz#a6370e2d4594e093270419d9cc47f7670488f893" - integrity sha512-FM/zAKgWTxj40rH03VxzIPdXmj39SwSjwG0heUcNFwI+EMZJnY93yAiKXM3dObIKAM5TA88werc8T/EwhB45eg== +prettier@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" + integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== pretty-bytes@^5.3.0: version "5.6.0" @@ -11434,10 +11503,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@4.6.4: - version "4.6.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" - integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== +typescript@4.8.2: + version "4.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" + integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== ua-parser-js@^0.7.30: version "0.7.31" @@ -11727,11 +11796,6 @@ v8-compile-cache-lib@^3.0.1: resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== -v8-compile-cache@^2.0.3: - version "2.3.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" - integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== - validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" From 7debc4925e4d22d98ae457bf8a7910d3da5aae62 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 30 Aug 2022 02:41:47 -0700 Subject: [PATCH 151/279] De-feedify contract tab contents (#808) * De-feedify contract bets list * De-feedify contract comments lists * Clean up a bunch of duplicated work in the comments list stuff * Remove wrapper markup from comment replies list * Fix sort order on comments I broke * Kill now unhelpful `CommentRepliesList` wrapper component * More random cleanup * More cleanup and fix some styling I had broken * Make bet calculations less wrong * Keep up to date with master * Make copy link component copy better URL * Make highlighted comments align properly * Make user header left align with content on comments * Fix some free response UI stuff up --- web/components/answers/answers-panel.tsx | 3 - web/components/bets-list.tsx | 8 +- .../contract/contract-leaderboard.tsx | 8 +- web/components/contract/contract-tabs.tsx | 140 +++++--- web/components/editor.tsx | 18 +- web/components/feed/activity-items.ts | 237 ------------- web/components/feed/contract-activity.tsx | 169 ++++++--- web/components/feed/copy-link-date-time.tsx | 8 +- .../feed/feed-answer-comment-group.tsx | 62 ++-- web/components/feed/feed-bets.tsx | 26 +- web/components/feed/feed-comments.tsx | 335 ++++++++---------- web/components/feed/feed-items.tsx | 279 --------------- web/components/feed/feed-liquidity.tsx | 21 +- web/components/linkify.tsx | 11 +- 14 files changed, 426 insertions(+), 899 deletions(-) delete mode 100644 web/components/feed/activity-items.ts delete mode 100644 web/components/feed/feed-items.tsx diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 6e0bfef6..3de99c34 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item' import { CreateAnswerPanel } from './create-answer-panel' import { AnswerResolvePanel } from './answer-resolve-panel' import { Spacer } from '../layout/spacer' -import { ActivityItem } from '../feed/activity-items' import { User } from 'common/user' import { getOutcomeProbability } from 'common/calculate' import { Answer } from 'common/answer' @@ -176,7 +175,6 @@ function getAnswerItems( type: 'answer' as const, contract, answer, - items: [] as ActivityItem[], user, } }) @@ -186,7 +184,6 @@ function getAnswerItems( function OpenAnswer(props: { contract: FreeResponseContract | MultipleChoiceContract answer: Answer - items: ActivityItem[] type: string }) { const { answer, contract } = props diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 3270408b..3217da3d 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -394,13 +394,11 @@ export function BetsSummary(props: { const { hasShares, invested, profitPercent, payout, profit, totalShares } = getContractBetMetrics(contract, bets) - const excludeSalesAndAntes = bets.filter( - (b) => !b.isAnte && !b.isSold && !b.sale - ) - const yesWinnings = sumBy(excludeSalesAndAntes, (bet) => + const excludeSales = bets.filter((b) => !b.isSold && !b.sale) + const yesWinnings = sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'YES') ) - const noWinnings = sumBy(excludeSalesAndAntes, (bet) => + const noWinnings = sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'NO') ) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 77af001e..cc253433 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -107,7 +107,6 @@ export function ContractTopTrades(props: { comment={commentsById[topCommentId]} tips={tips[topCommentId]} betsBySameUser={[betsById[topCommentId]]} - smallAvatar={false} /> </div> <div className="mt-2 text-sm text-gray-500"> @@ -123,12 +122,7 @@ export function ContractTopTrades(props: { <> <Title text="💸 Smartest money" 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]} - hideOutcome={false} - smallAvatar={false} - /> + <FeedBet contract={contract} bet={betsById[topBetId]} /> </div> <div className="mt-2 text-sm text-gray-500"> {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}! diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 9e9f62bf..417de12b 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -1,15 +1,24 @@ import { Bet } from 'common/bet' -import { Contract } from 'common/contract' +import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractComment } from 'common/comment' import { User } from 'common/user' -import { ContractActivity } from '../feed/contract-activity' +import { + ContractCommentsActivity, + ContractBetsActivity, + FreeResponseContractCommentsActivity, +} from '../feed/contract-activity' import { ContractBetsTable, BetsSummary } from '../bets-list' import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' import { Col } from '../layout/col' +import { tradingAllowed } from 'web/lib/firebase/contracts' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { useBets } from 'web/hooks/use-bets' import { useComments } from 'web/hooks/use-comments' import { useLiquidity } from 'web/hooks/use-liquidity' +import { BetSignUpPrompt } from '../sign-up-prompt' +import { PlayMoneyDisclaimer } from '../play-money-disclaimer' +import BetButton from '../bet-button' export function ContractTabs(props: { contract: Contract @@ -18,68 +27,69 @@ export function ContractTabs(props: { comments: ContractComment[] tips: CommentTipMap }) { - const { contract, user, bets, tips } = props + const { contract, user, tips } = props const { outcomeType } = contract - const userBets = user && bets.filter((bet) => bet.userId === user.id) + const bets = useBets(contract.id) ?? props.bets + const lps = useLiquidity(contract.id) ?? [] + + const userBets = + user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) const visibleBets = bets.filter( (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 ) - - const liquidityProvisions = - useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? [] + const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0) // Load comments here, so the badge count will be correct const updatedComments = useComments(contract.id) const comments = updatedComments ?? props.comments const betActivity = ( - <ContractActivity + <ContractBetsActivity contract={contract} - bets={bets} - liquidityProvisions={liquidityProvisions} - comments={comments} - tips={tips} - user={user} - mode="bets" - betRowClassName="!mt-0 xl:hidden" + bets={visibleBets} + lps={visibleLps} /> ) - const commentActivity = ( - <> - <ContractActivity - contract={contract} - bets={bets} - liquidityProvisions={liquidityProvisions} - comments={comments} - tips={tips} - user={user} - mode={ - contract.outcomeType === 'FREE_RESPONSE' - ? 'free-response-comment-answer-groups' - : 'comments' - } - betRowClassName="!mt-0 xl:hidden" - /> - {outcomeType === 'FREE_RESPONSE' && ( + const generalBets = outcomeType === 'FREE_RESPONSE' ? [] : visibleBets + const generalComments = comments.filter( + (comment) => + comment.answerOutcome === undefined && + (outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true) + ) + + const commentActivity = + outcomeType === 'FREE_RESPONSE' ? ( + <> + <FreeResponseContractCommentsActivity + contract={contract} + bets={visibleBets} + comments={comments} + tips={tips} + user={user} + /> <Col className={'mt-8 flex w-full '}> <div className={'text-md mt-8 mb-2 text-left'}>General Comments</div> <div className={'mb-4 w-full border-b border-gray-200'} /> - <ContractActivity + <ContractCommentsActivity contract={contract} - bets={bets} - liquidityProvisions={liquidityProvisions} - comments={comments} + bets={generalBets} + comments={generalComments} tips={tips} user={user} - mode={'comments'} - betRowClassName="!mt-0 xl:hidden" /> </Col> - )} - </> - ) + </> + ) : ( + <ContractCommentsActivity + contract={contract} + bets={visibleBets} + comments={comments} + tips={tips} + user={user} + /> + ) const yourTrades = ( <div> @@ -96,19 +106,39 @@ export function ContractTabs(props: { ) return ( - <Tabs - currentPageForAnalytics={'contract'} - tabs={[ - { - title: 'Comments', - content: commentActivity, - badge: `${comments.length}`, - }, - { title: 'Bets', content: betActivity, badge: `${visibleBets.length}` }, - ...(!user || !userBets?.length - ? [] - : [{ title: 'Your bets', content: yourTrades }]), - ]} - /> + <> + <Tabs + currentPageForAnalytics={'contract'} + tabs={[ + { + title: 'Comments', + content: commentActivity, + badge: `${comments.length}`, + }, + { + title: 'Bets', + content: betActivity, + badge: `${visibleBets.length}`, + }, + ...(!user || !userBets?.length + ? [] + : [{ title: 'Your bets', content: yourTrades }]), + ]} + /> + {!user ? ( + <Col className="mt-4 max-w-sm items-center xl:hidden"> + <BetSignUpPrompt /> + <PlayMoneyDisclaimer /> + </Col> + ) : ( + outcomeType === 'BINARY' && + tradingAllowed(contract) && ( + <BetButton + contract={contract as CPMMBinaryContract} + className="mb-2 !mt-0 xl:hidden" + /> + ) + )} + </> ) } diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 6af58caa..5f056f8b 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -236,9 +236,10 @@ const useUploadMutation = (editor: Editor | null) => export function RichContent(props: { content: JSONContent | string + className?: string smallImage?: boolean }) { - const { content, smallImage } = props + const { className, content, smallImage } = props const editor = useEditor({ editorProps: { attributes: { class: proseClass } }, extensions: [ @@ -254,19 +255,24 @@ export function RichContent(props: { }) useEffect(() => void editor?.commands?.setContent(content), [editor, content]) - return <EditorContent editor={editor} /> + return <EditorContent className={className} editor={editor} /> } // backwards compatibility: we used to store content as strings export function Content(props: { content: JSONContent | string + className?: string smallImage?: boolean }) { - const { content } = props + const { className, content } = props return typeof content === 'string' ? ( - <div className="whitespace-pre-line font-light leading-relaxed"> - <Linkify text={content} /> - </div> + <Linkify + className={clsx( + className, + 'whitespace-pre-line font-light leading-relaxed' + )} + text={content} + /> ) : ( <RichContent {...props} /> ) diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts deleted file mode 100644 index bcbb6721..00000000 --- a/web/components/feed/activity-items.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { uniq, sortBy } from 'lodash' - -import { Answer } from 'common/answer' -import { Bet } from 'common/bet' -import { getOutcomeProbability } from 'common/calculate' -import { ContractComment } from 'common/comment' -import { Contract, FreeResponseContract } from 'common/contract' -import { User } from 'common/user' -import { CommentTipMap } from 'web/hooks/use-tip-txns' -import { LiquidityProvision } from 'common/liquidity-provision' - -export type ActivityItem = - | DescriptionItem - | QuestionItem - | BetItem - | AnswerGroupItem - | CloseItem - | ResolveItem - | CommentInputItem - | CommentThreadItem - | LiquidityItem - -type BaseActivityItem = { - id: string - contract: Contract -} - -export type CommentInputItem = BaseActivityItem & { - type: 'commentInput' - betsByCurrentUser: Bet[] - commentsByCurrentUser: ContractComment[] -} - -export type DescriptionItem = BaseActivityItem & { - type: 'description' -} - -export type QuestionItem = BaseActivityItem & { - type: 'question' - contractPath?: string -} - -export type BetItem = BaseActivityItem & { - type: 'bet' - bet: Bet - hideOutcome: boolean - smallAvatar: boolean - hideComment?: boolean -} - -export type CommentThreadItem = BaseActivityItem & { - type: 'commentThread' - parentComment: ContractComment - comments: ContractComment[] - tips: CommentTipMap - bets: Bet[] -} - -export type AnswerGroupItem = BaseActivityItem & { - type: 'answergroup' - user: User | undefined | null - answer: Answer - comments: ContractComment[] - tips: CommentTipMap - bets: Bet[] -} - -export type CloseItem = BaseActivityItem & { - type: 'close' -} - -export type ResolveItem = BaseActivityItem & { - type: 'resolve' -} - -export type LiquidityItem = BaseActivityItem & { - type: 'liquidity' - liquidity: LiquidityProvision - hideOutcome: boolean - smallAvatar: boolean - hideComment?: boolean -} - -function getAnswerAndCommentInputGroups( - contract: FreeResponseContract, - bets: Bet[], - comments: ContractComment[], - tips: CommentTipMap, - user: User | undefined | null -) { - let outcomes = uniq(bets.map((bet) => bet.outcome)) - outcomes = sortBy(outcomes, (outcome) => - getOutcomeProbability(contract, outcome) - ) - - const answerGroups = outcomes - .map((outcome) => { - const answer = contract.answers?.find( - (answer) => answer.id === outcome - ) as Answer - - return { - id: outcome, - type: 'answergroup' as const, - contract, - user, - answer, - comments, - tips, - bets, - } - }) - .filter((group) => group.answer) as ActivityItem[] - return answerGroups -} - -function getCommentThreads( - bets: Bet[], - comments: ContractComment[], - tips: CommentTipMap, - contract: Contract -) { - const parentComments = comments.filter((comment) => !comment.replyToCommentId) - - const items = parentComments.map((comment) => ({ - type: 'commentThread' as const, - id: comment.id, - contract: contract, - comments: comments, - parentComment: comment, - bets: bets, - tips, - })) - - return items -} - -function commentIsGeneralComment(comment: ContractComment, contract: Contract) { - return ( - comment.answerOutcome === undefined && - (contract.outcomeType === 'FREE_RESPONSE' - ? comment.betId === undefined - : true) - ) -} - -export function getSpecificContractActivityItems( - contract: Contract, - bets: Bet[], - comments: ContractComment[], - liquidityProvisions: LiquidityProvision[], - tips: CommentTipMap, - user: User | null | undefined, - options: { - mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' - } -) { - const { mode } = options - let items = [] as ActivityItem[] - - switch (mode) { - case 'bets': - // Remove first bet (which is the ante): - if (contract.outcomeType === 'FREE_RESPONSE') bets = bets.slice(1) - items.push( - ...bets.map((bet) => ({ - type: 'bet' as const, - id: bet.id + '-' + bet.isSold, - bet, - contract, - hideOutcome: false, - smallAvatar: false, - hideComment: true, - })) - ) - items.push( - ...liquidityProvisions.map((liquidity) => ({ - type: 'liquidity' as const, - id: liquidity.id, - contract, - liquidity, - hideOutcome: false, - smallAvatar: false, - })) - ) - items = sortBy(items, (item) => - item.type === 'bet' - ? item.bet.createdTime - : item.type === 'liquidity' - ? item.liquidity.createdTime - : undefined - ) - break - - case 'comments': { - const nonFreeResponseComments = comments.filter((comment) => - commentIsGeneralComment(comment, contract) - ) - const nonFreeResponseBets = - contract.outcomeType === 'FREE_RESPONSE' ? [] : bets - items.push( - ...getCommentThreads( - nonFreeResponseBets, - nonFreeResponseComments, - tips, - contract - ) - ) - - items.push({ - type: 'commentInput', - id: 'commentInput', - contract, - betsByCurrentUser: nonFreeResponseBets.filter( - (bet) => bet.userId === user?.id - ), - commentsByCurrentUser: nonFreeResponseComments.filter( - (comment) => comment.userId === user?.id - ), - }) - break - } - case 'free-response-comment-answer-groups': - items.push( - ...getAnswerAndCommentInputGroups( - contract as FreeResponseContract, - bets, - comments, - tips, - user - ) - ) - break - } - - return items.reverse() -} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 3cc0acb0..744f06aa 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -1,55 +1,144 @@ -import { Contract } from 'web/lib/firebase/contracts' +import { Contract, FreeResponseContract } from 'common/contract' import { ContractComment } from 'common/comment' +import { Answer } from 'common/answer' import { Bet } from 'common/bet' -import { useBets } from 'web/hooks/use-bets' -import { getSpecificContractActivityItems } from './activity-items' -import { FeedItems } from './feed-items' +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 { User } from 'common/user' -import { useContractWithPreload } from 'web/hooks/use-contract' import { CommentTipMap } from 'web/hooks/use-tip-txns' import { LiquidityProvision } from 'common/liquidity-provision' +import { groupBy, sortBy, uniq } from 'lodash' +import { Col } from 'web/components/layout/col' -export function ContractActivity(props: { +export function ContractBetsActivity(props: { contract: Contract bets: Bet[] - comments: ContractComment[] - liquidityProvisions: LiquidityProvision[] - tips: CommentTipMap - user: User | null | undefined - mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' - contractPath?: string - className?: string - betRowClassName?: string + lps: LiquidityProvision[] }) { - const { user, mode, tips, className, betRowClassName, liquidityProvisions } = - props + const { contract, bets, lps } = props - const contract = useContractWithPreload(props.contract) ?? props.contract - const comments = props.comments - const updatedBets = useBets(contract.id, { - filterChallenges: false, - filterRedemptions: true, - }) - const bets = (updatedBets ?? props.bets).filter( - (bet) => !bet.isRedemption && bet.amount !== 0 - ) - const items = getSpecificContractActivityItems( - contract, - bets, - comments, - liquidityProvisions, - tips, - user, - { mode } + const items = [ + ...bets.map((bet) => ({ + type: 'bet' as const, + id: bet.id + '-' + bet.isSold, + bet, + })), + ...lps.map((lp) => ({ + type: 'liquidity' as const, + id: lp.id, + lp, + })), + ] + + const sortedItems = sortBy(items, (item) => + item.type === 'bet' + ? -item.bet.createdTime + : item.type === 'liquidity' + ? -item.lp.createdTime + : undefined ) return ( - <FeedItems - contract={contract} - items={items} - className={className} - betRowClassName={betRowClassName} - user={user} - /> + <Col className="gap-4"> + {sortedItems.map((item) => + item.type === 'bet' ? ( + <FeedBet key={item.id} contract={contract} bet={item.bet} /> + ) : ( + <FeedLiquidity key={item.id} liquidity={item.lp} /> + ) + )} + </Col> + ) +} + +export function ContractCommentsActivity(props: { + contract: Contract + bets: Bet[] + comments: ContractComment[] + tips: CommentTipMap + user: User | null | undefined +}) { + const { bets, contract, comments, user, tips } = props + const betsByUserId = groupBy(bets, (bet) => bet.userId) + const commentsByUserId = groupBy(comments, (c) => c.userId) + const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') + const topLevelComments = sortBy( + commentsByParentId['_'] ?? [], + (c) => -c.createdTime + ) + + return ( + <> + <CommentInput + className="mb-5" + contract={contract} + betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} + commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} + /> + {topLevelComments.map((parent) => ( + <FeedCommentThread + key={parent.id} + user={user} + contract={contract} + parentComment={parent} + threadComments={commentsByParentId[parent.id] ?? []} + tips={tips} + bets={bets} + betsByUserId={betsByUserId} + commentsByUserId={commentsByUserId} + /> + ))} + </> + ) +} + +export function FreeResponseContractCommentsActivity(props: { + contract: FreeResponseContract + bets: Bet[] + comments: ContractComment[] + tips: CommentTipMap + user: User | null | undefined +}) { + const { bets, contract, comments, user, tips } = props + + let outcomes = uniq(bets.map((bet) => bet.outcome)) + outcomes = sortBy( + outcomes, + (outcome) => -getOutcomeProbability(contract, outcome) + ) + + const answers = outcomes + .map((outcome) => { + return contract.answers.find((answer) => answer.id === outcome) as Answer + }) + .filter((answer) => answer != null) + + const betsByUserId = groupBy(bets, (bet) => bet.userId) + const commentsByUserId = groupBy(comments, (c) => c.userId) + const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_') + + return ( + <> + {answers.map((answer) => ( + <div key={answer.id} className={'relative pb-4'}> + <span + className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" + aria-hidden="true" + /> + <FeedAnswerCommentGroup + contract={contract} + user={user} + answer={answer} + answerComments={commentsByOutcome[answer.number.toString()] ?? []} + tips={tips} + betsByUserId={betsByUserId} + commentsByUserId={commentsByUserId} + /> + </div> + ))} + </> ) } diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index c4e69655..d4401b8c 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react' -import { ENV_CONFIG } from 'common/envs/constants' import { copyToClipboard } from 'web/lib/util/copy' import { DateTimeTooltip } from 'web/components/datetime-tooltip' import Link from 'next/link' @@ -21,9 +20,10 @@ export function CopyLinkDateTimeComponent(props: { event: React.MouseEvent<HTMLAnchorElement, MouseEvent> ) { event.preventDefault() - const elementLocation = `https://${ENV_CONFIG.domain}/${prefix}/${slug}#${elementId}` - - copyToClipboard(elementLocation) + const commentUrl = new URL(window.location.href) + commentUrl.pathname = `/${prefix}/${slug}` + commentUrl.hash = elementId + copyToClipboard(commentUrl.toString()) setShowToast(true) setTimeout(() => setShowToast(false), 2000) } diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 86686f1f..2df8cb4a 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -1,5 +1,6 @@ import { Answer } from 'common/answer' import { Bet } from 'common/bet' +import { FreeResponseContract } from 'common/contract' import { ContractComment } from 'common/comment' import React, { useEffect, useState } from 'react' import { Col } from 'web/components/layout/col' @@ -10,25 +11,34 @@ import { Linkify } from 'web/components/linkify' import clsx from 'clsx' import { CommentInput, - CommentRepliesList, + FeedComment, getMostRecentCommentableBet, } from 'web/components/feed/feed-comments' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { useRouter } from 'next/router' -import { groupBy } from 'lodash' +import { Dictionary } from 'lodash' import { User } from 'common/user' import { useEvent } from 'web/hooks/use-event' import { CommentTipMap } from 'web/hooks/use-tip-txns' export function FeedAnswerCommentGroup(props: { - contract: any + contract: FreeResponseContract user: User | undefined | null answer: Answer - comments: ContractComment[] + answerComments: ContractComment[] tips: CommentTipMap - bets: Bet[] + betsByUserId: Dictionary<Bet[]> + commentsByUserId: Dictionary<ContractComment[]> }) { - const { answer, contract, comments, tips, bets, user } = props + const { + answer, + contract, + answerComments, + tips, + betsByUserId, + commentsByUserId, + user, + } = props const { username, avatarUrl, name, text } = answer const [replyToUser, setReplyToUser] = @@ -38,11 +48,6 @@ export function FeedAnswerCommentGroup(props: { const router = useRouter() const answerElementId = `answer-${answer.id}` - const betsByUserId = groupBy(bets, (bet) => bet.userId) - const commentsByUserId = groupBy(comments, (comment) => comment.userId) - const commentsList = comments.filter( - (comment) => comment.answerOutcome === answer.number.toString() - ) const betsByCurrentUser = (user && betsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const isFreeResponseContractPage = !!commentsByCurrentUser @@ -101,10 +106,13 @@ export function FeedAnswerCommentGroup(props: { }, [answerElementId, router.asPath]) return ( - <Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}> + <Col + className={'relative flex-1 items-stretch gap-3'} + key={answer.id + 'comment'} + > <Row className={clsx( - 'flex gap-3 space-x-3 pt-4 transition-all duration-1000', + 'gap-3 space-x-3 pt-4 transition-all duration-1000', highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : '' )} id={answerElementId} @@ -150,21 +158,23 @@ export function FeedAnswerCommentGroup(props: { )} </Col> </Row> - <CommentRepliesList - contract={contract} - commentsList={commentsList} - betsByUserId={betsByUserId} - smallAvatar={true} - bets={bets} - tips={tips} - scrollAndOpenReplyInput={scrollAndOpenReplyInput} - treatFirstIndexEqually={true} - /> - + <Col className="gap-3 pl-1"> + {answerComments.map((comment) => ( + <FeedComment + key={comment.id} + indent={true} + contract={contract} + comment={comment} + tips={tips[comment.id]} + betsBySameUser={betsByUserId[comment.userId] ?? []} + onReplyClick={scrollAndOpenReplyInput} + /> + ))} + </Col> {showReply && ( - <div className={'ml-6'}> + <div className={'relative ml-7'}> <span - className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" + className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> <CommentInput diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index e4200593..b7aeb321 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -16,13 +16,8 @@ import { SiteLink } from 'web/components/site-link' import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' import { Challenge } from 'common/challenge' -export function FeedBet(props: { - contract: Contract - bet: Bet - hideOutcome: boolean - smallAvatar: boolean -}) { - const { contract, bet, hideOutcome, smallAvatar } = props +export function FeedBet(props: { contract: Contract; bet: Bet }) { + const { contract, bet } = props const { userId, createdTime } = bet const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') @@ -33,21 +28,11 @@ export function FeedBet(props: { const isSelf = user?.id === userId return ( - <Row className={'flex w-full items-center gap-2 pt-3'}> + <Row className="items-center gap-2 pt-3"> {isSelf ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={user.avatarUrl} - username={user.username} - /> + <Avatar avatarUrl={user.avatarUrl} username={user.username} /> ) : bettor ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={bettor.avatarUrl} - username={bettor.username} - /> + <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> ) : ( <EmptyAvatar className="mx-1" /> )} @@ -56,7 +41,6 @@ export function FeedBet(props: { contract={contract} isSelf={isSelf} bettor={bettor} - hideOutcome={hideOutcome} className="flex-1" /> </Row> diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index d74be119..9e6c3cd5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -3,7 +3,7 @@ import { ContractComment } from 'common/comment' import { User } from 'common/user' import { Contract } from 'common/contract' import React, { useEffect, useState } from 'react' -import { minBy, maxBy, groupBy, partition, sumBy, Dictionary } from 'lodash' +import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { useRouter } from 'next/router' @@ -31,62 +31,72 @@ import { Content, TextEditor, useTextEditor } from '../editor' import { Editor } from '@tiptap/react' export function FeedCommentThread(props: { + user: User | null | undefined contract: Contract - comments: ContractComment[] + threadComments: ContractComment[] tips: CommentTipMap parentComment: ContractComment bets: Bet[] - smallAvatar?: boolean + betsByUserId: Dictionary<Bet[]> + commentsByUserId: Dictionary<ContractComment[]> }) { - const { contract, comments, bets, tips, smallAvatar, parentComment } = props + const { + user, + contract, + threadComments, + commentsByUserId, + bets, + betsByUserId, + tips, + parentComment, + } = props const [showReply, setShowReply] = useState(false) - const [replyToUser, setReplyToUser] = useState<{ - id: string - username: string - }>() - const betsByUserId = groupBy(bets, (bet) => bet.userId) - const user = useUser() - const commentsList = comments.filter( - (comment) => - parentComment.id && comment.replyToCommentId === parentComment.id - ) - commentsList.unshift(parentComment) + const [replyTo, setReplyTo] = useState<{ id: string; username: string }>() function scrollAndOpenReplyInput(comment: ContractComment) { - setReplyToUser({ id: comment.userId, username: comment.userUsername }) + setReplyTo({ id: comment.userId, username: comment.userUsername }) setShowReply(true) } return ( - <Col className={'w-full gap-3 pr-1'}> + <Col className="relative w-full items-stretch gap-3 pb-4"> <span - className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" + className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" aria-hidden="true" /> - <CommentRepliesList - contract={contract} - commentsList={commentsList} - betsByUserId={betsByUserId} - tips={tips} - smallAvatar={smallAvatar} - bets={bets} - scrollAndOpenReplyInput={scrollAndOpenReplyInput} - /> + {[parentComment].concat(threadComments).map((comment, commentIdx) => ( + <FeedComment + key={comment.id} + indent={commentIdx != 0} + contract={contract} + comment={comment} + tips={tips[comment.id]} + betsBySameUser={betsByUserId[comment.userId] ?? []} + onReplyClick={scrollAndOpenReplyInput} + probAtCreatedTime={ + contract.outcomeType === 'BINARY' + ? minBy(bets, (bet) => { + return bet.createdTime < comment.createdTime + ? comment.createdTime - bet.createdTime + : comment.createdTime + })?.probAfter + : undefined + } + /> + ))} {showReply && ( - <Col className={'-pb-2 ml-6'}> + <Col className="-pb-2 relative ml-6"> <span - className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" + className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" aria-hidden="true" /> <CommentInput contract={contract} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} - commentsByCurrentUser={comments.filter( - (c) => c.userId === user?.id - )} + commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} parentCommentId={parentComment.id} - replyToUser={replyToUser} - parentAnswerOutcome={comments[0].answerOutcome} + replyToUser={replyTo} + parentAnswerOutcome={parentComment.answerOutcome} onSubmitComment={() => setShowReply(false)} /> </Col> @@ -95,74 +105,13 @@ export function FeedCommentThread(props: { ) } -export function CommentRepliesList(props: { - contract: Contract - commentsList: ContractComment[] - betsByUserId: Dictionary<Bet[]> - tips: CommentTipMap - scrollAndOpenReplyInput: (comment: ContractComment) => void - bets: Bet[] - treatFirstIndexEqually?: boolean - smallAvatar?: boolean -}) { - const { - contract, - commentsList, - betsByUserId, - tips, - smallAvatar, - bets, - scrollAndOpenReplyInput, - treatFirstIndexEqually, - } = props - return ( - <> - {commentsList.map((comment, commentIdx) => ( - <div - key={comment.id} - id={comment.id} - className={clsx( - 'relative', - !treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6' - )} - > - {/*draw a gray line from the comment to the left:*/} - {(treatFirstIndexEqually || commentIdx != 0) && ( - <span - className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" - aria-hidden="true" - /> - )} - <FeedComment - contract={contract} - comment={comment} - tips={tips[comment.id]} - betsBySameUser={betsByUserId[comment.userId] ?? []} - onReplyClick={scrollAndOpenReplyInput} - probAtCreatedTime={ - contract.outcomeType === 'BINARY' - ? minBy(bets, (bet) => { - return bet.createdTime < comment.createdTime - ? comment.createdTime - bet.createdTime - : comment.createdTime - })?.probAfter - : undefined - } - smallAvatar={smallAvatar} - /> - </div> - ))} - </> - ) -} - export function FeedComment(props: { contract: Contract comment: ContractComment tips: CommentTips betsBySameUser: Bet[] + indent?: boolean probAtCreatedTime?: number - smallAvatar?: boolean onReplyClick?: (comment: ContractComment) => void }) { const { @@ -170,6 +119,7 @@ export function FeedComment(props: { comment, tips, betsBySameUser, + indent, probAtCreatedTime, onReplyClick, } = props @@ -203,19 +153,23 @@ export function FeedComment(props: { return ( <Row + id={comment.id} className={clsx( - 'flex space-x-1.5 sm:space-x-3', - highlighted ? `-m-1 rounded bg-indigo-500/[0.2] p-2` : '' + 'relative', + indent ? 'ml-6' : '', + highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : '' )} > - <Avatar - className={'ml-1'} - size={'sm'} - username={userUsername} - avatarUrl={userAvatarUrl} - /> - <div className="min-w-0 flex-1"> - <div className="mt-0.5 pl-0.5 text-sm text-gray-500"> + {/*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} @@ -233,21 +187,19 @@ export function FeedComment(props: { /> </> )} - <> - {bought} {money} - {contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && ( - <> - {' '} - of{' '} - <OutcomeLabel - outcome={betOutcome ? betOutcome : ''} - value={(matchedBet as any).value} - contract={contract} - truncate="short" - /> - </> - )} - </> + {bought} {money} + {contract.outcomeType !== 'FREE_RESPONSE' && betOutcome && ( + <> + {' '} + of{' '} + <OutcomeLabel + outcome={betOutcome ? betOutcome : ''} + value={(matchedBet as any).value} + contract={contract} + truncate="short" + /> + </> + )} <CopyLinkDateTimeComponent prefix={contract.creatorUsername} slug={contract.slug} @@ -255,9 +207,11 @@ export function FeedComment(props: { elementId={comment.id} /> </div> - <div className="mt-2 text-[15px] text-gray-700"> - <Content content={content || text} smallImage /> - </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 && ( @@ -322,6 +276,7 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: ContractComment[] + className?: string replyToUser?: { id: string; username: string } // Reply to a free response answer parentAnswerOutcome?: string @@ -333,6 +288,7 @@ export function CommentInput(props: { contract, betsByCurrentUser, commentsByCurrentUser, + className, parentAnswerOutcome, parentCommentId, replyToUser, @@ -387,60 +343,51 @@ export function CommentInput(props: { if (user?.isBannedFromPosting) return <></> return ( - <> - <Row className={'mb-2 gap-1 sm:gap-2'}> - <div className={'mt-2'}> - <Avatar - avatarUrl={user?.avatarUrl} - username={user?.username} - size={'sm'} - className={'ml-1'} - /> - </div> - <div className={'min-w-0 flex-1'}> - <div className="pl-0.5 text-sm"> - <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} - prob={ - contract.outcomeType === 'BINARY' - ? getProbability(contract) - : undefined - } - /> - </> - )} - </div> - <CommentInputTextArea - editor={editor} - upload={upload} - replyToUser={replyToUser} - user={user} - submitComment={submitComment} - isSubmitting={isSubmitting} - presetId={id} + <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 + contract={contract} + bet={mostRecentCommentableBet} + isSelf={true} + hideOutcome={ + isNumeric || contract.outcomeType === 'FREE_RESPONSE' + } /> - </div> + )} + {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( + <> + {"You're"} + <CommentStatus + outcome={outcome} + contract={contract} + prob={ + contract.outcomeType === 'BINARY' + ? getProbability(contract) + : undefined + } + /> + </> + )} </div> - </Row> - </> + <CommentInputTextArea + editor={editor} + upload={upload} + replyToUser={replyToUser} + user={user} + submitComment={submitComment} + isSubmitting={isSubmitting} + presetId={id} + /> + </div> + </Row> ) } @@ -516,23 +463,21 @@ export function CommentInputTextArea(props: { return ( <> - <div> - <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> - )} + <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> - </div> + {isSubmitting && ( + <LoadingIndicator spinnerClassName={'border-gray-500'} /> + )} + </TextEditor> <Row> {!user && ( <button @@ -557,10 +502,6 @@ function getBettorsLargestPositionBeforeTime( noShares = 0, noFloorShares = 0 - const emptyReturn = { - userPosition: 0, - outcome: '', - } const previousBets = bets.filter( (prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte ) @@ -584,7 +525,7 @@ function getBettorsLargestPositionBeforeTime( } } if (bets.length === 0) { - return emptyReturn + return { userPosition: 0, outcome: '' } } const [yesBets, noBets] = partition( diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx deleted file mode 100644 index 4a121120..00000000 --- a/web/components/feed/feed-items.tsx +++ /dev/null @@ -1,279 +0,0 @@ -// From https://tailwindui.com/components/application-ui/lists/feeds -import React from 'react' -import { - BanIcon, - CheckIcon, - LockClosedIcon, - XIcon, -} from '@heroicons/react/solid' -import clsx from 'clsx' - -import { OutcomeLabel } from '../outcome-label' -import { - Contract, - contractPath, - tradingAllowed, -} from 'web/lib/firebase/contracts' -import { BinaryResolutionOrChance } from '../contract/contract-card' -import { SiteLink } from '../site-link' -import { Col } from '../layout/col' -import { UserLink } from '../user-page' -import BetButton from '../bet-button' -import { Avatar } from '../avatar' -import { ActivityItem } from './activity-items' -import { useUser } from 'web/hooks/use-user' -import { trackClick } from 'web/lib/firebase/tracking' -import { DAY_MS } from 'common/util/time' -import NewContractBadge from '../new-contract-badge' -import { RelativeTimestamp } from '../relative-timestamp' -import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group' -import { - FeedCommentThread, - CommentInput, -} from 'web/components/feed/feed-comments' -import { FeedBet } from 'web/components/feed/feed-bets' -import { CPMMBinaryContract, NumericContract } from 'common/contract' -import { FeedLiquidity } from './feed-liquidity' -import { BetSignUpPrompt } from '../sign-up-prompt' -import { User } from 'common/user' -import { PlayMoneyDisclaimer } from '../play-money-disclaimer' -import { contractMetrics } from 'common/contract-details' - -export function FeedItems(props: { - contract: Contract - items: ActivityItem[] - className?: string - betRowClassName?: string - user: User | null | undefined -}) { - const { contract, items, className, betRowClassName, user } = props - const { outcomeType } = contract - - return ( - <div className={clsx('flow-root', className)}> - <div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}> - {items.map((item, activityItemIdx) => ( - <div key={item.id} className={'relative pb-4'}> - {activityItemIdx !== items.length - 1 || - item.type === 'answergroup' ? ( - <span - className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" - aria-hidden="true" - /> - ) : null} - <div className="relative flex-col items-start space-x-3"> - <FeedItem item={item} /> - </div> - </div> - ))} - </div> - - {!user ? ( - <Col className="mt-4 max-w-sm items-center xl:hidden"> - <BetSignUpPrompt /> - <PlayMoneyDisclaimer /> - </Col> - ) : ( - outcomeType === 'BINARY' && - tradingAllowed(contract) && ( - <BetButton - contract={contract as CPMMBinaryContract} - className={clsx('mb-2', betRowClassName)} - /> - ) - )} - </div> - ) -} - -export function FeedItem(props: { item: ActivityItem }) { - const { item } = props - - switch (item.type) { - case 'question': - return <FeedQuestion {...item} /> - case 'description': - return <FeedDescription {...item} /> - case 'bet': - return <FeedBet {...item} /> - case 'liquidity': - return <FeedLiquidity {...item} /> - case 'answergroup': - return <FeedAnswerCommentGroup {...item} /> - case 'close': - return <FeedClose {...item} /> - case 'resolve': - return <FeedResolve {...item} /> - case 'commentInput': - return <CommentInput {...item} /> - case 'commentThread': - return <FeedCommentThread {...item} /> - } -} - -export function FeedQuestion(props: { - contract: Contract - contractPath?: string -}) { - const { contract } = props - const { - creatorName, - creatorUsername, - question, - outcomeType, - volume, - createdTime, - isResolved, - } = contract - const { volumeLabel } = contractMetrics(contract) - const isBinary = outcomeType === 'BINARY' - const isNew = createdTime > Date.now() - DAY_MS && !isResolved - const user = useUser() - - return ( - <div className={'flex gap-2'}> - <Avatar - username={contract.creatorUsername} - avatarUrl={contract.creatorAvatarUrl} - /> - <div className="min-w-0 flex-1 py-1.5"> - <div className="mb-2 text-sm text-gray-500"> - <UserLink - className="text-gray-900" - name={creatorName} - username={creatorUsername} - />{' '} - asked - {/* Currently hidden on mobile; ideally we'd fit this in somewhere. */} - <div className="relative -top-2 float-right "> - {isNew || volume === 0 ? ( - <NewContractBadge /> - ) : ( - <span className="hidden text-gray-400 sm:inline"> - {volumeLabel} - </span> - )} - </div> - </div> - <Col className="items-start justify-between gap-2 sm:flex-row sm:gap-4"> - <SiteLink - href={ - props.contractPath ? props.contractPath : contractPath(contract) - } - onClick={() => user && trackClick(user.id, contract.id)} - className="text-lg text-indigo-700 sm:text-xl" - > - {question} - </SiteLink> - {isBinary && ( - <BinaryResolutionOrChance - className="items-center" - contract={contract} - /> - )} - </Col> - </div> - </div> - ) -} - -function FeedDescription(props: { contract: Contract }) { - const { contract } = props - const { creatorName, creatorUsername } = contract - - return ( - <> - <Avatar - username={contract.creatorUsername} - avatarUrl={contract.creatorAvatarUrl} - /> - <div className="min-w-0 flex-1 py-1.5"> - <div className="text-sm text-gray-500"> - <UserLink - className="text-gray-900" - name={creatorName} - username={creatorUsername} - />{' '} - created this market <RelativeTimestamp time={contract.createdTime} /> - </div> - </div> - </> - ) -} - -function OutcomeIcon(props: { outcome?: string }) { - const { outcome } = props - switch (outcome) { - case 'YES': - return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - case 'NO': - return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - case 'CANCEL': - return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - default: - return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> - } -} - -function FeedResolve(props: { contract: Contract }) { - const { contract } = props - const { creatorName, creatorUsername } = contract - - const resolution = contract.resolution || 'CANCEL' - - const resolutionValue = (contract as NumericContract).resolutionValue - - return ( - <> - <div> - <div className="relative px-1"> - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> - <OutcomeIcon outcome={resolution} /> - </div> - </div> - </div> - <div className="min-w-0 flex-1 py-1.5"> - <div className="text-sm text-gray-500"> - <UserLink - className="text-gray-900" - name={creatorName} - username={creatorUsername} - />{' '} - resolved this market to{' '} - <OutcomeLabel - outcome={resolution} - value={resolutionValue} - contract={contract} - truncate="long" - />{' '} - <RelativeTimestamp time={contract.resolutionTime || 0} /> - </div> - </div> - </> - ) -} - -function FeedClose(props: { contract: Contract }) { - const { contract } = props - - return ( - <> - <div> - <div className="relative px-1"> - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200"> - <LockClosedIcon - className="h-5 w-5 text-gray-500" - aria-hidden="true" - /> - </div> - </div> - </div> - <div className="min-w-0 flex-1 py-1.5"> - <div className="text-sm text-gray-500"> - Trading closed in this market{' '} - <RelativeTimestamp time={contract.closeTime || 0} /> - </div> - </div> - </> - ) -} diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index 0ed06046..3a9ffdeb 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -3,7 +3,6 @@ import { User } from 'common/user' import { useUser, useUserById } from 'web/hooks/use-user' import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' -import clsx from 'clsx' import { formatMoney } from 'common/util/format' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React from 'react' @@ -11,10 +10,10 @@ import { UserLink } from '../user-page' import { LiquidityProvision } from 'common/liquidity-provision' export function FeedLiquidity(props: { + className?: string liquidity: LiquidityProvision - smallAvatar: boolean }) { - const { liquidity, smallAvatar } = props + const { liquidity } = props const { userId, createdTime } = liquidity const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01') @@ -26,21 +25,11 @@ export function FeedLiquidity(props: { return ( <> - <Row className={'flex w-full gap-2 pt-3'}> + <Row className="flex w-full gap-2 pt-3"> {isSelf ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={user.avatarUrl} - username={user.username} - /> + <Avatar avatarUrl={user.avatarUrl} username={user.username} /> ) : bettor ? ( - <Avatar - className={clsx(smallAvatar && 'ml-1')} - size={smallAvatar ? 'sm' : undefined} - avatarUrl={bettor.avatarUrl} - username={bettor.username} - /> + <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> ) : ( <div className="relative px-1"> <EmptyAvatar /> diff --git a/web/components/linkify.tsx b/web/components/linkify.tsx index b4f05165..a24ab0b6 100644 --- a/web/components/linkify.tsx +++ b/web/components/linkify.tsx @@ -1,10 +1,15 @@ +import clsx from 'clsx' import { Fragment } from 'react' import { SiteLink } from './site-link' // Return a JSX span, linkifying @username, #hashtags, and https://... // TODO: Use a markdown parser instead of rolling our own here. -export function Linkify(props: { text: string; gray?: boolean }) { - const { text, gray } = props +export function Linkify(props: { + text: string + className?: string + gray?: boolean +}) { + const { text, className, gray } = props // Replace "m1234" with "ϻ1234" // const mRegex = /(\W|^)m(\d+)/g // text = text.replace(mRegex, (_, pre, num) => `${pre}ϻ${num}`) @@ -38,7 +43,7 @@ export function Linkify(props: { text: string; gray?: boolean }) { ) }) return ( - <span className="break-anywhere"> + <span className={clsx(className, 'break-anywhere')}> {text.split(regex).map((part, i) => ( <Fragment key={i}> {part} From e1f19c52abf5ab1144de2084c1dbcded2836b611 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Tue, 30 Aug 2022 13:39:10 +0100 Subject: [PATCH 152/279] Post in a group about page. (#803) * Dashboards in Group about page * Rename group dashboard to 'About Post' * Fixed James nits --- common/group.ts | 1 + firestore.rules | 2 +- web/components/groups/group-about-post.tsx | 141 +++++++++++++++++++++ web/hooks/use-post.ts | 13 ++ web/lib/firebase/groups.ts | 5 + web/lib/firebase/posts.ts | 9 +- web/pages/group/[...slugs]/index.tsx | 21 ++- 7 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 web/components/groups/group-about-post.tsx create mode 100644 web/hooks/use-post.ts diff --git a/common/group.ts b/common/group.ts index 7d3215ae..181ad153 100644 --- a/common/group.ts +++ b/common/group.ts @@ -10,6 +10,7 @@ export type Group = { anyoneCanJoin: boolean contractIds: string[] + aboutPostId?: string chatDisabled?: boolean mostRecentChatActivityTime?: number mostRecentContractAddedTime?: number diff --git a/firestore.rules b/firestore.rules index fe45071b..26aa52e0 100644 --- a/firestore.rules +++ b/firestore.rules @@ -160,7 +160,7 @@ service cloud.firestore { allow update: if request.auth.uid == resource.data.creatorId && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]); + .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]); allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin) && request.resource.data.diff(resource.data) .affectedKeys() diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-about-post.tsx new file mode 100644 index 00000000..1b42c04d --- /dev/null +++ b/web/components/groups/group-about-post.tsx @@ -0,0 +1,141 @@ +import { useAdmin } from 'web/hooks/use-admin' +import { Row } from '../layout/row' +import { Content } from '../editor' +import { TextEditor, useTextEditor } from 'web/components/editor' +import { Button } from '../button' +import { Spacer } from '../layout/spacer' +import { Group } from 'common/group' +import { deleteFieldFromGroup, updateGroup } from 'web/lib/firebase/groups' +import PencilIcon from '@heroicons/react/solid/PencilIcon' +import { DocumentRemoveIcon } from '@heroicons/react/solid' +import { createPost } from 'web/lib/firebase/api' +import { Post } from 'common/post' +import { deletePost, updatePost } from 'web/lib/firebase/posts' +import { useState } from 'react' +import { usePost } from 'web/hooks/use-post' + +export function GroupAboutPost(props: { + group: Group + isCreator: boolean + post: Post +}) { + const { group, isCreator } = props + const post = usePost(group.aboutPostId) ?? props.post + const isAdmin = useAdmin() + + if (group.aboutPostId == null && !isCreator) { + return <p className="text-center">No post has been created </p> + } + + return ( + <div className="rounded-md bg-white p-4"> + {isCreator || isAdmin ? ( + <RichEditGroupAboutPost group={group} post={post} /> + ) : ( + <Content content={post.content} /> + )} + </div> + ) +} + +function RichEditGroupAboutPost(props: { group: Group; post: Post }) { + const { group, 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 + const newPost = { + title: group.name, + content: editor.getJSON(), + } + + if (group.aboutPostId == null) { + const result = await createPost(newPost).catch((e) => { + console.error(e) + return e + }) + await updateGroup(group, { + aboutPostId: result.post.id, + }) + } else { + await updatePost(post, { + content: newPost.content, + }) + } + } + + async function deleteGroupAboutPost() { + await deletePost(post) + await deleteFieldFromGroup(group, 'aboutPostId') + } + + 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> + </> + ) : ( + <> + {group.aboutPostId == null ? ( + <div className="text-center text-gray-500"> + <p className="text-sm"> + No post has been added yet. + <Spacer h={2} /> + <Button onClick={() => setEditing(true)}>Add a post</Button> + </p> + </div> + ) : ( + <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> + + <Button + color="gray" + size="xs" + onClick={() => { + deleteGroupAboutPost() + }} + > + <DocumentRemoveIcon className="inline h-4 w-4" /> + Delete + </Button> + </div> + + <Content content={post.content} /> + <Spacer h={2} /> + </div> + )} + </> + ) +} diff --git a/web/hooks/use-post.ts b/web/hooks/use-post.ts new file mode 100644 index 00000000..9daf2d22 --- /dev/null +++ b/web/hooks/use-post.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' +import { Post } from 'common/post' +import { listenForPost } from 'web/lib/firebase/posts' + +export const usePost = (postId: string | undefined) => { + const [post, setPost] = useState<Post | null | undefined>() + + useEffect(() => { + if (postId) return listenForPost(postId, setPost) + }, [postId]) + + return post +} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 3f5d18af..28515a35 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -1,5 +1,6 @@ import { deleteDoc, + deleteField, doc, getDocs, query, @@ -36,6 +37,10 @@ export function updateGroup(group: Group, updates: Partial<Group>) { return updateDoc(doc(groups, group.id), updates) } +export function deleteFieldFromGroup(group: Group, field: string) { + return updateDoc(doc(groups, group.id), { [field]: deleteField() }) +} + export function deleteGroup(group: Group) { return deleteDoc(doc(groups, group.id)) } diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts index 10bea499..162933af 100644 --- a/web/lib/firebase/posts.ts +++ b/web/lib/firebase/posts.ts @@ -7,7 +7,7 @@ import { where, } from 'firebase/firestore' import { Post } from 'common/post' -import { coll, getValue } from './utils' +import { coll, getValue, listenForValue } from './utils' export const posts = coll<Post>('posts') @@ -32,3 +32,10 @@ export async function getPostBySlug(slug: string) { const docs = (await getDocs(q)).docs return docs.length === 0 ? null : docs[0].data() } + +export function listenForPost( + postId: string, + setPost: (post: Post | null) => void +) { + return listenForValue(doc(posts, postId), setPost) +} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 28658a16..5271a395 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -45,6 +45,11 @@ import { Button } from 'web/components/button' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { GroupComment } from 'common/comment' import { REFERRAL_AMOUNT } from 'common/economy' +import { GroupAboutPost } from 'web/components/groups/group-about-post' +import { getPost } from 'web/lib/firebase/posts' +import { Post } from 'common/post' +import { Spacer } from 'web/components/layout/spacer' +import { usePost } from 'web/hooks/use-post' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -57,6 +62,8 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const contracts = (group && (await listContractsByGroupSlug(group.slug))) ?? [] + const aboutPost = + group && group.aboutPostId != null && (await getPost(group.aboutPostId)) const bets = await Promise.all( contracts.map((contract: Contract) => listAllBets(contract.id)) ) @@ -83,6 +90,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { creatorScores, topCreators, messages, + aboutPost, }, revalidate: 60, // regenerate after a minute @@ -121,6 +129,7 @@ export default function GroupPage(props: { creatorScores: { [userId: string]: number } topCreators: User[] messages: GroupComment[] + aboutPost: Post }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -146,6 +155,7 @@ export default function GroupPage(props: { const page = slugs?.[1] as typeof groupSubpages[number] const group = useGroup(props.group?.id) ?? props.group + const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost const user = useUser() @@ -176,6 +186,16 @@ export default function GroupPage(props: { const aboutTab = ( <Col> + {group.aboutPostId != null || isCreator ? ( + <GroupAboutPost + group={group} + isCreator={!!isCreator} + post={aboutPost} + /> + ) : ( + <div></div> + )} + <Spacer h={3} /> <GroupOverview group={group} creator={creator} @@ -292,7 +312,6 @@ function GroupOverview(props: { error: "Couldn't update group", }) } - const postFix = user ? '?referrer=' + user.username : '' const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug From a0402830c506cf9a018eb303cf6384c5a8541182 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 30 Aug 2022 09:38:59 -0600 Subject: [PATCH 153/279] liking markets with tip/heart (#798) * WIP liking markets with tip * Refactor Userlink, add MultiUserLink * Lint * Lint * Fix merge * Fix imports * wip liked contracts list * Cache likes and liked by user ids on contract * Refactor tip amount, reduce to M * Move back to M * Change positioning for large screens --- common/contract.ts | 2 + common/like.ts | 8 ++ common/notification.ts | 4 + firestore.rules | 5 + functions/src/create-notification.ts | 34 ++++++ functions/src/index.ts | 2 + functions/src/on-create-like.ts | 71 ++++++++++++ functions/src/on-delete-like.ts | 32 ++++++ functions/src/on-update-contract-follow.ts | 1 + web/components/answers/answers-panel.tsx | 2 +- web/components/bets-list.tsx | 2 +- web/components/charity/feed-items.tsx | 2 +- web/components/comments-list.tsx | 2 +- web/components/contract/contract-details.tsx | 2 +- web/components/contract/contract-overview.tsx | 50 ++++++--- .../contract/like-market-button.tsx | 55 ++++++++++ web/components/contract/share-row.tsx | 4 + .../feed/feed-answer-comment-group.tsx | 2 +- web/components/feed/feed-bets.tsx | 2 +- web/components/feed/feed-comments.tsx | 2 +- web/components/feed/feed-liquidity.tsx | 2 +- web/components/filter-select-users.tsx | 2 +- web/components/follow-list.tsx | 2 +- web/components/groups/group-chat.tsx | 2 +- web/components/online-user-list.tsx | 2 +- web/components/profile/user-likes-button.tsx | 48 +++++++++ web/components/referrals-button.tsx | 2 +- web/components/user-link.tsx | 102 ++++++++++++++++++ web/components/user-page.tsx | 31 +----- web/hooks/use-likes.ts | 38 +++++++ web/hooks/use-notifications.ts | 8 +- web/lib/firebase/likes.ts | 54 ++++++++++ web/lib/firebase/users.ts | 9 ++ .../[contractSlug]/[challengeSlug].tsx | 2 +- web/pages/challenges/index.tsx | 2 +- web/pages/group/[...slugs]/index.tsx | 2 +- web/pages/groups.tsx | 2 +- web/pages/links.tsx | 2 +- web/pages/notifications.tsx | 45 +++++--- web/pages/post/[...slugs]/index.tsx | 2 +- 40 files changed, 565 insertions(+), 78 deletions(-) create mode 100644 common/like.ts create mode 100644 functions/src/on-create-like.ts create mode 100644 functions/src/on-delete-like.ts create mode 100644 web/components/contract/like-market-button.tsx create mode 100644 web/components/profile/user-likes-button.tsx create mode 100644 web/components/user-link.tsx create mode 100644 web/hooks/use-likes.ts create mode 100644 web/lib/firebase/likes.ts diff --git a/common/contract.ts b/common/contract.ts index 2b330201..5dc4b696 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -59,6 +59,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = { popularityScore?: number followerCount?: number featuredOnHomeRank?: number + likedByUserIds?: string[] + likedByUserCount?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/like.ts b/common/like.ts new file mode 100644 index 00000000..85140e02 --- /dev/null +++ b/common/like.ts @@ -0,0 +1,8 @@ +export type Like = { + id: string // will be id of the object liked, i.e. contract.id + userId: string + type: 'contract' + createdTime: number + tipTxnId?: string +} +export const LIKE_TIP_AMOUNT = 5 diff --git a/common/notification.ts b/common/notification.ts index f10bd3f6..657ea2c1 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -40,6 +40,8 @@ export type notification_source_types = | 'challenge' | 'betting_streak_bonus' | 'loan' + | 'like' + | 'tip_and_like' export type notification_source_update_types = | 'created' @@ -71,3 +73,5 @@ export type notification_reason_types = | 'betting_streak_incremented' | 'loan_income' | 'you_follow_contract' + | 'liked_your_contract' + | 'liked_and_tipped_your_contract' diff --git a/firestore.rules b/firestore.rules index 26aa52e0..7b263e1a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -62,6 +62,11 @@ service cloud.firestore { allow write: if request.auth.uid == userId; } + match /users/{userId}/likes/{likeId} { + allow read; + allow write: if request.auth.uid == userId; + } + match /{somePath=**}/follows/{followUserId} { allow read; } diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 035126c5..9c5d98c1 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Challenge } from '../../common/challenge' import { richTextToString } from '../../common/util/parse' +import { Like } from '../../common/like' const firestore = admin.firestore() type user_to_reason_texts = { @@ -689,3 +690,36 @@ export const createBettingStreakBonusNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createLikeNotification = async ( + fromUser: User, + toUser: User, + like: Like, + idempotencyKey: string, + contract: Contract, + tip?: TipTxn +) => { + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: like.id, + sourceType: tip ? 'tip_and_like' : 'like', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: tip?.amount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: contract.slug, + sourceTitle: contract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 32bc16c4..6ede39a0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,8 @@ export * from './weekly-markets-emails' export * from './reset-betting-streaks' export * from './reset-weekly-emails-flag' export * from './on-update-contract-follow' +export * from './on-create-like' +export * from './on-delete-like' // v2 export * from './health' diff --git a/functions/src/on-create-like.ts b/functions/src/on-create-like.ts new file mode 100644 index 00000000..8c5885b0 --- /dev/null +++ b/functions/src/on-create-like.ts @@ -0,0 +1,71 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Like } from '../../common/like' +import { getContract, getUser, log } from './utils' +import { createLikeNotification } from './create-notification' +import { TipTxn } from '../../common/txn' +import { uniq } from 'lodash' + +const firestore = admin.firestore() + +export const onCreateLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onCreate(async (change, context) => { + const like = change.data() as Like + const { eventId } = context + if (like.type === 'contract') { + await handleCreateLikeNotification(like, eventId) + await updateContractLikes(like) + } + }) + +const updateContractLikes = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + likedByUserIds.push(like.userId) + await firestore + .collection('contracts') + .doc(like.id) + .update({ likedByUserIds, likedByUserCount: likedByUserIds.length }) +} + +const handleCreateLikeNotification = async (like: Like, eventId: string) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const contractCreator = await getUser(contract.creatorId) + if (!contractCreator) { + log('Could not find contract creator') + return + } + const liker = await getUser(like.userId) + if (!liker) { + log('Could not find liker') + return + } + let tipTxnData = undefined + + if (like.tipTxnId) { + const tipTxn = await firestore.collection('txns').doc(like.tipTxnId).get() + if (!tipTxn.exists) { + log('Could not find tip txn') + return + } + tipTxnData = tipTxn.data() as TipTxn + } + + await createLikeNotification( + liker, + contractCreator, + like, + eventId, + contract, + tipTxnData + ) +} diff --git a/functions/src/on-delete-like.ts b/functions/src/on-delete-like.ts new file mode 100644 index 00000000..151614b0 --- /dev/null +++ b/functions/src/on-delete-like.ts @@ -0,0 +1,32 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Like } from '../../common/like' +import { getContract, log } from './utils' +import { uniq } from 'lodash' + +const firestore = admin.firestore() + +export const onDeleteLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onDelete(async (change) => { + const like = change.data() as Like + if (like.type === 'contract') { + await removeContractLike(like) + } + }) + +const removeContractLike = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + const newLikedByUserIds = likedByUserIds.filter( + (userId) => userId !== like.userId + ) + await firestore.collection('contracts').doc(like.id).update({ + likedByUserIds: newLikedByUserIds, + likedByUserCount: newLikedByUserIds.length, + }) +} diff --git a/functions/src/on-update-contract-follow.ts b/functions/src/on-update-contract-follow.ts index f7d54fe8..20ef8e30 100644 --- a/functions/src/on-update-contract-follow.ts +++ b/functions/src/on-update-contract-follow.ts @@ -2,6 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { FieldValue } from 'firebase-admin/firestore' +// TODO: should cache the follower user ids in the contract as these triggers aren't idempotent export const onDeleteContractFollow = functions.firestore .document('contracts/{contractId}/follows/{userId}') .onDelete(async (change, context) => { diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 3de99c34..e53153b1 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -20,9 +20,9 @@ import { Modal } from 'web/components/layout/modal' import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' +import { UserLink } from 'web/components/user-link' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 3217da3d..932d689c 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -21,7 +21,6 @@ import { getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { Row } from './layout/row' -import { UserLink } from './user-page' import { sellBet } from 'web/lib/firebase/api' import { ConfirmationButton } from './confirmation-button' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' @@ -48,6 +47,7 @@ import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' +import { UserLink } from 'web/components/user-link' import { useUserBetContracts } from 'web/hooks/use-contracts' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' diff --git a/web/components/charity/feed-items.tsx b/web/components/charity/feed-items.tsx index 365aa606..b589f34b 100644 --- a/web/components/charity/feed-items.tsx +++ b/web/components/charity/feed-items.tsx @@ -1,9 +1,9 @@ import { DonationTxn } from 'common/txn' import { Avatar } from '../avatar' import { useUserById } from 'web/hooks/use-user' -import { UserLink } from '../user-page' import { manaToUSD } from '../../../common/util/format' import { RelativeTimestamp } from '../relative-timestamp' +import { UserLink } from 'web/components/user-link' export function Donation(props: { txn: DonationTxn }) { const { txn } = props diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 12ae0649..0b1c3843 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -6,11 +6,11 @@ import { SiteLink } from './site-link' import { Row } from './layout/row' import { Avatar } from './avatar' import { RelativeTimestamp } from './relative-timestamp' -import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' import { Content } from './editor' import { LoadingIndicator } from './loading-indicator' +import { UserLink } from 'web/components/user-link' import { PaginationNextPrev } from 'web/components/pagination' type ContractKey = { diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 175b36b5..72ecbb1f 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -12,7 +12,6 @@ import dayjs from 'dayjs' import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' -import { UserLink } from '../user-page' import { Contract, updateContract } from 'web/lib/firebase/contracts' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -34,6 +33,7 @@ import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' +import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 23485179..37639d79 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -21,6 +21,7 @@ import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' import { ShareRow } from './share-row' +import { LikeMarketButton } from 'web/components/contract/like-market-button' export const ContractOverview = (props: { contract: Contract @@ -43,6 +44,13 @@ export const ContractOverview = (props: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && + !resolution && ( + <div className={'sm:hidden'}> + <LikeMarketButton contract={contract} user={user} /> + </div> + )} <Row className={'hidden gap-3 xl:flex'}> {isBinary && ( <BinaryResolutionOrChance @@ -72,28 +80,38 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> {tradingAllowed(contract) && ( - <Col> - <BetButton contract={contract as CPMMBinaryContract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> + <Row> + <div className={'sm:hidden'}> + <LikeMarketButton contract={contract} user={user} /> + </div> + <Col> + <BetButton contract={contract as CPMMBinaryContract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + </Row> )} </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> {tradingAllowed(contract) && ( - <Col> - <BetButton contract={contract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> + <Row> + <div className={'sm:hidden'}> + <LikeMarketButton contract={contract} user={user} /> + </div> + <Col> + <BetButton contract={contract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + </Row> )} </Row> ) : ( diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx new file mode 100644 index 00000000..f0cb77b0 --- /dev/null +++ b/web/components/contract/like-market-button.tsx @@ -0,0 +1,55 @@ +import { HeartIcon } from '@heroicons/react/outline' +import { Button } from 'web/components/button' +import React from 'react' +import { Contract } from 'common/contract' +import { User } from 'common/user' +import { useUserLikes } from 'web/hooks/use-likes' +import toast from 'react-hot-toast' +import { formatMoney } from 'common/util/format' +import { likeContract, unLikeContract } from 'web/lib/firebase/likes' +import { LIKE_TIP_AMOUNT } from 'common/like' +import clsx from 'clsx' +import { Row } from 'web/components/layout/row' + +export function LikeMarketButton(props: { + contract: Contract + user: User | null | undefined +}) { + const { contract, user } = props + + const likes = useUserLikes(user?.id) + const likedContractIds = likes + ?.filter((l) => l.type === 'contract') + .map((l) => l.id) + if (!user) return <div /> + + const onLike = async () => { + if (likedContractIds?.includes(contract.id)) { + await unLikeContract(user.id, contract.id) + return + } + await likeContract(user, contract) + toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) + } + + return ( + <Button + size={'lg'} + className={'mb-1'} + color={'gray-white'} + onClick={onLike} + > + <Row className={'gap-0 sm:gap-2'}> + <HeartIcon + className={clsx( + 'h-6 w-6', + likedContractIds?.includes(contract.id) + ? 'fill-red-500 text-red-500' + : '' + )} + /> + <span className={'hidden sm:block'}>Tip</span> + </Row> + </Button> + ) +} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 1af52291..03bd99e6 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -11,6 +11,7 @@ import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' import { withTracking } from 'web/lib/service/analytics' import { FollowMarketButton } from 'web/components/follow-market-button' +import { LikeMarketButton } from 'web/components/contract/like-market-button' export function ShareRow(props: { contract: Contract @@ -64,6 +65,9 @@ export function ShareRow(props: { </Button> )} <FollowMarketButton contract={contract} user={user} /> + <div className={'hidden sm:block'}> + <LikeMarketButton contract={contract} user={user} /> + </div> </Row> ) } diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 2df8cb4a..7758ec82 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -6,7 +6,6 @@ import React, { useEffect, useState } from 'react' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import clsx from 'clsx' import { @@ -20,6 +19,7 @@ import { Dictionary } from 'lodash' import { User } from 'common/user' import { useEvent } from 'web/hooks/use-event' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { UserLink } from 'web/components/user-link' export function FeedAnswerCommentGroup(props: { contract: FreeResponseContract diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index b7aeb321..cf444061 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -10,11 +10,11 @@ import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React, { useEffect } from 'react' -import { UserLink } from '../user-page' import { formatNumericProbability } from 'common/pseudo-numeric' import { SiteLink } from 'web/components/site-link' import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' import { Challenge } from 'common/challenge' +import { UserLink } from 'web/components/user-link' export function FeedBet(props: { contract: Contract; bet: Bet }) { const { contract, bet } = props diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 9e6c3cd5..1aebb27b 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -10,7 +10,6 @@ import { useRouter } from 'next/router' import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' @@ -29,6 +28,7 @@ import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { useWindowSize } from 'web/hooks/use-window-size' import { Content, TextEditor, useTextEditor } from '../editor' import { Editor } from '@tiptap/react' +import { UserLink } from 'web/components/user-link' export function FeedCommentThread(props: { user: User | null | undefined diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index 3a9ffdeb..ee2e34e5 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -6,8 +6,8 @@ import { Avatar, EmptyAvatar } from 'web/components/avatar' import { formatMoney } from 'common/util/format' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React from 'react' -import { UserLink } from '../user-page' import { LiquidityProvision } from 'common/liquidity-provision' +import { UserLink } from 'web/components/user-link' export function FeedLiquidity(props: { className?: string diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index a19ab6af..415a6d57 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -6,8 +6,8 @@ import clsx from 'clsx' import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' -import { UserLink } from 'web/components/user-page' import { searchInAny } from 'common/util/parse' +import { UserLink } from 'web/components/user-link' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void diff --git a/web/components/follow-list.tsx b/web/components/follow-list.tsx index c935f73d..65b9ef4a 100644 --- a/web/components/follow-list.tsx +++ b/web/components/follow-list.tsx @@ -6,7 +6,7 @@ import { Avatar } from './avatar' import { FollowButton } from './follow-button' import { Col } from './layout/col' import { Row } from './layout/row' -import { UserLink } from './user-page' +import { UserLink } from 'web/components/user-link' export function FollowList(props: { userIds: string[] }) { const { userIds } = props diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 244a3ffe..9a60c9c7 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -11,7 +11,6 @@ import { track } from 'web/lib/service/analytics' import { firebaseLogin } from 'web/lib/firebase/users' import { useRouter } from 'next/router' import clsx from 'clsx' -import { UserLink } from 'web/components/user-page' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' @@ -23,6 +22,7 @@ import { useUnseenNotifications } from 'web/hooks/use-notifications' import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' import { usePrivateUser } from 'web/hooks/use-user' +import { UserLink } from 'web/components/user-link' export function GroupChat(props: { messages: GroupComment[] diff --git a/web/components/online-user-list.tsx b/web/components/online-user-list.tsx index d7f52d56..e5e006ac 100644 --- a/web/components/online-user-list.tsx +++ b/web/components/online-user-list.tsx @@ -2,13 +2,13 @@ import clsx from 'clsx' import { Avatar } from './avatar' import { Col } from './layout/col' import { Row } from './layout/row' -import { UserLink } from './user-page' import { User } from 'common/user' import { UserCircleIcon } from '@heroicons/react/solid' import { useUsers } from 'web/hooks/use-users' import { partition } from 'lodash' import { useWindowSize } from 'web/hooks/use-window-size' import { useState } from 'react' +import { UserLink } from 'web/components/user-link' const isOnline = (user?: User) => user && user.lastPingTime && user.lastPingTime > Date.now() - 5 * 60 * 1000 diff --git a/web/components/profile/user-likes-button.tsx b/web/components/profile/user-likes-button.tsx new file mode 100644 index 00000000..3d4fa9ac --- /dev/null +++ b/web/components/profile/user-likes-button.tsx @@ -0,0 +1,48 @@ +import { User } from 'common/user' +import { useState } from 'react' +import { TextButton } from 'web/components/text-button' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { useUserLikedContracts } from 'web/hooks/use-likes' +import { SiteLink } from 'web/components/site-link' +import { Row } from 'web/components/layout/row' +import { XIcon } from '@heroicons/react/outline' +import { unLikeContract } from 'web/lib/firebase/likes' +import { contractPath } from 'web/lib/firebase/contracts' + +export function UserLikesButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + + const likedContracts = useUserLikedContracts(user.id) + + return ( + <> + <TextButton onClick={() => setIsOpen(true)}> + <span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '} + Likes + </TextButton> + <Modal open={isOpen} setOpen={setIsOpen}> + <Col className="rounded bg-white p-6"> + <span className={'mb-4 text-xl'}>Liked Markets</span> + <Col className={'gap-4'}> + {likedContracts?.map((likedContract) => ( + <Row key={likedContract.id} className={'justify-between gap-2'}> + <SiteLink + href={contractPath(likedContract)} + className={'truncate text-indigo-700'} + > + {likedContract.question} + </SiteLink> + <XIcon + className="ml-2 h-5 w-5 shrink-0 cursor-pointer" + onClick={() => unLikeContract(user.id, likedContract.id)} + /> + </Row> + ))} + </Col> + </Col> + </Modal> + </> + ) +} diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index fed8fb6b..3cf77cfd 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -7,11 +7,11 @@ import { Modal } from './layout/modal' import { Tabs } from './layout/tabs' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { useReferrals } from 'web/hooks/use-referrals' import { FilterSelectUsers } from 'web/components/filter-select-users' import { getUser, updateUser } from 'web/lib/firebase/users' import { TextButton } from 'web/components/text-button' +import { UserLink } from 'web/components/user-link' export function ReferralsButton(props: { user: User; currentUser?: User }) { const { user, currentUser } = props diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx new file mode 100644 index 00000000..5eeab1c4 --- /dev/null +++ b/web/components/user-link.tsx @@ -0,0 +1,102 @@ +import { linkClass, SiteLink } from 'web/components/site-link' +import clsx from 'clsx' +import { Row } from 'web/components/layout/row' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { useState } from 'react' +import { Avatar } from 'web/components/avatar' +import { formatMoney } from 'common/util/format' + +function shortenName(name: string) { + const firstName = name.split(' ')[0] + const maxLength = 10 + const shortName = + firstName.length >= 3 + ? firstName.length < maxLength + ? firstName + : firstName.substring(0, maxLength - 3) + '...' + : name.length > maxLength + ? name.substring(0, maxLength) + '...' + : name + return shortName +} + +export function UserLink(props: { + name: string + username: string + showUsername?: boolean + className?: string + short?: boolean +}) { + const { name, username, showUsername, className, short } = props + const shortName = short ? shortenName(name) : name + return ( + <SiteLink + href={`/${username}`} + className={clsx('z-10 truncate', className)} + > + {shortName} + {showUsername && ` (@${username})`} + </SiteLink> + ) +} + +export type MultiUserLinkInfo = { + name: string + username: string + avatarUrl: string | undefined + amountTipped: number +} + +export function MultiUserTipLink(props: { + userInfos: MultiUserLinkInfo[] + className?: string +}) { + const { userInfos, className } = props + const [open, setOpen] = useState(false) + const maxShowCount = 2 + return ( + <> + <Row + className={clsx('mr-1 inline-flex gap-1', linkClass, className)} + onClick={(e) => { + e.stopPropagation() + setOpen(true) + }} + > + {userInfos.map((userInfo, index) => + index < maxShowCount ? ( + <span key={userInfo.username + 'shortened'} className={linkClass}> + {shortenName(userInfo.name) + + (index < maxShowCount - 1 ? ', ' : '')} + </span> + ) : ( + <span className={linkClass}> + & {userInfos.length - maxShowCount} more + </span> + ) + )} + </Row> + <Modal open={open} setOpen={setOpen} size={'sm'}> + <Col className="items-start gap-4 rounded-md bg-white p-6"> + <span className={'text-xl'}>Who tipped you</span> + {userInfos.map((userInfo) => ( + <Row + key={userInfo.username + 'list'} + className="w-full items-center gap-2" + > + <span className="text-primary min-w-[3.5rem]"> + +{formatMoney(userInfo.amountTipped)} + </span> + <Avatar + username={userInfo.username} + avatarUrl={userInfo.avatarUrl} + /> + <UserLink name={userInfo.name} username={userInfo.username} /> + </Row> + ))} + </Col> + </Modal> + </> + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index a20fc58a..8312f16e 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -31,35 +31,7 @@ import { ENV_CONFIG } from 'common/envs/constants' import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' - -export function UserLink(props: { - name: string - username: string - showUsername?: boolean - className?: string - short?: boolean -}) { - const { name, username, showUsername, className, short } = props - const firstName = name.split(' ')[0] - const maxLength = 10 - const shortName = - firstName.length >= 3 - ? firstName.length < maxLength - ? firstName - : firstName.substring(0, maxLength - 3) + '...' - : name.length > maxLength - ? name.substring(0, maxLength) + '...' - : name - return ( - <SiteLink - href={`/${username}`} - className={clsx('z-10 truncate', className)} - > - {short ? shortName : name} - {showUsername && ` (@${username})`} - </SiteLink> - ) -} +import { UserLikesButton } from 'web/components/profile/user-likes-button' export function UserPage(props: { user: User }) { const { user } = props @@ -302,6 +274,7 @@ export function UserPage(props: { user: User }) { <FollowersButton user={user} /> <ReferralsButton user={user} /> <GroupsButton user={user} /> + <UserLikesButton user={user} /> </Row> ), }, diff --git a/web/hooks/use-likes.ts b/web/hooks/use-likes.ts new file mode 100644 index 00000000..015d2c3c --- /dev/null +++ b/web/hooks/use-likes.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react' +import { listenForLikes } from 'web/lib/firebase/users' +import { Like } from 'common/like' +import { Contract } from 'common/contract' +import { getContractFromId } from 'web/lib/firebase/contracts' +import { filterDefined } from 'common/util/array' + +export const useUserLikes = (userId: string | undefined) => { + const [contractIds, setContractIds] = useState<Like[] | undefined>() + + useEffect(() => { + if (userId) return listenForLikes(userId, setContractIds) + }, [userId]) + + return contractIds +} +export const useUserLikedContracts = (userId: string | undefined) => { + const [likes, setLikes] = useState<Like[] | undefined>() + const [contracts, setContracts] = useState<Contract[] | undefined>() + + useEffect(() => { + if (userId) + return listenForLikes(userId, (likes) => { + setLikes(likes.filter((l) => l.type === 'contract')) + }) + }, [userId]) + + useEffect(() => { + if (likes) + Promise.all( + likes.map(async (like) => { + return await getContractFromId(like.id) + }) + ).then((contracts) => setContracts(filterDefined(contracts))) + }, [likes]) + + return contracts +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index b2f1701f..60d0e43e 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -63,7 +63,13 @@ export function groupNotifications(notifications: Notification[]) { const notificationGroupsByDay = groupBy(notifications, (notification) => new Date(notification.createdTime).toDateString() ) - const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus'] + const incomeSourceTypes = [ + 'bonus', + 'tip', + 'loan', + 'betting_streak_bonus', + 'tip_and_like', + ] Object.keys(notificationGroupsByDay).forEach((day) => { const notificationsGroupedByDay = notificationGroupsByDay[day] diff --git a/web/lib/firebase/likes.ts b/web/lib/firebase/likes.ts new file mode 100644 index 00000000..f16bedb7 --- /dev/null +++ b/web/lib/firebase/likes.ts @@ -0,0 +1,54 @@ +import { collection, deleteDoc, doc, setDoc } from 'firebase/firestore' +import { db } from 'web/lib/firebase/init' +import toast from 'react-hot-toast' +import { transact } from 'web/lib/firebase/api' +import { removeUndefinedProps } from 'common/util/object' +import { Like, LIKE_TIP_AMOUNT } from 'common/like' +import { track } from '@amplitude/analytics-browser' +import { User } from 'common/user' +import { Contract } from 'common/contract' + +function getLikesCollection(userId: string) { + return collection(db, 'users', userId, 'likes') +} + +export const unLikeContract = async (userId: string, contractId: string) => { + const ref = await doc(getLikesCollection(userId), contractId) + return await deleteDoc(ref) +} + +export const likeContract = async (user: User, contract: Contract) => { + if (user.balance < LIKE_TIP_AMOUNT) { + toast('You do not have enough M$ to like this market!') + return + } + let result: any = {} + if (LIKE_TIP_AMOUNT > 0) { + result = await transact({ + amount: LIKE_TIP_AMOUNT, + fromId: user.id, + fromType: 'USER', + toId: contract.creatorId, + toType: 'USER', + token: 'M$', + category: 'TIP', + data: { contractId: contract.id }, + description: `${user.name} liked contract ${contract.id} for M$ ${LIKE_TIP_AMOUNT} to ${contract.creatorId} `, + }) + console.log('result', result) + } + // create new like in db under users collection + const ref = doc(getLikesCollection(user.id), contract.id) + // contract slug and question are set via trigger + const like = removeUndefinedProps({ + id: ref.id, + userId: user.id, + createdTime: Date.now(), + type: 'contract', + tipTxnId: result.txn.id, + } as Like) + track('like', { + contractId: contract.id, + }) + await setDoc(ref, like) +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index c0764f0a..fc024e04 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -28,6 +28,7 @@ import utc from 'dayjs/plugin/utc' dayjs.extend(utc) import { track } from '@amplitude/analytics-browser' +import { Like } from 'common/like' export const users = coll<User>('users') export const privateUsers = coll<PrivateUser>('private-users') @@ -310,3 +311,11 @@ export function listenForReferrals( } ) } + +export function listenForLikes( + userId: string, + setLikes: (likes: Like[]) => void +) { + const likes = collection(users, userId, 'likes') + return listenForValues<Like>(likes, (docs) => setLikes(docs)) +} diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index f15c5809..6b8152d6 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -21,7 +21,6 @@ import { Page } from 'web/components/page' import { useUser, useUserById } from 'web/hooks/use-user' import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { BinaryOutcomeLabel } from 'web/components/outcome-label' import { formatMoney } from 'common/util/format' import { LoadingIndicator } from 'web/components/loading-indicator' @@ -33,6 +32,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { BinaryContract } from 'common/contract' import { Title } from 'web/components/title' import { getOpenGraphProps } from 'common/contract-details' +import { UserLink } from 'web/components/user-link' export const getStaticProps = fromPropz(getStaticPropz) diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index ad4136f0..11d0f9ab 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -19,7 +19,6 @@ import { import { Challenge, CHALLENGES_ENABLED } from 'common/challenge' import { Tabs } from 'web/components/layout/tabs' import { SiteLink } from 'web/components/site-link' -import { UserLink } from 'web/components/user-page' import { Avatar } from 'web/components/avatar' import Router from 'next/router' import { contractPathWithoutContract } from 'web/lib/firebase/contracts' @@ -30,6 +29,7 @@ import toast from 'react-hot-toast' import { Modal } from 'web/components/layout/modal' import { QRCode } from 'web/components/qr-code' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' +import { UserLink } from 'web/components/user-link' dayjs.extend(customParseFormat) const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 5271a395..bf29cc8b 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -17,7 +17,6 @@ import { updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' -import { UserLink } from 'web/components/user-page' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' @@ -45,6 +44,7 @@ import { Button } from 'web/components/button' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { GroupComment } from 'common/comment' import { REFERRAL_AMOUNT } from 'common/economy' +import { UserLink } from 'web/components/user-link' import { GroupAboutPost } from 'web/components/groups/group-about-post' import { getPost } from 'web/lib/firebase/posts' import { Post } from 'common/post' diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 521742b2..aaf1374c 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -16,9 +16,9 @@ import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { UserLink } from 'web/components/user-page' import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' +import { UserLink } from 'web/components/user-link' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 6f57dc14..4c4a0be1 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -18,7 +18,6 @@ import { ManalinkTxn } from 'common/txn' import { User } from 'common/user' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' -import { UserLink } from 'web/components/user-page' import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' @@ -27,6 +26,7 @@ import { Pagination } from 'web/components/pagination' import { Manalink } from 'common/manalink' import { SiteLink } from 'web/components/site-link' import { REFERRAL_AMOUNT } from 'common/economy' +import { UserLink } from 'web/components/user-link' const LINKS_PER_PAGE = 24 diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 85cbcbae..f1995568 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -7,7 +7,6 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' -import { UserLink } from 'web/components/user-page' import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, @@ -35,7 +34,7 @@ import { BETTING_STREAK_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT, } from 'common/economy' -import { groupBy, sum, uniq } from 'lodash' +import { groupBy, sum, uniqBy } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' @@ -45,10 +44,14 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { useUser } from 'web/hooks/use-user' +import { + MultiUserTipLink, + MultiUserLinkInfo, + UserLink, +} from 'web/components/user-link' import { LoadingIndicator } from 'web/components/loading-indicator' export const NOTIFICATIONS_PER_PAGE = 30 -const MULTIPLE_USERS_KEY = 'multipleUsers' const HIGHLIGHT_CLASS = 'bg-indigo-50' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { @@ -233,13 +236,26 @@ function IncomeNotificationGroupItem(props: { let sum = 0 notificationsForSourceTitle.forEach( (notification) => - notification.sourceText && - (sum = parseInt(notification.sourceText) + sum) + (sum = parseInt(notification.sourceText ?? '0') + sum) ) - const uniqueUsers = uniq( + const uniqueUsers = uniqBy( notificationsForSourceTitle.map((notification) => { - return notification.sourceUserUsername - }) + let thisSum = 0 + notificationsForSourceTitle + .filter( + (n) => n.sourceUserUsername === notification.sourceUserUsername + ) + .forEach( + (n) => (thisSum = parseInt(n.sourceText ?? '0') + thisSum) + ) + return { + username: notification.sourceUserUsername, + name: notification.sourceUserName, + avatarUrl: notification.sourceUserAvatarUrl, + amountTipped: thisSum, + } as MultiUserLinkInfo + }), + (n) => n.username ) const newNotification = { @@ -247,7 +263,7 @@ function IncomeNotificationGroupItem(props: { sourceText: sum.toString(), sourceUserUsername: uniqueUsers.length > 1 - ? MULTIPLE_USERS_KEY + ? JSON.stringify(uniqueUsers) : notificationsForSourceTitle[0].sourceType, } newNotifications.push(newNotification) @@ -385,6 +401,9 @@ function IncomeNotificationItem(props: { else reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { reasonText = `of your invested bets returned as a` + // TODO: support just 'like' notification without a tip + } else if (sourceType === 'tip_and_like' && sourceText) { + reasonText = !simple ? `liked` : `in likes on` } const streakInDays = @@ -493,9 +512,11 @@ function IncomeNotificationItem(props: { <span className={'mr-1'}>{incomeNotificationLabel()}</span> </div> <span> - {sourceType === 'tip' && - (sourceUserUsername === MULTIPLE_USERS_KEY ? ( - <span className={'mr-1 truncate'}>Multiple users</span> + {(sourceType === 'tip' || sourceType === 'tip_and_like') && + (sourceUserUsername?.includes(',') ? ( + <MultiUserTipLink + userInfos={JSON.parse(sourceUserUsername)} + /> ) : ( <UserLink name={sourceUserName || ''} diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 41c0d775..737e025f 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -5,7 +5,6 @@ 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 { UserLink } from 'web/components/user-page' import { getUser, User } from 'web/lib/firebase/users' import { ShareIcon } from '@heroicons/react/solid' import clsx from 'clsx' @@ -16,6 +15,7 @@ import { Row } from 'web/components/layout/row' 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' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params From 74c9406191e80792e6658b3325d8df5fda1a3d7a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 30 Aug 2022 09:52:14 -0600 Subject: [PATCH 154/279] Use cached user ids while likes is loading --- web/components/contract/like-market-button.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index f0cb77b0..f4fed287 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -43,7 +43,8 @@ export function LikeMarketButton(props: { <HeartIcon className={clsx( 'h-6 w-6', - likedContractIds?.includes(contract.id) + likedContractIds?.includes(contract.id) || + (!likes && contract.likedByUserIds?.includes(user.id)) ? 'fill-red-500 text-red-500' : '' )} From 876abef0409841f136f8aaecc16dded4dbcd443f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 30 Aug 2022 10:02:51 -0600 Subject: [PATCH 155/279] Only send dev weekly trending emails to ian --- functions/src/weekly-markets-emails.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index bf839d00..50f7195a 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -2,10 +2,18 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' -import { getAllPrivateUsers, getUser, getValues, log } from './utils' +import { + getAllPrivateUsers, + getPrivateUser, + getUser, + getValues, + isProd, + log, +} from './utils' import { sendInterestingMarketsEmail } from './emails' import { createRNG, shuffle } from '../../common/util/random' import { DAY_MS } from '../../common/util/time' +import { filterDefined } from '../../common/util/array' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -34,7 +42,9 @@ export async function getTrendingContracts() { async function sendTrendingMarketsEmailsToAllUsers() { const numContractsToSend = 6 - const privateUsers = await getAllPrivateUsers() + const privateUsers = isProd() + ? await getAllPrivateUsers() + : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return ( From d658a48b6673df54f105169891054751deb478b0 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 30 Aug 2022 10:31:35 -0700 Subject: [PATCH 156/279] Revert "hide quick bet on mobile" This reverts commit 3d073da97e7fd7c6a381f340e82abd4128054fc9. --- web/components/contract/contract-card.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index ef23b4be..e7c26fe0 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -32,7 +32,6 @@ import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' import { getMappedValue } from 'common/pseudo-numeric' import { Tooltip } from '../tooltip' -import { useWindowSize } from 'web/hooks/use-window-size' export function ContractCard(props: { contract: Contract @@ -64,11 +63,7 @@ export function ContractCard(props: { const marketClosed = (contract.closeTime || Infinity) < Date.now() || !!resolution - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 - const showQuickBet = - !isMobile && user && !marketClosed && (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && From f83b62cf50aab0a60e7bde567afd8a08a231d3d0 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 30 Aug 2022 16:18:39 -0500 Subject: [PATCH 157/279] Implement double carousel for /experimental/home --- web/components/carousel.tsx | 60 +++++++++++++++ web/components/contract-search.tsx | 24 +++--- web/pages/experimental/home.tsx | 114 +++++++++++++++++++++-------- web/pages/tournaments/index.tsx | 67 +---------------- 4 files changed, 163 insertions(+), 102 deletions(-) create mode 100644 web/components/carousel.tsx diff --git a/web/components/carousel.tsx b/web/components/carousel.tsx new file mode 100644 index 00000000..7ca19c66 --- /dev/null +++ b/web/components/carousel.tsx @@ -0,0 +1,60 @@ +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { throttle } from 'lodash' +import { ReactNode, useRef, useState, useEffect } from 'react' +import { Row } from './layout/row' + +export function Carousel(props: { children: ReactNode; className?: string }) { + const { children, className } = props + + const ref = useRef<HTMLDivElement>(null) + + const th = (f: () => any) => throttle(f, 500, { trailing: false }) + const scrollLeft = th(() => + ref.current?.scrollBy({ left: -ref.current.clientWidth }) + ) + const scrollRight = th(() => + ref.current?.scrollBy({ left: ref.current.clientWidth }) + ) + + const [atFront, setAtFront] = useState(true) + const [atBack, setAtBack] = useState(false) + const onScroll = throttle(() => { + if (ref.current) { + const { scrollLeft, clientWidth, scrollWidth } = ref.current + setAtFront(scrollLeft < 80) + setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80) + } + }, 500) + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(onScroll, []) + + return ( + <div className={clsx('relative', className)}> + <Row + className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth" + ref={ref} + onScroll={onScroll} + > + {children} + </Row> + {!atFront && ( + <div + className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" + onMouseDown={scrollLeft} + > + <ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> + </div> + )} + {!atBack && ( + <div + className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" + onMouseDown={scrollRight} + > + <ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> + </div> + )} + </div> + ) +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index fa6ea204..097a3b44 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -10,7 +10,7 @@ import { } from './contract/contracts-grid' import { ShowTime } from './contract/contract-details' import { Row } from './layout/row' -import { useEffect, useLayoutEffect, useRef, useMemo } from 'react' +import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' import { @@ -85,6 +85,7 @@ export function ContractSearch(props: { isWholePage?: boolean maxItems?: number noControls?: boolean + renderContracts?: (contracts: Contract[] | undefined) => ReactNode }) { const { user, @@ -101,6 +102,7 @@ export function ContractSearch(props: { isWholePage, maxItems, noControls, + renderContracts, } = props const [state, setState] = usePersistentState( @@ -203,14 +205,18 @@ export function ContractSearch(props: { onSearchParametersChanged={onSearchParametersChanged} noControls={noControls} /> - <ContractsGrid - contracts={renderedContracts} - loadMore={noControls ? undefined : performQuery} - showTime={state.showTime ?? undefined} - onContractClick={onContractClick} - highlightOptions={highlightOptions} - cardHideOptions={cardHideOptions} - /> + {renderContracts ? ( + renderContracts(renderedContracts) + ) : ( + <ContractsGrid + contracts={renderedContracts} + loadMore={noControls ? undefined : performQuery} + showTime={state.showTime ?? undefined} + onContractClick={onContractClick} + highlightOptions={highlightOptions} + cardHideOptions={cardHideOptions} + /> + )} </Col> ) } diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx index 607c54d0..a2d47609 100644 --- a/web/pages/experimental/home.tsx +++ b/web/pages/experimental/home.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { useRouter } from 'next/router' +import Router from 'next/router' import { PlusSmIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' @@ -17,7 +17,13 @@ import { Button } from 'web/components/button' import { Spacer } from 'web/components/layout/spacer' import { useMemberGroups } from 'web/hooks/use-group' import { Group } from 'common/group' -import { Title } from 'web/components/title' +import { Carousel } from 'web/components/carousel' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { ContractCard } from 'web/components/contract/contract-card' +import { range } from 'lodash' +import { Subtitle } from 'web/components/subtitle' +import { Contract } from 'common/contract' +import { ShowTime } from 'web/components/contract/contract-details' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -28,7 +34,6 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const Home = (props: { auth: { user: User } | null }) => { const user = props.auth ? props.auth.user : null - const router = useRouter() useTracking('view home') useSaveReferral() @@ -39,7 +44,7 @@ const Home = (props: { auth: { user: User } | null }) => { return ( <Page> - <Col className="mx-auto mb-8 w-full"> + <Col className="mx-4 mt-4 gap-2 sm:mx-10 xl:w-[125%]"> <SearchSection label="Trending" sort="score" user={user} /> <SearchSection label="Newest" sort="newest" user={user} /> <SearchSection label="Closing soon" sort="close-date" user={user} /> @@ -51,7 +56,7 @@ const Home = (props: { auth: { user: User } | null }) => { type="button" className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" onClick={() => { - router.push('/create') + Router.push('/create') track('mobile create button') }} > @@ -68,21 +73,25 @@ function SearchSection(props: { }) { const { label, user, sort } = props - const router = useRouter() - return ( <Col> - <Title className="mx-2 !text-gray-800 sm:mx-0" text={label} /> - <Spacer h={2} /> - <ContractSearch user={user} defaultSort={sort} maxItems={4} noControls /> - <Button - className="self-end" - color="blue" - size="sm" - onClick={() => router.push(`/home?s=${sort}`)} - > - See more - </Button> + <Subtitle className="mx-2 !mt-2 !text-gray-800 sm:mx-0" text={label} /> + <ContractSearch + user={user} + defaultSort={sort} + maxItems={12} + noControls + renderContracts={(contracts) => + contracts ? ( + <DoubleCarousel + contracts={contracts} + seeMoreUrl={`/home?s=${sort}`} + /> + ) : ( + <LoadingIndicator /> + ) + } + /> </Col> ) } @@ -90,29 +99,74 @@ function SearchSection(props: { function GroupSection(props: { group: Group; user: User | null }) { const { group, user } = props - const router = useRouter() - return ( <Col className=""> - <Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} /> + <Subtitle className="mx-2 !text-gray-800 sm:mx-0" text={group.name} /> <Spacer h={2} /> <ContractSearch user={user} defaultSort={'score'} additionalFilter={{ groupSlug: group.slug }} - maxItems={4} + maxItems={12} noControls + renderContracts={(contracts) => + contracts ? ( + <DoubleCarousel + contracts={contracts} + seeMoreUrl={`/group/${group.slug}`} + /> + ) : ( + <LoadingIndicator /> + ) + } /> - <Button - className="mr-2 self-end" - color="blue" - size="sm" - onClick={() => router.push(`/group/${group.slug}`)} - > - See more - </Button> </Col> ) } +function DoubleCarousel(props: { + contracts: Contract[] + seeMoreUrl?: string + showTime?: ShowTime +}) { + const { contracts, seeMoreUrl, showTime } = props + return ( + <Carousel className="-mx-4 mt-2 sm:-mx-10"> + <div className="shrink-0 sm:w-6" /> + {contracts && + range(0, Math.floor(contracts.length / 2)).map((col) => { + const i = col * 2 + return ( + <Col> + <ContractCard + key={contracts[i].id} + contract={contracts[i]} + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + <ContractCard + key={contracts[i + 1].id} + contract={contracts[i + 1]} + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + </Col> + ) + })} + <Button + className="self-center whitespace-nowrap" + color="blue" + size="sm" + onClick={() => seeMoreUrl && Router.push(seeMoreUrl)} + > + See more + </Button> + </Carousel> + ) +} + export default Home diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index f3fcbfce..4f66cc22 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -1,10 +1,5 @@ import { ClockIcon } from '@heroicons/react/outline' -import { - ChevronLeftIcon, - ChevronRightIcon, - UsersIcon, -} from '@heroicons/react/solid' -import clsx from 'clsx' +import { UsersIcon } from '@heroicons/react/solid' import { BinaryContract, Contract, @@ -15,10 +10,10 @@ import dayjs, { 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, throttle } from 'lodash' +import { keyBy, mapValues, sortBy } from 'lodash' import Image, { ImageProps, StaticImageData } from 'next/image' import Link from 'next/link' -import { ReactNode, useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { ContractCard } from 'web/components/contract/contract-card' import { DateTimeTooltip } from 'web/components/datetime-tooltip' import { Col } from 'web/components/layout/col' @@ -33,6 +28,7 @@ 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' dayjs.extend(utc) dayjs.extend(timezone) @@ -254,58 +250,3 @@ const NaturalImage = (props: ImageProps) => { /> ) } - -function Carousel(props: { children: ReactNode; className?: string }) { - const { children, className } = props - - const ref = useRef<HTMLDivElement>(null) - - const th = (f: () => any) => throttle(f, 500, { trailing: false }) - const scrollLeft = th(() => - ref.current?.scrollBy({ left: -ref.current.clientWidth }) - ) - const scrollRight = th(() => - ref.current?.scrollBy({ left: ref.current.clientWidth }) - ) - - const [atFront, setAtFront] = useState(true) - const [atBack, setAtBack] = useState(false) - const onScroll = throttle(() => { - if (ref.current) { - const { scrollLeft, clientWidth, scrollWidth } = ref.current - setAtFront(scrollLeft < 80) - setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80) - } - }, 500) - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(onScroll, []) - - return ( - <div className={clsx('relative', className)}> - <Row - className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth" - ref={ref} - onScroll={onScroll} - > - {children} - </Row> - {!atFront && ( - <div - className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" - onMouseDown={scrollLeft} - > - <ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> - </div> - )} - {!atBack && ( - <div - className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30" - onMouseDown={scrollRight} - > - <ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" /> - </div> - )} - </div> - ) -} From 3e1e84ee5ea400df94f73c1aea4c3eb61d7cbb8f Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 30 Aug 2022 17:14:22 -0500 Subject: [PATCH 158/279] Experimental Home: Add links. Single layer carousel for < 6 cards --- web/pages/experimental/home.tsx | 81 ++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx index a2d47609..887cb4c6 100644 --- a/web/pages/experimental/home.tsx +++ b/web/pages/experimental/home.tsx @@ -14,16 +14,16 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { GetServerSideProps } from 'next' import { Sort } from 'web/components/contract-search' import { Button } from 'web/components/button' -import { Spacer } from 'web/components/layout/spacer' import { useMemberGroups } from 'web/hooks/use-group' import { Group } from 'common/group' import { Carousel } from 'web/components/carousel' import { LoadingIndicator } from 'web/components/loading-indicator' import { ContractCard } from 'web/components/contract/contract-card' import { range } from 'lodash' -import { Subtitle } from 'web/components/subtitle' import { Contract } from 'common/contract' import { ShowTime } from 'web/components/contract/contract-details' +import { GroupLinkItem } from '../groups' +import { SiteLink } from 'web/components/site-link' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -44,7 +44,7 @@ const Home = (props: { auth: { user: User } | null }) => { return ( <Page> - <Col className="mx-4 mt-4 gap-2 sm:mx-10 xl:w-[125%]"> + <Col className="mx-4 mt-4 gap-4 sm:mx-10 xl:w-[125%]"> <SearchSection label="Trending" sort="score" user={user} /> <SearchSection label="Newest" sort="newest" user={user} /> <SearchSection label="Closing soon" sort="close-date" user={user} /> @@ -72,10 +72,13 @@ function SearchSection(props: { sort: Sort }) { const { label, user, sort } = props + const href = `/home?s=${sort}` return ( <Col> - <Subtitle className="mx-2 !mt-2 !text-gray-800 sm:mx-0" text={label} /> + <SiteLink className="mb-2 text-xl" href={href}> + {label} + </SiteLink> <ContractSearch user={user} defaultSort={sort} @@ -85,7 +88,12 @@ function SearchSection(props: { contracts ? ( <DoubleCarousel contracts={contracts} - seeMoreUrl={`/home?s=${sort}`} + seeMoreUrl={href} + showTime={ + sort === 'close-date' || sort === 'resolve-date' + ? sort + : undefined + } /> ) : ( <LoadingIndicator /> @@ -100,9 +108,8 @@ function GroupSection(props: { group: Group; user: User | null }) { const { group, user } = props return ( - <Col className=""> - <Subtitle className="mx-2 !text-gray-800 sm:mx-0" text={group.name} /> - <Spacer h={2} /> + <Col> + <GroupLinkItem className="mb-2 text-xl" group={group} /> <ContractSearch user={user} defaultSort={'score'} @@ -133,30 +140,40 @@ function DoubleCarousel(props: { return ( <Carousel className="-mx-4 mt-2 sm:-mx-10"> <div className="shrink-0 sm:w-6" /> - {contracts && - range(0, Math.floor(contracts.length / 2)).map((col) => { - const i = col * 2 - return ( - <Col> - <ContractCard - key={contracts[i].id} - contract={contracts[i]} - className="mb-2 max-h-[200px] w-96 shrink-0" - questionClass="line-clamp-3" - trackingPostfix=" tournament" - showTime={showTime} - /> - <ContractCard - key={contracts[i + 1].id} - contract={contracts[i + 1]} - className="mb-2 max-h-[200px] w-96 shrink-0" - questionClass="line-clamp-3" - trackingPostfix=" tournament" - showTime={showTime} - /> - </Col> - ) - })} + {contracts.length >= 6 + ? range(0, Math.floor(contracts.length / 2)).map((col) => { + const i = col * 2 + return ( + <Col> + <ContractCard + key={contracts[i].id} + contract={contracts[i]} + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + <ContractCard + key={contracts[i + 1].id} + contract={contracts[i + 1]} + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + </Col> + ) + }) + : contracts.map((c) => ( + <ContractCard + key={c.id} + contract={c} + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + ))} <Button className="self-center whitespace-nowrap" color="blue" From aad5f6528bb68c68ff6d24e43026d11b501450ba Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 30 Aug 2022 17:13:25 -0600 Subject: [PATCH 159/279] new market view (#819) * Show old details on lg, don't unfill heart * Hide tip market if creator * Small ui tweaks * Remove contract. calls * Update high-medium-low * Remove unused bets prop * Show uniques * Remove unused bets prop --- common/like.ts | 2 +- functions/src/index.ts | 3 +- functions/src/on-delete-like.ts | 32 ---- .../{on-create-like.ts => on-update-like.ts} | 42 ++++- web/components/contract/contract-details.tsx | 145 +++++++++++++----- .../contract/contract-info-dialog.tsx | 21 ++- web/components/contract/contract-overview.tsx | 43 +++--- ...row.tsx => extra-contract-actions-row.tsx} | 51 +++--- .../contract/like-market-button.tsx | 24 ++- web/components/contract/share-modal.tsx | 38 ++++- web/components/follow-market-button.tsx | 10 +- web/components/user-link.tsx | 2 +- web/pages/embed/[username]/[contractSlug].tsx | 8 +- 13 files changed, 245 insertions(+), 176 deletions(-) delete mode 100644 functions/src/on-delete-like.ts rename functions/src/{on-create-like.ts => on-update-like.ts} (61%) rename web/components/contract/{share-row.tsx => extra-contract-actions-row.tsx} (51%) diff --git a/common/like.ts b/common/like.ts index 85140e02..38b25dad 100644 --- a/common/like.ts +++ b/common/like.ts @@ -3,6 +3,6 @@ export type Like = { userId: string type: 'contract' createdTime: number - tipTxnId?: string + tipTxnId?: string // only holds most recent tip txn id } export const LIKE_TIP_AMOUNT = 5 diff --git a/functions/src/index.ts b/functions/src/index.ts index 6ede39a0..2ec7f3ce 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,8 +31,7 @@ export * from './weekly-markets-emails' export * from './reset-betting-streaks' export * from './reset-weekly-emails-flag' export * from './on-update-contract-follow' -export * from './on-create-like' -export * from './on-delete-like' +export * from './on-update-like' // v2 export * from './health' diff --git a/functions/src/on-delete-like.ts b/functions/src/on-delete-like.ts deleted file mode 100644 index 151614b0..00000000 --- a/functions/src/on-delete-like.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { Like } from '../../common/like' -import { getContract, log } from './utils' -import { uniq } from 'lodash' - -const firestore = admin.firestore() - -export const onDeleteLike = functions.firestore - .document('users/{userId}/likes/{likeId}') - .onDelete(async (change) => { - const like = change.data() as Like - if (like.type === 'contract') { - await removeContractLike(like) - } - }) - -const removeContractLike = async (like: Like) => { - const contract = await getContract(like.id) - if (!contract) { - log('Could not find contract') - return - } - const likedByUserIds = uniq(contract.likedByUserIds ?? []) - const newLikedByUserIds = likedByUserIds.filter( - (userId) => userId !== like.userId - ) - await firestore.collection('contracts').doc(like.id).update({ - likedByUserIds: newLikedByUserIds, - likedByUserCount: newLikedByUserIds.length, - }) -} diff --git a/functions/src/on-create-like.ts b/functions/src/on-update-like.ts similarity index 61% rename from functions/src/on-create-like.ts rename to functions/src/on-update-like.ts index 8c5885b0..7633c395 100644 --- a/functions/src/on-create-like.ts +++ b/functions/src/on-update-like.ts @@ -19,14 +19,36 @@ export const onCreateLike = functions.firestore } }) +export const onUpdateLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onUpdate(async (change, context) => { + const like = change.after.data() as Like + const prevLike = change.before.data() as Like + const { eventId } = context + if (like.type === 'contract' && like.tipTxnId !== prevLike.tipTxnId) { + await handleCreateLikeNotification(like, eventId) + await updateContractLikes(like) + } + }) + +export const onDeleteLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onDelete(async (change) => { + const like = change.data() as Like + if (like.type === 'contract') { + await removeContractLike(like) + } + }) + const updateContractLikes = async (like: Like) => { const contract = await getContract(like.id) if (!contract) { log('Could not find contract') return } - const likedByUserIds = uniq(contract.likedByUserIds ?? []) - likedByUserIds.push(like.userId) + const likedByUserIds = uniq( + (contract.likedByUserIds ?? []).concat(like.userId) + ) await firestore .collection('contracts') .doc(like.id) @@ -69,3 +91,19 @@ const handleCreateLikeNotification = async (like: Like, eventId: string) => { tipTxnData ) } + +const removeContractLike = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + const newLikedByUserIds = likedByUserIds.filter( + (userId) => userId !== like.userId + ) + await firestore.collection('contracts').doc(like.id).update({ + likedByUserIds: newLikedByUserIds, + likedByUserCount: newLikedByUserIds.length, + }) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 72ecbb1f..2e76531b 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -18,7 +18,6 @@ import { fromNow } from 'web/lib/util/time' import { Avatar } from '../avatar' import { useState } from 'react' import { ContractInfoDialog } from './contract-info-dialog' -import { Bet } from 'common/bet' import NewContractBadge from '../new-contract-badge' import { UserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' @@ -35,6 +34,8 @@ import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' +import { Tooltip } from 'web/components/tooltip' +import { useWindowSize } from 'web/hooks/use-window-size' export type ShowTime = 'resolve-date' | 'close-date' @@ -78,7 +79,7 @@ export function MiscDetails(props: { ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( <FeaturedContractBadge /> ) : volume > 0 || !isNew ? ( - <Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row> + <Row className={'shrink-0'}>{formatMoney(volume)} bet</Row> ) : ( <NewContractBadge /> )} @@ -101,7 +102,7 @@ export function AvatarDetails(props: { short?: boolean }) { const { contract, short, className } = props - const { creatorName, creatorUsername } = contract + const { creatorName, creatorUsername, creatorAvatarUrl } = contract return ( <Row @@ -109,7 +110,7 @@ export function AvatarDetails(props: { > <Avatar username={creatorUsername} - avatarUrl={contract.creatorAvatarUrl} + avatarUrl={creatorAvatarUrl} size={6} /> <UserLink name={creatorName} username={creatorUsername} short={short} /> @@ -138,20 +139,28 @@ export function AbbrContractDetails(props: { export function ContractDetails(props: { contract: Contract - bets: Bet[] user: User | null | undefined isCreator?: boolean disabled?: boolean }) { - const { contract, bets, isCreator, disabled } = props - const { closeTime, creatorName, creatorUsername, creatorId, groupLinks } = - contract + const { contract, isCreator, disabled } = props + const { + closeTime, + creatorName, + creatorUsername, + creatorId, + groupLinks, + creatorAvatarUrl, + resolutionTime, + } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) const groupToDisplay = groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null const user = useUser() const [open, setOpen] = useState(false) + const { width } = useWindowSize() + const isMobile = (width ?? 0) < 600 const groupInfo = ( <Row> @@ -167,7 +176,7 @@ export function ContractDetails(props: { <Row className="items-center gap-2"> <Avatar username={creatorUsername} - avatarUrl={contract.creatorAvatarUrl} + avatarUrl={creatorAvatarUrl} noLink={disabled} size={6} /> @@ -178,6 +187,7 @@ export function ContractDetails(props: { className="whitespace-nowrap" name={creatorName} username={creatorUsername} + short={isMobile} /> )} {!disabled && <UserFollowButton userId={creatorId} small />} @@ -228,14 +238,11 @@ export function ContractDetails(props: { </Modal> {(!!closeTime || !!resolvedDate) && ( - <Row className="items-center gap-1"> - {resolvedDate && contract.resolutionTime ? ( + <Row className="hidden items-center gap-1 md:inline-flex"> + {resolvedDate && resolutionTime ? ( <> <ClockIcon className="h-5 w-5" /> - <DateTimeTooltip - text="Market resolved:" - time={contract.resolutionTime} - > + <DateTimeTooltip text="Market resolved:" time={resolutionTime}> {resolvedDate} </DateTimeTooltip> </> @@ -255,17 +262,84 @@ export function ContractDetails(props: { )} {user && ( <> - <Row className="items-center gap-1"> + <Row className="hidden items-center gap-1 md:inline-flex"> <DatabaseIcon className="h-5 w-5" /> <div className="whitespace-nowrap">{volumeLabel}</div> </Row> - {!disabled && <ContractInfoDialog contract={contract} bets={bets} />} + {!disabled && ( + <ContractInfoDialog + contract={contract} + className={'hidden md:inline-flex'} + /> + )} </> )} </Row> ) } +export function ExtraMobileContractDetails(props: { + contract: Contract + user: User | null | undefined + forceShowVolume?: boolean +}) { + const { contract, user, forceShowVolume } = props + const { volume, resolutionTime, closeTime, creatorId, uniqueBettorCount } = + contract + const uniqueBettors = uniqueBettorCount ?? 0 + const { resolvedDate } = contractMetrics(contract) + const volumeTranslation = + volume > 800 || uniqueBettors > 20 + ? 'High' + : volume > 300 || uniqueBettors > 10 + ? 'Medium' + : 'Low' + + return ( + <Row + className={clsx( + 'items-center justify-around md:hidden', + user ? 'w-full' : '' + )} + > + {resolvedDate && resolutionTime ? ( + <Col className={'items-center text-sm'}> + <Row className={'text-gray-500'}> + <DateTimeTooltip text="Market resolved:" time={resolutionTime}> + {resolvedDate} + </DateTimeTooltip> + </Row> + <Row className={'text-gray-400'}>Ended</Row> + </Col> + ) : ( + !resolvedDate && + closeTime && ( + <Col className={'items-center text-sm text-gray-500'}> + <EditableCloseDate + closeTime={closeTime} + contract={contract} + isCreator={creatorId === user?.id} + /> + <Row className={'text-gray-400'}>Ends</Row> + </Col> + ) + )} + {(user || forceShowVolume) && ( + <Col className={'items-center text-sm text-gray-500'}> + <Tooltip + text={`${formatMoney( + volume + )} bet - ${uniqueBettors} unique bettors`} + > + {volumeTranslation} + </Tooltip> + <Row className={'text-gray-400'}>Activity</Row> + </Col> + )} + </Row> + ) +} + function EditableCloseDate(props: { closeTime: number contract: Contract @@ -318,10 +392,10 @@ function EditableCloseDate(props: { return ( <> {isEditingCloseTime ? ( - <Row className="mr-1 items-start"> + <Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1"> <input type="date" - className="input input-bordered" + className="input input-bordered shrink-0" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)} min={Date.now()} @@ -329,39 +403,32 @@ function EditableCloseDate(props: { /> <input type="time" - className="input input-bordered ml-2" + className="input input-bordered shrink-0" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseHoursMinutes(e.target.value)} min="00:00" value={closeHoursMinutes} /> + <Button size={'xs'} color={'blue'} onClick={onSave}> + Done + </Button> </Row> ) : ( <DateTimeTooltip text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'} time={closeTime} > - {isSameYear - ? dayJsCloseTime.format('MMM D') - : dayJsCloseTime.format('MMM D, YYYY')} - {isSameDay && <> ({fromNow(closeTime)})</>} + <span + className={isCreator ? 'cursor-pointer' : ''} + onClick={() => isCreator && setIsEditingCloseTime(true)} + > + {isSameYear + ? dayJsCloseTime.format('MMM D') + : dayJsCloseTime.format('MMM D, YYYY')} + {isSameDay && <> ({fromNow(closeTime)})</>} + </span> </DateTimeTooltip> )} - - {isCreator && - (isEditingCloseTime ? ( - <button className="btn btn-xs" onClick={onSave}> - Done - </button> - ) : ( - <Button - size={'xs'} - color={'gray-white'} - onClick={() => setIsEditingCloseTime(true)} - > - <PencilIcon className="!container mr-0.5 mb-0.5 inline h-4 w-4" /> - </Button> - ))} </> ) } diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index f418db06..aaa3cad6 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -1,9 +1,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline' import clsx from 'clsx' import dayjs from 'dayjs' -import { uniqBy } from 'lodash' import { useState } from 'react' -import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' @@ -22,8 +20,11 @@ import ShortToggle from '../widgets/short-toggle' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' -export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { - const { contract, bets } = props +export function ContractInfoDialog(props: { + contract: Contract + className?: string +}) { + const { contract, className } = props const [open, setOpen] = useState(false) const [featured, setFeatured] = useState( @@ -37,11 +38,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = contract - const tradersCount = uniqBy( - bets.filter((bet) => !bet.isAnte), - 'userId' - ).length - + const bettorsCount = contract.uniqueBettorCount ?? 'Unknown' const typeDisplay = outcomeType === 'BINARY' ? 'YES / NO' @@ -69,7 +66,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { return ( <> <button - className={contractDetailsButtonClassName} + className={clsx(contractDetailsButtonClassName, className)} onClick={() => setOpen(true)} > <DotsHorizontalIcon @@ -136,8 +133,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { </tr> */} <tr> - <td>Traders</td> - <td>{tradersCount}</td> + <td>Bettors</td> + <td>{bettorsCount}</td> </tr> <tr> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 37639d79..bf62f77e 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -18,10 +18,9 @@ import BetButton from '../bet-button' import { AnswersGraph } from '../answers/answers-graph' import { Contract, CPMMBinaryContract } from 'common/contract' import { ContractDescription } from './contract-description' -import { ContractDetails } from './contract-details' +import { ContractDetails, ExtraMobileContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' -import { ShareRow } from './share-row' -import { LikeMarketButton } from 'web/components/contract/like-market-button' +import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' export const ContractOverview = (props: { contract: Contract @@ -40,17 +39,15 @@ export const ContractOverview = (props: { return ( <Col className={clsx('mb-6', className)}> <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails + contract={contract} + user={user} + isCreator={isCreator} + /> <Row className="justify-between gap-4"> <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> - {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && - !resolution && ( - <div className={'sm:hidden'}> - <LikeMarketButton contract={contract} user={user} /> - </div> - )} <Row className={'hidden gap-3 xl:flex'}> {isBinary && ( <BinaryResolutionOrChance @@ -79,11 +76,9 @@ export const ContractOverview = (props: { {isBinary ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + <ExtraMobileContractDetails contract={contract} user={user} /> {tradingAllowed(contract) && ( <Row> - <div className={'sm:hidden'}> - <LikeMarketButton contract={contract} user={user} /> - </div> <Col> <BetButton contract={contract as CPMMBinaryContract} /> {!user && ( @@ -98,11 +93,9 @@ export const ContractOverview = (props: { ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> + <ExtraMobileContractDetails contract={contract} user={user} /> {tradingAllowed(contract) && ( <Row> - <div className={'sm:hidden'}> - <LikeMarketButton contract={contract} user={user} /> - </div> <Col> <BetButton contract={contract} /> {!user && ( @@ -130,13 +123,6 @@ export const ContractOverview = (props: { <NumericResolutionOrExpectation contract={contract} /> </Row> )} - - <ContractDetails - contract={contract} - bets={bets} - isCreator={isCreator} - user={user} - /> </Col> <div className={'my-1 md:my-2'}></div> {(isBinary || isPseudoNumeric) && ( @@ -144,10 +130,17 @@ export const ContractOverview = (props: { )}{' '} {(outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && ( - <AnswersGraph contract={contract} bets={bets} /> + <Col className={'mb-1 gap-y-2'}> + <AnswersGraph contract={contract} bets={bets} /> + <ExtraMobileContractDetails + contract={contract} + user={user} + forceShowVolume={true} + /> + </Col> )} {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} - <ShareRow user={user} contract={contract} /> + <ExtraContractActionsRow user={user} contract={contract} /> <ContractDescription className="px-2" contract={contract} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/extra-contract-actions-row.tsx similarity index 51% rename from web/components/contract/share-row.tsx rename to web/components/contract/extra-contract-actions-row.tsx index 03bd99e6..4f362d84 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -3,31 +3,25 @@ import { ShareIcon } from '@heroicons/react/outline' import { Row } from '../layout/row' import { Contract } from 'web/lib/firebase/contracts' -import { useState } from 'react' +import React, { useState } from 'react' import { Button } from 'web/components/button' -import { CreateChallengeModal } from '../challenges/create-challenge-modal' import { User } from 'common/user' -import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' -import { withTracking } from 'web/lib/service/analytics' import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' +import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' +import { Col } from 'web/components/layout/col' -export function ShareRow(props: { +export function ExtraContractActionsRow(props: { contract: Contract user: User | undefined | null }) { const { user, contract } = props - const { outcomeType, resolution } = contract - const showChallenge = - user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED - - const [isOpen, setIsOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false) return ( - <Row className="mt-0.5 sm:mt-2"> + <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> <Button size="lg" color="gray-white" @@ -36,8 +30,14 @@ export function ShareRow(props: { setShareOpen(true) }} > - <ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" /> - Share + <Col className={'items-center sm:flex-row'}> + <ShareIcon + className={clsx('h-[24px] w-5 sm:mr-2')} + aria-hidden="true" + /> + <span>Share</span> + </Col> + <ShareModal isOpen={isShareOpen} setOpen={setShareOpen} @@ -46,28 +46,13 @@ export function ShareRow(props: { /> </Button> - {showChallenge && ( - <Button - size="lg" - color="gray-white" - onClick={withTracking( - () => setIsOpen(true), - 'click challenge button' - )} - > - ⚔️ Challenge - <CreateChallengeModal - isOpen={isOpen} - setOpen={setIsOpen} - user={user} - contract={contract} - /> - </Button> - )} <FollowMarketButton contract={contract} user={user} /> - <div className={'hidden sm:block'}> + {user?.id !== contract.creatorId && ( <LikeMarketButton contract={contract} user={user} /> - </div> + )} + <Col className={'justify-center md:hidden'}> + <ContractInfoDialog contract={contract} /> + </Col> </Row> ) } diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index f4fed287..0fed0518 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -6,10 +6,11 @@ import { User } from 'common/user' import { useUserLikes } from 'web/hooks/use-likes' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' -import { likeContract, unLikeContract } from 'web/lib/firebase/likes' +import { likeContract } from 'web/lib/firebase/likes' import { LIKE_TIP_AMOUNT } from 'common/like' import clsx from 'clsx' -import { Row } from 'web/components/layout/row' +import { Col } from 'web/components/layout/col' +import { firebaseLogin } from 'web/lib/firebase/users' export function LikeMarketButton(props: { contract: Contract @@ -18,16 +19,12 @@ export function LikeMarketButton(props: { const { contract, user } = props const likes = useUserLikes(user?.id) - const likedContractIds = likes + const userLikedContractIds = likes ?.filter((l) => l.type === 'contract') .map((l) => l.id) - if (!user) return <div /> const onLike = async () => { - if (likedContractIds?.includes(contract.id)) { - await unLikeContract(user.id, contract.id) - return - } + if (!user) return firebaseLogin() await likeContract(user, contract) toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) } @@ -39,18 +36,19 @@ export function LikeMarketButton(props: { color={'gray-white'} onClick={onLike} > - <Row className={'gap-0 sm:gap-2'}> + <Col className={'sm:flex-row sm:gap-x-2'}> <HeartIcon className={clsx( 'h-6 w-6', - likedContractIds?.includes(contract.id) || - (!likes && contract.likedByUserIds?.includes(user.id)) + user && + (userLikedContractIds?.includes(contract.id) || + (!likes && contract.likedByUserIds?.includes(user.id))) ? 'fill-red-500 text-red-500' : '' )} /> - <span className={'hidden sm:block'}>Tip</span> - </Row> + Tip + </Col> </Button> ) } diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index 2c74a5a4..5bae101d 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -12,12 +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 @@ -26,8 +29,13 @@ export function ShareModal(props: { setOpen: (open: boolean) => void }) { const { contract, user, isOpen, setOpen } = props + const { outcomeType, resolution } = contract + 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 @@ -46,7 +54,6 @@ export function ShareModal(props: { </SiteLink>{' '} if a new user signs up using the link! </p> - <Button size="2xl" color="gradient" @@ -61,8 +68,31 @@ export function ShareModal(props: { > {linkIcon} Copy link </Button> - - <Row className="z-0 justify-start gap-4 self-center"> + {showChallenge && ( + <Button + size="lg" + color="gray-white" + className={'mb-2 flex max-w-xs self-center'} + onClick={withTracking( + () => setOpenCreateChallengeModal(true), + 'click challenge button' + )} + > + <span>⚔️ Challenge a friend</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" tweetText={getTweetText(contract, shareUrl)} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 45d26ce4..332b044a 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -13,7 +13,7 @@ import { firebaseLogin, updateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' import { FollowMarketModal } from 'web/components/contract/follow-market-modal' import { useState } from 'react' -import { Row } from 'web/components/layout/row' +import { Col } from 'web/components/layout/col' export const FollowMarketButton = (props: { contract: Contract @@ -55,15 +55,15 @@ export const FollowMarketButton = (props: { }} > {followers?.includes(user?.id ?? 'nope') ? ( - <Row className={'gap-2'}> + <Col className={'items-center gap-x-2 sm:flex-row'}> <EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> Unwatch - </Row> + </Col> ) : ( - <Row className={'gap-2'}> + <Col className={'items-center gap-x-2 sm:flex-row'}> <EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> Watch - </Row> + </Col> )} <FollowMarketModal open={open} diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index 5eeab1c4..796bb367 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -11,7 +11,7 @@ function shortenName(name: string) { const firstName = name.split(' ')[0] const maxLength = 10 const shortName = - firstName.length >= 3 + firstName.length >= 4 ? firstName.length < maxLength ? firstName : firstName.substring(0, maxLength - 3) + '...' diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index afec84bb..8044ec6e 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -105,13 +105,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { <Spacer h={3} /> <Row className="items-center justify-between gap-4 px-2"> - <ContractDetails - contract={contract} - bets={bets} - isCreator={false} - user={null} - disabled - /> + <ContractDetails contract={contract} user={null} disabled /> {(isBinary || isPseudoNumeric) && tradingAllowed(contract) && From c202c5de68a72e8b072f3957379e90be1faf572f Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 30 Aug 2022 16:28:49 -0700 Subject: [PATCH 160/279] clarify closed/open group copy --- web/pages/group/[...slugs]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index bf29cc8b..5c22dbb6 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -357,7 +357,7 @@ function GroupOverview(props: { /> ) : ( <span className={'text-gray-700'}> - {anyoneCanJoin ? 'Open' : 'Closed'} + {anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'} </span> )} </Row> From ec90b041ee3b112414f0b0ea80c42d9cfcc9e1f7 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 30 Aug 2022 20:54:29 -0500 Subject: [PATCH 161/279] upgrade firebase, nextjs versions --- web/components/bet-panel.tsx | 12 +- web/package.json | 4 +- yarn.lock | 757 +++++++++++++++++------------------ 3 files changed, 382 insertions(+), 391 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index f15a7445..26a01ea3 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -179,12 +179,12 @@ function BuyPanel(props: { const [inputRef, focusAmountInput] = useFocus() - useEffect(() => { - if (selected) { - if (isIOS()) window.scrollTo(0, window.scrollY + 200) - focusAmountInput() - } - }, [selected, focusAmountInput]) + // useEffect(() => { + // if (selected) { + // if (isIOS()) window.scrollTo(0, window.scrollY + 200) + // focusAmountInput() + // } + // }, [selected, focusAmountInput]) function onBetChoice(choice: 'YES' | 'NO') { setOutcome(choice) diff --git a/web/package.json b/web/package.json index 847c7ef5..36001355 100644 --- a/web/package.json +++ b/web/package.json @@ -41,12 +41,12 @@ "cors": "2.8.5", "daisyui": "1.16.4", "dayjs": "1.10.7", - "firebase": "9.6.0", + "firebase": "9.9.3", "gridjs": "5.0.2", "gridjs-react": "5.0.2", "lodash": "4.17.21", "nanoid": "^3.3.4", - "next": "12.2.2", + "next": "12.2.5", "node-fetch": "3.2.4", "react": "17.0.2", "react-confetti": "6.0.1", diff --git a/yarn.lock b/yarn.lock index 07755708..0381fd46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1759,15 +1759,15 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@firebase/analytics-compat@0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.1.5.tgz#9fd587b1b6fa283354428a0f96a19db2389e7da4" - integrity sha512-5cfr0uWwlhoHQYAr6UtQCHwnGjs/3J/bWrfA3INNtzaN4/tTTLTD02iobbccRcM7dM5TR0sZFWS5orfAU3OBFg== +"@firebase/analytics-compat@0.1.13": + version "0.1.13" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.1.13.tgz#61e1d6f9e4d033c3ed9943d91530eb3e0f382f92" + integrity sha512-QC1DH/Dwc8fBihn0H+jocBWyE17GF1fOCpCrpAiQ2u16F/NqsVDVG4LjIqdhq963DXaXneNY7oDwa25Up682AA== dependencies: - "@firebase/analytics" "0.7.4" + "@firebase/analytics" "0.8.0" "@firebase/analytics-types" "0.7.0" - "@firebase/component" "0.5.9" - "@firebase/util" "1.4.2" + "@firebase/component" "0.5.17" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/analytics-types@0.7.0": @@ -1775,26 +1775,27 @@ resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.7.0.tgz#91960e7c87ce8bf18cf8dd9e55ccbf5dc3989b5d" integrity sha512-DNE2Waiwy5+zZnCfintkDtBfaW6MjIG883474v6Z0K1XZIvl76cLND4iv0YUb48leyF+PJK1KO2XrgHb/KpmhQ== -"@firebase/analytics@0.7.4": - version "0.7.4" - resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.7.4.tgz#33b3d6a34736e1a726652e48b6bd39163e6561c2" - integrity sha512-AU3XMwHW7SFGCNeUKKNW2wXGTdmS164ackt/Epu2bDXCT1OcauPE1AVd+ofULSIDCaDUAQVmvw3JrobgogEU7Q== +"@firebase/analytics@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.8.0.tgz#b5d595082f57d33842b1fd9025d88f83065e87fe" + integrity sha512-wkcwainNm8Cu2xkJpDSHfhBSdDJn86Q1TZNmLWc67VrhZUHXIKXxIqb65/tNUVE+I8+sFiDDNwA+9R3MqTQTaA== dependencies: - "@firebase/component" "0.5.9" - "@firebase/installations" "0.5.4" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/component" "0.5.17" + "@firebase/installations" "0.5.12" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" tslib "^2.1.0" -"@firebase/app-check-compat@0.2.2": - version "0.2.2" - resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.2.2.tgz#7d6c04464a78cbc6a717cb4f33871e2f980cdb02" - integrity sha512-nX2Ou8Rwo+TMMNDecQOGH78kFw6sORLrsGyu0eC95M853JjisVxTngN1TU/RL5h83ElJ0HhNlz6C3FYAuGNqqA== +"@firebase/app-check-compat@0.2.12": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.2.12.tgz#e30b2395e3d30f8cfcf3554fc87875f82c1aa086" + integrity sha512-GFppNLlUyMN9Iq31ME/+GkjRVKlc+MeanzUKQ9UaR73ZsYH3oX3Ja+xjoYgixaVJDDG+ofBYR7ZXTkkQdSR/pw== dependencies: - "@firebase/app-check" "0.5.2" - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/app-check" "0.5.12" + "@firebase/app-check-types" "0.4.0" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/app-check-interop-types@0.1.0": @@ -1802,25 +1803,30 @@ resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.1.0.tgz#83afd9d41f99166c2bdb2d824e5032e9edd8fe53" integrity sha512-uZfn9s4uuRsaX5Lwx+gFP3B6YsyOKUE+Rqa6z9ojT4VSRAsZFko9FRn6OxQUA1z5t5d08fY4pf+/+Dkd5wbdbA== -"@firebase/app-check@0.5.2": - version "0.5.2" - resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.5.2.tgz#5166aeed767efb8e5f0c719b83439e58abbee0fd" - integrity sha512-DJrvxcn5QPO5dU735GA9kYpf+GwmCmnd/oQdWVExrRG+yjaLnP0rSJ2HKQ4bZKGo8qig3P7fwQpdMOgP2BXFjQ== +"@firebase/app-check-types@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.4.0.tgz#7007a9d1d720db20bcf466fe6785c96feaa0a82d" + integrity sha512-SsWafqMABIOu7zLgWbmwvHGOeQQVQlwm42kwwubsmfLmL4Sf5uGpBfDhQ0CAkpi7bkJ/NwNFKafNDL9prRNP0Q== + +"@firebase/app-check@0.5.12": + version "0.5.12" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.5.12.tgz#82f305cc01bfe4d32c35e425941b2eca2ce9f089" + integrity sha512-l+MmvupSGT/F+I5ei7XjhEfpoL4hLVJr0vUwcG5NEf2hAkQnySli9fnbl9fZu1BJaQ2kthrMmtg1gcbcM9BUCQ== dependencies: - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" tslib "^2.1.0" -"@firebase/app-compat@0.1.11": - version "0.1.11" - resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.1.11.tgz#22705fa65f2408ce6e2b43747b7bdcf1bdfcea7a" - integrity sha512-I6L6hHoAxylFg39w1I0w7zJ4cDq41FdUHUPhhNzDcPUJMJUQNzZXXBxUvDCj8ChFXDjVb/YTbLKzitqQXvkWBg== +"@firebase/app-compat@0.1.32": + version "0.1.32" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.1.32.tgz#e1e391c78ce176ef26c6a236b423b91b87ffc632" + integrity sha512-dChnJsnHxih0MYQxCWBPAruqK2M4ba/t+DvKu8IcRpd4FkcUQ8FO19Z963nCdXyu2T6cxPcwCopKWaWlymBVVA== dependencies: - "@firebase/app" "0.7.10" - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/app" "0.7.31" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/app-types@0.6.3": @@ -1833,27 +1839,28 @@ resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.7.0.tgz#c9e16d1b8bed1a991840b8d2a725fb58d0b5899f" integrity sha512-6fbHQwDv2jp/v6bXhBw2eSRbNBpxHcd1NBF864UksSMVIqIyri9qpJB1Mn6sGZE+bnDsSQBC5j2TbMxYsJQkQg== -"@firebase/app@0.7.10": - version "0.7.10" - resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.7.10.tgz#1771f47dc704402219d1fb6a574db6989f533fb0" - integrity sha512-u3dawOIj5EOK8OOJy0QypS51pdR2tJMD/DnrQy0U2vau3nLDZalXmcknA23HPX67pIbjg5AkUv9RhulM4qUK7g== +"@firebase/app@0.7.31": + version "0.7.31" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.7.31.tgz#5de539070acdb0661dd0250228c1ef44493d880f" + integrity sha512-pqCkY2wC5pRBVH1oYliD9E0aSW6qisuMy7meaCtGzwaVcE8AFMhW9xhxHuBMpX1291+2iimUZWnCxSL9DaUUGA== dependencies: - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" + idb "7.0.1" tslib "^2.1.0" -"@firebase/auth-compat@0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.2.3.tgz#74cb13c01d362eacb8422bbcd184171f781559b9" - integrity sha512-qXdibKq44Lf22hy9YQaaMsAFMOiTA95Z9NjZJbrY8P0zXZUjFhwpx41Mett8+3X/uv/mXa6KuouRt2QdpsqU/g== +"@firebase/auth-compat@0.2.18": + version "0.2.18" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.2.18.tgz#c7bb254fbb23447069f81abb15f96e91de40b285" + integrity sha512-Fw2PJS0G/tGrfyEBcYJQ42sfy5+sANrK5xd7tuzgV7zLFW5rYkHUIZngXjuOBwLOcfO2ixa/FavfeJle3oJ38Q== dependencies: - "@firebase/auth" "0.19.3" + "@firebase/auth" "0.20.5" "@firebase/auth-types" "0.11.0" - "@firebase/component" "0.5.9" - "@firebase/util" "1.4.2" - node-fetch "2.6.5" - selenium-webdriver "^4.0.0-beta.2" + "@firebase/component" "0.5.17" + "@firebase/util" "1.6.3" + node-fetch "2.6.7" + selenium-webdriver "4.1.2" tslib "^2.1.0" "@firebase/auth-interop-types@0.1.6": @@ -1866,16 +1873,16 @@ resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.11.0.tgz#b9c73c60ca07945b3bbd7a097633e5f78fa9e886" integrity sha512-q7Bt6cx+ySj9elQHTsKulwk3+qDezhzRBFC9zlQ1BjgMueUOnGMcvqmU0zuKlQ4RhLSH7MNAdBV2znVaoN3Vxw== -"@firebase/auth@0.19.3": - version "0.19.3" - resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-0.19.3.tgz#cb22954b9cf46ed8a537163b13aaddfbd3f7ee11" - integrity sha512-asOJkmzBh38DgZ5fBt7cv8dNyU3r7kRVoXi9f1eCpQp/n+NagaiUM+YKXq0snjbchFJu7qPBiwrIg/xZinY4kg== +"@firebase/auth@0.20.5": + version "0.20.5" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-0.20.5.tgz#a2e6c6b593d8f9cf8276a7d1f8ab5b055d65cc50" + integrity sha512-SbKj7PCAuL0lXEToUOoprc1im2Lr/bzOePXyPC7WWqVgdVBt0qovbfejlzKYwJLHUAPg9UW1y3XYe3IlbXr77w== dependencies: - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" - node-fetch "2.6.5" - selenium-webdriver "4.0.0-rc-1" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" + node-fetch "2.6.7" + selenium-webdriver "4.1.2" tslib "^2.1.0" "@firebase/component@0.5.13": @@ -1886,24 +1893,24 @@ "@firebase/util" "1.5.2" tslib "^2.1.0" -"@firebase/component@0.5.9": - version "0.5.9" - resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.5.9.tgz#a859f655bd6e5b691bc5596fe43a91b12a443052" - integrity sha512-oLCY3x9WbM5rn06qmUvbtJuPj4dIw/C9T4Th52IiHF5tiCRC5k6YthvhfUVcTwfoUhK0fOgtwuKJKA/LpCPjgA== +"@firebase/component@0.5.17": + version "0.5.17" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.5.17.tgz#89291f378714df05d44430c524708669380d8ea6" + integrity sha512-mTM5CBSIlmI+i76qU4+DhuExnWtzcPS3cVgObA3VAjliPPr3GrUlTaaa8KBGfxsD27juQxMsYA0TvCR5X+GQ3Q== dependencies: - "@firebase/util" "1.4.2" + "@firebase/util" "1.6.3" tslib "^2.1.0" -"@firebase/database-compat@0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-0.1.4.tgz#9bad05a4a14e557271b887b9ab97f8b39f91f5aa" - integrity sha512-dIJiZLDFF3U+MoEwoPBy7zxWmBUro1KefmwSHlpOoxmPv76tuoPm85NumpW/HmMrtTcTkC2qowtb6NjGE8X7mw== +"@firebase/database-compat@0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-0.2.5.tgz#5bed7e2a2f671391bd2b23e9dca09400214a15dd" + integrity sha512-fj88gwtNJMcJBDjcTMbCuYEiVzuGb76rTOaaiAOqxR+unzvvbs2KU5KbFyl83jcpIjY6NIt+xXNrCXpzo7Zp3g== dependencies: - "@firebase/component" "0.5.9" - "@firebase/database" "0.12.4" - "@firebase/database-types" "0.9.3" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/component" "0.5.17" + "@firebase/database" "0.13.5" + "@firebase/database-types" "0.9.13" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/database-compat@^0.1.1": @@ -1918,13 +1925,13 @@ "@firebase/util" "1.5.2" tslib "^2.1.0" -"@firebase/database-types@0.9.3": - version "0.9.3" - resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-0.9.3.tgz#d1a8ee34601136fd0047817d94432d89fdba5fef" - integrity sha512-R+YXLWy/Q7mNUxiUYiMboTwvVoprrgfyvf1Viyevskw6IoH1q8HV1UjlkLSgmRsOT9HPWt7XZUEStVZJFknHwg== +"@firebase/database-types@0.9.13": + version "0.9.13" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-0.9.13.tgz#47c12593ed27a9562f0919b7d3a1f1e00888abc2" + integrity sha512-dIJ1zGe3EHMhwcvukTOPzYlFYFIG1Et5Znl7s7y/ZTN2/toARRNnsv1qCKvqevIMYKvIrRsYOYfOXDS8l1YIJA== dependencies: "@firebase/app-types" "0.7.0" - "@firebase/util" "1.4.2" + "@firebase/util" "1.6.3" "@firebase/database-types@0.9.7": version "0.9.7" @@ -1941,18 +1948,6 @@ dependencies: "@firebase/app-types" "0.6.3" -"@firebase/database@0.12.4": - version "0.12.4" - resolved "https://registry.yarnpkg.com/@firebase/database/-/database-0.12.4.tgz#7ad26393f59ede2b93444406651f976a7008114d" - integrity sha512-XkrL1kXELRNkqKcltuT4hfG1gWmFiGvjFY+z7Lhb//12MqdkLjwa9YMK8c6Lo+Ro+IkWcJArQaOQYe3GkU5Wgg== - dependencies: - "@firebase/auth-interop-types" "0.1.6" - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" - faye-websocket "0.11.4" - tslib "^2.1.0" - "@firebase/database@0.12.8": version "0.12.8" resolved "https://registry.yarnpkg.com/@firebase/database/-/database-0.12.8.tgz#11a1b6752ba0614892af15c71958e00ce16f5824" @@ -1965,15 +1960,27 @@ faye-websocket "0.11.4" tslib "^2.1.0" -"@firebase/firestore-compat@0.1.9": - version "0.1.9" - resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.1.9.tgz#31c0e8154fcbc457d4413465bbdfeb4fea60ac84" - integrity sha512-OvWx3uzv9KzVJQPOyugz8RLbGVitjdRX+Wb845GtLbnFzApILHbjhd2zIKbvDQfnZsAD0eXPXLFIyCBCAEVz9g== +"@firebase/database@0.13.5": + version "0.13.5" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-0.13.5.tgz#c66888147d4d707237285547f8405dfa739f47a2" + integrity sha512-QmX73yi8URk36NAbykXeuAcJCjDtx3BzuxKJO3sL9B4CtjNFAfpWawVxoaaThocDWNAyMJxFhiL1kkaVraH7Lg== dependencies: - "@firebase/component" "0.5.9" - "@firebase/firestore" "3.4.0" + "@firebase/auth-interop-types" "0.1.6" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.1.23": + version "0.1.23" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.1.23.tgz#e941fa10f3eeca615df119470103fb4656842eef" + integrity sha512-QfcuyMAavp//fQnjSfCEpnbWi7spIdKaXys1kOLu7395fLr+U6ykmto1HUMCSz8Yus9cEr/03Ujdi2SUl2GUAA== + dependencies: + "@firebase/component" "0.5.17" + "@firebase/firestore" "3.4.14" "@firebase/firestore-types" "2.5.0" - "@firebase/util" "1.4.2" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/firestore-types@2.5.0": @@ -1981,29 +1988,29 @@ resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-2.5.0.tgz#16fca40b6980fdb000de86042d7a96635f2bcdd7" integrity sha512-I6c2m1zUhZ5SH0cWPmINabDyH5w0PPFHk2UHsjBpKdZllzJZ2TwTkXbDtpHUZNmnc/zAa0WNMNMvcvbb/xJLKA== -"@firebase/firestore@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-3.4.0.tgz#1ead32ae912545f12ccf9203a55e6e8e27786851" - integrity sha512-AiK4ol0U1Ul2oWegHgtAL47MRN7pkEo4XMtMY6ysVpopkVsiZzHFQIIgq5nFi/dQczWUvwX/ntOIELGJyQEZXQ== +"@firebase/firestore@3.4.14": + version "3.4.14" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-3.4.14.tgz#864a56e70b3fd8f0274d3497ed67fabf5b38fdb2" + integrity sha512-F4Pqd5OUBtJaAWWC39C0vrMLIdZtx7jsO7sARFHSiOZY/8bikfH9YovIRkpxk7OSs3HT/SgVdK0B1vISGNSnJA== dependencies: - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" - "@firebase/webchannel-wrapper" "0.6.1" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" + "@firebase/webchannel-wrapper" "0.6.2" "@grpc/grpc-js" "^1.3.2" - "@grpc/proto-loader" "^0.6.0" - node-fetch "2.6.5" + "@grpc/proto-loader" "^0.6.13" + node-fetch "2.6.7" tslib "^2.1.0" -"@firebase/functions-compat@0.1.7": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.1.7.tgz#0c73acedbf2701715fbec6b293ba1cd2549812c5" - integrity sha512-Rv3mAUIhsLTxIgPWJSESUcmE1tzNHzUlqQStPnxHn6eFFgHVhkU2wg/NMrKZWTFlb51jpKTjh51AQDhRdT3n3A== +"@firebase/functions-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.2.4.tgz#afa5d8eefe6d51c7b89e44d9262700b68fbcb73f" + integrity sha512-Crfn6il1yXGuXkjSd8nKrqR4XxPvuP19g64bXpM6Ix67qOkQg676kyOuww0FF17xN0NSXHfG8Pyf+CUrx8wJ5g== dependencies: - "@firebase/component" "0.5.9" - "@firebase/functions" "0.7.6" + "@firebase/component" "0.5.17" + "@firebase/functions" "0.8.4" "@firebase/functions-types" "0.5.0" - "@firebase/util" "1.4.2" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/functions-types@0.5.0": @@ -2011,27 +2018,43 @@ resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.5.0.tgz#b50ba95ccce9e96f7cda453228ffe1684645625b" integrity sha512-qza0M5EwX+Ocrl1cYI14zoipUX4gI/Shwqv0C1nB864INAD42Dgv4v94BCyxGHBg2kzlWy8PNafdP7zPO8aJQA== -"@firebase/functions@0.7.6": - version "0.7.6" - resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.7.6.tgz#c2ae5866943d812580bda26200c0b17295505dc3" - integrity sha512-Kl6a2PbRkOlSlOWJSgYuNp3e53G3cb+axF+r7rbWhJIHiaelG16GerBMxZTSxyiCz77C24LwiA2TKNwe85ObZg== +"@firebase/functions@0.8.4": + version "0.8.4" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.8.4.tgz#a9b7a10314f286df1ded87d8546fb8d9107a9c06" + integrity sha512-o1bB0xMyQKe+b246zGnjwHj4R6BH4mU2ZrSaa/3QvTpahUQ3hqYfkZPLOXCU7+vEFxHb3Hd4UUjkFhxoAcPqLA== dependencies: "@firebase/app-check-interop-types" "0.1.0" "@firebase/auth-interop-types" "0.1.6" - "@firebase/component" "0.5.9" + "@firebase/component" "0.5.17" "@firebase/messaging-interop-types" "0.1.0" - "@firebase/util" "1.4.2" - node-fetch "2.6.5" + "@firebase/util" "1.6.3" + node-fetch "2.6.7" tslib "^2.1.0" -"@firebase/installations@0.5.4": - version "0.5.4" - resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.5.4.tgz#c6f5a40eee930d447c909d84f01f5ebfe2f5f46e" - integrity sha512-rYb6Ju/tIBhojmM8FsgS96pErKl6gPgJFnffMO4bKH7HilXhOfgLfKU9k51ZDcps8N0npDx9+AJJ6pL1aYuYZQ== +"@firebase/installations-compat@0.1.12": + version "0.1.12" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.1.12.tgz#d0394127f71aff596cb8bb607840095d1617246e" + integrity sha512-BIhFpWIn/GkuOa+jnXkp3SDJT2RLYJF6MWpinHIBKFJs7MfrgYZ3zQ1AlhobDEql+bkD1dK4dB5sNcET2T+EyA== dependencies: - "@firebase/component" "0.5.9" - "@firebase/util" "1.4.2" - idb "3.0.2" + "@firebase/component" "0.5.17" + "@firebase/installations" "0.5.12" + "@firebase/installations-types" "0.4.0" + "@firebase/util" "1.6.3" + tslib "^2.1.0" + +"@firebase/installations-types@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.4.0.tgz#256782ff9adfb390ac658c25bc32f89635ddce7c" + integrity sha512-nXxWKQDvBGctuvsizbUEJKfxXU9WAaDhon+j0jpjIfOJkvkj3YHqlLB/HeYjpUn85Pb22BjplpTnDn4Gm9pc3A== + +"@firebase/installations@0.5.12": + version "0.5.12" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.5.12.tgz#1d5764aa6f0b73d9d6d1a81a07eab5cd71a5ea27" + integrity sha512-Zq43fCE0PB5tGJ3ojzx5RNQzKdej1188qgAk22rwjuhP7npaG/PlJqDG1/V0ZjTLRePZ1xGrfXSPlA17c/vtNw== + dependencies: + "@firebase/component" "0.5.17" + "@firebase/util" "1.6.3" + idb "7.0.1" tslib "^2.1.0" "@firebase/logger@0.3.2": @@ -2041,14 +2064,21 @@ dependencies: tslib "^2.1.0" -"@firebase/messaging-compat@0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.1.4.tgz#14dffa349e241557b10d8fb7f5896a04d3f857a7" - integrity sha512-6477jBw7w7hk0uhnTUMsPoukalpcwbxTTo9kMguHVSXe0t3OdoxeXEaapaNJlOmU4Kgc8j3rsms8IDLdKVpvlA== +"@firebase/logger@0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.3.3.tgz#0f724b1e0b166d17ac285aac5c8ec14d136beed4" + integrity sha512-POTJl07jOKTOevLXrTvJD/VZ0M6PnJXflbAh5J9VGkmtXPXNG6MdZ9fmRgqYhXKTaDId6AQenQ262uwgpdtO0Q== dependencies: - "@firebase/component" "0.5.9" - "@firebase/messaging" "0.9.4" - "@firebase/util" "1.4.2" + tslib "^2.1.0" + +"@firebase/messaging-compat@0.1.16": + version "0.1.16" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.1.16.tgz#4fe4e2c1b496e62f63e815cb242a2ab323cd7899" + integrity sha512-uG7rWcXJzU8vvlEBFpwG1ndw/GURrrmKcwsHopEWbsPGjMRaVWa7XrdKbvIR7IZohqPzcC/V9L8EeqF4Q4lz8w== + dependencies: + "@firebase/component" "0.5.17" + "@firebase/messaging" "0.9.16" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/messaging-interop-types@0.1.0": @@ -2056,28 +2086,28 @@ resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.1.0.tgz#bdac02dd31edd5cb9eec37b1db698ea5e2c1a631" integrity sha512-DbvUl/rXAZpQeKBnwz0NYY5OCqr2nFA0Bj28Fmr3NXGqR4PAkfTOHuQlVtLO1Nudo3q0HxAYLa68ZDAcuv2uKQ== -"@firebase/messaging@0.9.4": - version "0.9.4" - resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.9.4.tgz#a1cd38ad92eb92cde908dc695767362087137f6d" - integrity sha512-OvYV4MLPfDpdP/yltLqZXZRx6rXWz52bEilS2jL2B4sGiuTaXSkR6BIHB54EPTblu32nbyZYdlER4fssz4TfXw== +"@firebase/messaging@0.9.16": + version "0.9.16" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.9.16.tgz#96b57ebbb054e57f78585f85f59d521c5ba5cd85" + integrity sha512-Yl9gGrAvJF6C1gg3+Cr2HxlL6APsDEkrorkFafmSP1l+rg1epZKoOAcKJbSF02Vtb50wfb9FqGGy8tzodgETxg== dependencies: - "@firebase/component" "0.5.9" - "@firebase/installations" "0.5.4" + "@firebase/component" "0.5.17" + "@firebase/installations" "0.5.12" "@firebase/messaging-interop-types" "0.1.0" - "@firebase/util" "1.4.2" - idb "3.0.2" + "@firebase/util" "1.6.3" + idb "7.0.1" tslib "^2.1.0" -"@firebase/performance-compat@0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.1.4.tgz#0e887e9d707515db0594117072375e18200703a9" - integrity sha512-YuGfmpC0o+YvEBlEZCbPdNbT4Nn2qhi5uMXjqKnNIUepmXUsgOYDiAqM9nxHPoE/6IkvoFMdCj5nTUYVLCFXgg== +"@firebase/performance-compat@0.1.12": + version "0.1.12" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.1.12.tgz#ac50b0cd29bf7f5e1e33c640dba25e2f8db95f0b" + integrity sha512-IBORzUeGY1MGdZnsix9Mu5z4+C3WHIwalu0usxvygL0EZKHztGG8bppYPGH/b5vvg8QyHs9U+Pn1Ot2jZhffQQ== dependencies: - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/performance" "0.5.4" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/performance" "0.5.12" "@firebase/performance-types" "0.1.0" - "@firebase/util" "1.4.2" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/performance-types@0.1.0": @@ -2085,36 +2115,27 @@ resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.1.0.tgz#5e6efa9dc81860aee2cb7121b39ae8fa137e69fc" integrity sha512-6p1HxrH0mpx+622Ql6fcxFxfkYSBpE3LSuwM7iTtYU2nw91Hj6THC8Bc8z4nboIq7WvgsT/kOTYVVZzCSlXl8w== -"@firebase/performance@0.5.4": - version "0.5.4" - resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.5.4.tgz#480bf61a8ff248e55506172be267029270457743" - integrity sha512-ES6aS4eoMhf9CczntBADDsXhaFea/3a0FADwy/VpWXXBxVb8tqc5tPcoTwd9L5M/aDeSiQMy344rhrSsTbIZEg== +"@firebase/performance@0.5.12": + version "0.5.12" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.5.12.tgz#4eae3eb91eeffb29b996e7908172052d4a901856" + integrity sha512-MPVTkOkGrm2SMQgI1FPNBm85y2pPqlPb6VDjIMCWkVpAr6G1IZzUT24yEMySRcIlK/Hh7/Qu1Nu5ASRzRuX6+Q== dependencies: - "@firebase/component" "0.5.9" - "@firebase/installations" "0.5.4" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/component" "0.5.17" + "@firebase/installations" "0.5.12" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" tslib "^2.1.0" -"@firebase/polyfill@0.3.36": - version "0.3.36" - resolved "https://registry.yarnpkg.com/@firebase/polyfill/-/polyfill-0.3.36.tgz#c057cce6748170f36966b555749472b25efdb145" - integrity sha512-zMM9oSJgY6cT2jx3Ce9LYqb0eIpDE52meIzd/oe/y70F+v9u1LDqk5kUF5mf16zovGBWMNFmgzlsh6Wj0OsFtg== +"@firebase/remote-config-compat@0.1.12": + version "0.1.12" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.1.12.tgz#7606752d7bfe2701d58568345ca536beda14ee53" + integrity sha512-Yz7Gtb2rLa7ykXZX9DnSTId8CXd++jFFLW3foUImrYwJEtWgLJc7gwkRfd1M73IlKGNuQAY+DpUNF0n1dLbecA== dependencies: - core-js "3.6.5" - promise-polyfill "8.1.3" - whatwg-fetch "2.0.4" - -"@firebase/remote-config-compat@0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.1.4.tgz#25561c070b2ba8e41e3f33aa9e9db592bbec5a37" - integrity sha512-6WeKR7E9KJ1RIF9GZiyle1uD4IsIPUBKUnUnFkQhj3FV6cGvQwbeG0rbh7QQLvd0IWuh9lABYjHXWp+rGHQk8A== - dependencies: - "@firebase/component" "0.5.9" - "@firebase/logger" "0.3.2" - "@firebase/remote-config" "0.3.3" + "@firebase/component" "0.5.17" + "@firebase/logger" "0.3.3" + "@firebase/remote-config" "0.3.11" "@firebase/remote-config-types" "0.2.0" - "@firebase/util" "1.4.2" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/remote-config-types@0.2.0": @@ -2122,26 +2143,26 @@ resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.2.0.tgz#1e2759fc01f20b58c564db42196f075844c3d1fd" integrity sha512-hqK5sCPeZvcHQ1D6VjJZdW6EexLTXNMJfPdTwbD8NrXUw6UjWC4KWhLK/TSlL0QPsQtcKRkaaoP+9QCgKfMFPw== -"@firebase/remote-config@0.3.3": - version "0.3.3" - resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.3.3.tgz#dedee2de508e2392ec2f254368adb7c2d969fc16" - integrity sha512-9hZWfB3k3IYsjHbWeUfhv/SDCcOgv/JMJpLXlUbTppXPm1IZ3X9ZW4I9bS86gGYr7m/kSv99U0oxQ7N9PoR8Iw== +"@firebase/remote-config@0.3.11": + version "0.3.11" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.3.11.tgz#93c82b5944a20c027f4ee82c145813ca96b430bb" + integrity sha512-qA84dstrvVpO7rWT/sb2CLv1kjHVmz59SRFPKohJJYFBcPOGK4Pe4FWWhKAE9yg1Gnl0qYAGkahOwNawq3vE0g== dependencies: - "@firebase/component" "0.5.9" - "@firebase/installations" "0.5.4" - "@firebase/logger" "0.3.2" - "@firebase/util" "1.4.2" + "@firebase/component" "0.5.17" + "@firebase/installations" "0.5.12" + "@firebase/logger" "0.3.3" + "@firebase/util" "1.6.3" tslib "^2.1.0" -"@firebase/storage-compat@0.1.8": - version "0.1.8" - resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.1.8.tgz#edbd9e2d8178c5695817e75f1da5c570c11f44dd" - integrity sha512-L5R0DQoHCDKIgcBbqTx+6+RQ2533WFKeV3cfLAZCTGjyMUustj0eYDsr7fLhGexwsnpT3DaxhlbzT3icUWoDaA== +"@firebase/storage-compat@0.1.17": + version "0.1.17" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.1.17.tgz#da721071e006d066fb9b1cff69481bd59a02346b" + integrity sha512-nOYmnpI0gwoz5nROseMi9WbmHGf+xumfsOvdPyMZAjy0VqbDnpKIwmTUZQBdR+bLuB5oIkHQsvw9nbb1SH+PzQ== dependencies: - "@firebase/component" "0.5.9" - "@firebase/storage" "0.9.0" + "@firebase/component" "0.5.17" + "@firebase/storage" "0.9.9" "@firebase/storage-types" "0.6.0" - "@firebase/util" "1.4.2" + "@firebase/util" "1.6.3" tslib "^2.1.0" "@firebase/storage-types@0.6.0": @@ -2149,21 +2170,14 @@ resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.6.0.tgz#0b1af64a2965af46fca138e5b70700e9b7e6312a" integrity sha512-1LpWhcCb1ftpkP/akhzjzeFxgVefs6eMD2QeKiJJUGH1qOiows2w5o0sKCUSQrvrRQS1lz3SFGvNR1Ck/gqxeA== -"@firebase/storage@0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.9.0.tgz#e33d2dea4c056d70d801a20521aa96fa2e4fbfb8" - integrity sha512-1gSYdrwP9kECmugH9L3tvNMvSjnNJGamj91rrESOFk2ZHDO93qKR90awc68NnhmzFAJOT/eJzVm35LKU6SqUNg== - dependencies: - "@firebase/component" "0.5.9" - "@firebase/util" "1.4.2" - node-fetch "2.6.5" - tslib "^2.1.0" - -"@firebase/util@1.4.2": - version "1.4.2" - resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.4.2.tgz#271c63bb7cce4607f7679dc5624ef241c4cf2498" - integrity sha512-JMiUo+9QE9lMBvEtBjqsOFdmJgObFvi7OL1A0uFGwTmlCI1ZeNPOEBrwXkgTOelVCdiMO15mAebtEyxFuQ6FsA== +"@firebase/storage@0.9.9": + version "0.9.9" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.9.9.tgz#3d0080dd130bc3315731483384a7ef7c00f76e22" + integrity sha512-Zch7srLT2SIh9y2nCVv/4Kne0HULn7OPkmreY70BJTUJ+g5WLRjggBq6x9fV5ls9V38iqMWfn4prxzX8yIc08A== dependencies: + "@firebase/component" "0.5.17" + "@firebase/util" "1.6.3" + node-fetch "2.6.7" tslib "^2.1.0" "@firebase/util@1.5.2": @@ -2173,10 +2187,17 @@ dependencies: tslib "^2.1.0" -"@firebase/webchannel-wrapper@0.6.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.6.1.tgz#0c74724ba6e9ea6ad25a391eab60a79eaba4c556" - integrity sha512-9FqhNjKQWpQ3fGnSOCovHOm+yhhiorKEqYLAfd525jWavunDJcx8rOW6i6ozAh+FbwcYMkL7b+3j4UR/30MpoQ== +"@firebase/util@1.6.3": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.6.3.tgz#76128c1b5684c031823e95f6c08a7fb8560655c6" + integrity sha512-FujteO6Zjv6v8A4HS+t7c+PjU0Kaxj+rOnka0BsI/twUaCC9t8EQPmXpWZdk7XfszfahJn2pqsflUWUhtUkRlg== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@0.6.2": + version "0.6.2" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.6.2.tgz#6d05fa126104c9907573364dc04147b89b530e15" + integrity sha512-zThUKcqIU6utWzM93uEvhlh8qj8A5LMPFJPvk/ODb+8GSSif19xM2Lw1M2ijyBy8+6skSkQBbavPzOU5Oh/8tQ== "@floating-ui/core@^1.0.1": version "1.0.1" @@ -2284,7 +2305,7 @@ "@grpc/proto-loader" "^0.6.4" "@types/node" ">=12.12.47" -"@grpc/proto-loader@^0.6.0", "@grpc/proto-loader@^0.6.12", "@grpc/proto-loader@^0.6.4": +"@grpc/proto-loader@^0.6.12", "@grpc/proto-loader@^0.6.4": version "0.6.12" resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.6.12.tgz#459b619b8b9b67794bf0d1cb819653a38c63e164" integrity sha512-filTVbETFnxb9CyRX98zN18ilChTuf/C5scZ2xyaOTp0EHGq0/ufX8rjqXUcSb1Gpv7eZq4M2jDvbh9BogKnrg== @@ -2295,6 +2316,17 @@ protobufjs "^6.10.0" yargs "^16.2.0" +"@grpc/proto-loader@^0.6.13": + version "0.6.13" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.6.13.tgz#008f989b72a40c60c96cd4088522f09b05ac66bc" + integrity sha512-FjxPYDRTn6Ec3V0arm1FtSpmP6V50wuph2yILpyvTKzjc76oDdoihXqM1DzOW5ubvCC8GivfCnNtfaRE8myJ7g== + dependencies: + "@types/long" "^4.0.1" + lodash.camelcase "^4.3.0" + long "^4.0.0" + protobufjs "^6.11.3" + yargs "^16.2.0" + "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -2437,10 +2469,10 @@ resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== -"@next/env@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc" - integrity sha512-BqDwE4gDl1F608TpnNxZqrCn6g48MBjvmWFEmeX5wEXDXh3IkAOw6ASKUgjT8H4OUePYFqghDFUss5ZhnbOUjw== +"@next/env@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.5.tgz#d908c57b35262b94db3e431e869b72ac3e1ad3e3" + integrity sha512-vLPLV3cpPGjUPT3PjgRj7e3nio9t6USkuew3JE/jMeon/9Mvp1WyR18v3iwnCuX7eUAm1HmAbJHHLAbcu/EJcw== "@next/eslint-plugin-next@12.1.6": version "12.1.6" @@ -2449,70 +2481,70 @@ dependencies: glob "7.1.7" -"@next/swc-android-arm-eabi@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.2.tgz#f6c4111e6371f73af6bf80c9accb3d96850a92cd" - integrity sha512-VHjuCHeq9qCprUZbsRxxM/VqSW8MmsUtqB5nEpGEgUNnQi/BTm/2aK8tl7R4D0twGKRh6g1AAeFuWtXzk9Z/vQ== +"@next/swc-android-arm-eabi@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.5.tgz#903a5479ab4c2705d9c08d080907475f7bacf94d" + integrity sha512-cPWClKxGhgn2dLWnspW+7psl3MoLQUcNqJqOHk2BhNcou9ARDtC0IjQkKe5qcn9qg7I7U83Gp1yh2aesZfZJMA== -"@next/swc-android-arm64@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.2.tgz#b69de59c51e631a7600439e7a8993d6e82f3369e" - integrity sha512-v5EYzXUOSv0r9mO/2PX6mOcF53k8ndlu9yeFHVAWW1Dhw2jaJcvTRcCAwYYN8Q3tDg0nH3NbEltJDLKmcJOuVA== +"@next/swc-android-arm64@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.5.tgz#2f9a98ec4166c7860510963b31bda1f57a77c792" + integrity sha512-vMj0efliXmC5b7p+wfcQCX0AfU8IypjkzT64GiKJD9PgiA3IILNiGJr1fw2lyUDHkjeWx/5HMlMEpLnTsQslwg== -"@next/swc-darwin-arm64@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.2.tgz#80157c91668eff95b72d052428c353eab0fc4c50" - integrity sha512-JCoGySHKGt+YBk7xRTFGx1QjrnCcwYxIo3yGepcOq64MoiocTM3yllQWeOAJU2/k9MH0+B5E9WUSme4rOCBbpA== +"@next/swc-darwin-arm64@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.5.tgz#31b1c3c659d54be546120c488a1e1bad21c24a1d" + integrity sha512-VOPWbO5EFr6snla/WcxUKtvzGVShfs302TEMOtzYyWni6f9zuOetijJvVh9CCTzInnXAZMtHyNhefijA4HMYLg== -"@next/swc-darwin-x64@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.2.tgz#12be2f58e676fccff3d48a62921b9927ed295133" - integrity sha512-dztDtvfkhUqiqpXvrWVccfGhLe44yQ5tQ7B4tBfnsOR6vxzI9DNPHTlEOgRN9qDqTAcFyPxvg86mn4l8bB9Jcw== +"@next/swc-darwin-x64@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.5.tgz#2e44dd82b2b7fef88238d1bc4d3bead5884cedfd" + integrity sha512-5o8bTCgAmtYOgauO/Xd27vW52G2/m3i5PX7MUYePquxXAnX73AAtqA3WgPXBRitEB60plSKZgOTkcpqrsh546A== -"@next/swc-freebsd-x64@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.2.tgz#de1363431a49059f1efb8c0f86ce6a79c53b3a95" - integrity sha512-JUnXB+2xfxqsAvhFLPJpU1NeyDsvJrKoOjpV7g3Dxbno2Riu4tDKn3kKF886yleAuD/1qNTUCpqubTvbbT2VoA== +"@next/swc-freebsd-x64@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.5.tgz#e24e75d8c2581bfebc75e4f08f6ddbd116ce9dbd" + integrity sha512-yYUbyup1JnznMtEBRkK4LT56N0lfK5qNTzr6/DEyDw5TbFVwnuy2hhLBzwCBkScFVjpFdfiC6SQAX3FrAZzuuw== -"@next/swc-linux-arm-gnueabihf@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.2.tgz#d5b8e0d1bb55bbd9db4d2fec018217471dc8b9e6" - integrity sha512-XeYC/qqPLz58R4pjkb+x8sUUxuGLnx9QruC7/IGkK68yW4G17PHwKI/1njFYVfXTXUukpWjcfBuauWwxp9ke7Q== +"@next/swc-linux-arm-gnueabihf@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.5.tgz#46d8c514d834d2b5f67086013f0bd5e3081e10b9" + integrity sha512-2ZE2/G921Acks7UopJZVMgKLdm4vN4U0yuzvAMJ6KBavPzqESA2yHJlm85TV/K9gIjKhSk5BVtauIUntFRP8cg== -"@next/swc-linux-arm64-gnu@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.2.tgz#3bc75984e1d5ec8f59eb53702cc382d8e1be2061" - integrity sha512-d6jT8xgfKYFkzR7J0OHo2D+kFvY/6W8qEo6/hmdrTt6AKAqxs//rbbcdoyn3YQq1x6FVUUd39zzpezZntg9Naw== +"@next/swc-linux-arm64-gnu@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.5.tgz#91f725ac217d3a1f4f9f53b553615ba582fd3d9f" + integrity sha512-/I6+PWVlz2wkTdWqhlSYYJ1pWWgUVva6SgX353oqTh8njNQp1SdFQuWDqk8LnM6ulheVfSsgkDzxrDaAQZnzjQ== -"@next/swc-linux-arm64-musl@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.2.tgz#270db73e07a18d999f61e79a917943fa5bc1ef56" - integrity sha512-rIZRFxI9N/502auJT1i7coas0HTHUM+HaXMyJiCpnY8Rimbo0495ir24tzzHo3nQqJwcflcPTwEh/DV17sdv9A== +"@next/swc-linux-arm64-musl@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.5.tgz#e627e8c867920995810250303cd9b8e963598383" + integrity sha512-LPQRelfX6asXyVr59p5sTpx5l+0yh2Vjp/R8Wi4X9pnqcayqT4CUJLiHqCvZuLin3IsFdisJL0rKHMoaZLRfmg== -"@next/swc-linux-x64-gnu@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.2.tgz#e6c72fa20478552e898c434f4d4c0c5e89d2ea78" - integrity sha512-ir1vNadlUDj7eQk15AvfhG5BjVizuCHks9uZwBfUgT5jyeDCeRvaDCo1+Q6+0CLOAnYDR/nqSCvBgzG2UdFh9A== +"@next/swc-linux-x64-gnu@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.5.tgz#83a5e224fbc4d119ef2e0f29d0d79c40cc43887e" + integrity sha512-0szyAo8jMCClkjNK0hknjhmAngUppoRekW6OAezbEYwHXN/VNtsXbfzgYOqjKWxEx3OoAzrT3jLwAF0HdX2MEw== -"@next/swc-linux-x64-musl@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.2.tgz#b9ef9efe2c401839cdefa5e70402386aafdce15a" - integrity sha512-bte5n2GzLN3O8JdSFYWZzMgEgDHZmRz5wiispiiDssj4ik3l8E7wq/czNi8RmIF+ioj2sYVokUNa/ekLzrESWw== +"@next/swc-linux-x64-musl@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.5.tgz#be700d48471baac1ec2e9539396625584a317e95" + integrity sha512-zg/Y6oBar1yVnW6Il1I/08/2ukWtOG6s3acdJdEyIdsCzyQi4RLxbbhkD/EGQyhqBvd3QrC6ZXQEXighQUAZ0g== -"@next/swc-win32-arm64-msvc@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.2.tgz#18fa7ec7248da3a7926a0601d9ececc53ac83157" - integrity sha512-ZUGCmcDmdPVSAlwJ/aD+1F9lYW8vttseiv4n2+VCDv5JloxiX9aY32kYZaJJO7hmTLNrprvXkb4OvNuHdN22Jg== +"@next/swc-win32-arm64-msvc@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.5.tgz#a93e958133ad3310373fda33a79aa10af2a0aa97" + integrity sha512-3/90DRNSqeeSRMMEhj4gHHQlLhhKg5SCCoYfE3kBjGpE63EfnblYUqsszGGZ9ekpKL/R4/SGB40iCQr8tR5Jiw== -"@next/swc-win32-ia32-msvc@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.2.tgz#54936e84f4a219441d051940354da7cd3eafbb4f" - integrity sha512-v7ykeEDbr9eXiblGSZiEYYkWoig6sRhAbLKHUHQtk8vEWWVEqeXFcxmw6LRrKu5rCN1DY357UlYWToCGPQPCRA== +"@next/swc-win32-ia32-msvc@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.5.tgz#4f5f7ba0a98ff89a883625d4af0125baed8b2e19" + integrity sha512-hGLc0ZRAwnaPL4ulwpp4D2RxmkHQLuI8CFOEEHdzZpS63/hMVzv81g8jzYA0UXbb9pus/iTc3VRbVbAM03SRrw== -"@next/swc-win32-x64-msvc@12.2.2": - version "12.2.2" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.2.tgz#7460be700a60d75816f01109400b51fe929d7e89" - integrity sha512-2D2iinWUL6xx8D9LYVZ5qi7FP6uLAoWymt8m8aaG2Ld/Ka8/k723fJfiklfuAcwOxfufPJI+nRbT5VcgHGzHAQ== +"@next/swc-win32-x64-msvc@12.2.5": + version "12.2.5" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.5.tgz#20fed129b04a0d3f632c6d0de135345bb623b1e4" + integrity sha512-7h5/ahY7NeaO2xygqVrSG/Y8Vs4cdjxIjowTZ5W6CKoTKn7tmnuxlUc2h74x06FKmbhAd9agOjr/AOKyxYYm9Q== "@nivo/annotations@0.74.0": version "0.74.0" @@ -2894,10 +2926,10 @@ "@svgr/plugin-jsx" "^6.2.1" "@svgr/plugin-svgo" "^6.2.0" -"@swc/helpers@0.4.2": - version "0.4.2" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.2.tgz#ed1f6997ffbc22396665d9ba74e2a5c0a2d782f8" - integrity sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw== +"@swc/helpers@0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.3.tgz#16593dfc248c53b699d4b5026040f88ddb497012" + integrity sha512-6JrF+fdUK2zbGpJIlN7G3v966PQjyx/dPt1T9km2wj+EUBqgrxCk3uX4Kct16MIm9gGxfKRcfax2hVf5jvlTzA== dependencies: tslib "^2.4.0" @@ -4875,11 +4907,6 @@ core-js-pure@^3.20.2: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.22.5.tgz#bdee0ed2f9b78f2862cda4338a07b13a49b6c9a9" integrity sha512-8xo9R00iYD7TcV7OrC98GwxiUEAabVWO3dix+uyWjnYrx9fyASLlIX+f/3p5dW5qByaP2bcZ8X/T47s55et/tA== -core-js@3.6.5: - version "3.6.5" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== - core-js@^3.21.1: version "3.22.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.7.tgz#8d6c37f630f6139b8732d10f2c114c3f1d00024f" @@ -6377,37 +6404,37 @@ firebase-functions@3.21.2: lodash "^4.17.14" node-fetch "^2.6.7" -firebase@9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/firebase/-/firebase-9.6.0.tgz#3f2c3e3cc33d51285bcb40738d1b5318c9c8aaa0" - integrity sha512-ZpChU8JIwetXxcOwoJV/IKabMyW/oLsq9l+qf3aFB7LPcxcq0yxCFQzpHnYeQeWGrn9lcqfuhS1kXpIv5Ky7EQ== +firebase@9.9.3: + version "9.9.3" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-9.9.3.tgz#f759ca9bd845b6c05a0c69a0cd62a23b3876d6bb" + integrity sha512-lU1FstWqfVZQfz4+TWCZvqJYbwZMyoyP0X/xD/YIfrtXgquOMEDTpoasH4P79N9y3I8iV+6gQHuVmpK+AX2elg== dependencies: - "@firebase/analytics" "0.7.4" - "@firebase/analytics-compat" "0.1.5" - "@firebase/app" "0.7.10" - "@firebase/app-check" "0.5.2" - "@firebase/app-check-compat" "0.2.2" - "@firebase/app-compat" "0.1.11" + "@firebase/analytics" "0.8.0" + "@firebase/analytics-compat" "0.1.13" + "@firebase/app" "0.7.31" + "@firebase/app-check" "0.5.12" + "@firebase/app-check-compat" "0.2.12" + "@firebase/app-compat" "0.1.32" "@firebase/app-types" "0.7.0" - "@firebase/auth" "0.19.3" - "@firebase/auth-compat" "0.2.3" - "@firebase/database" "0.12.4" - "@firebase/database-compat" "0.1.4" - "@firebase/firestore" "3.4.0" - "@firebase/firestore-compat" "0.1.9" - "@firebase/functions" "0.7.6" - "@firebase/functions-compat" "0.1.7" - "@firebase/installations" "0.5.4" - "@firebase/messaging" "0.9.4" - "@firebase/messaging-compat" "0.1.4" - "@firebase/performance" "0.5.4" - "@firebase/performance-compat" "0.1.4" - "@firebase/polyfill" "0.3.36" - "@firebase/remote-config" "0.3.3" - "@firebase/remote-config-compat" "0.1.4" - "@firebase/storage" "0.9.0" - "@firebase/storage-compat" "0.1.8" - "@firebase/util" "1.4.2" + "@firebase/auth" "0.20.5" + "@firebase/auth-compat" "0.2.18" + "@firebase/database" "0.13.5" + "@firebase/database-compat" "0.2.5" + "@firebase/firestore" "3.4.14" + "@firebase/firestore-compat" "0.1.23" + "@firebase/functions" "0.8.4" + "@firebase/functions-compat" "0.2.4" + "@firebase/installations" "0.5.12" + "@firebase/installations-compat" "0.1.12" + "@firebase/messaging" "0.9.16" + "@firebase/messaging-compat" "0.1.16" + "@firebase/performance" "0.5.12" + "@firebase/performance-compat" "0.1.12" + "@firebase/remote-config" "0.3.11" + "@firebase/remote-config-compat" "0.1.12" + "@firebase/storage" "0.9.9" + "@firebase/storage-compat" "0.1.17" + "@firebase/util" "1.6.3" flat-cache@^3.0.4: version "3.0.4" @@ -7243,10 +7270,10 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -idb@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/idb/-/idb-3.0.2.tgz#c8e9122d5ddd40f13b60ae665e4862f8b13fa384" - integrity sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw== +idb@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.0.1.tgz#d2875b3a2f205d854ee307f6d196f246fea590a7" + integrity sha512-UUxlE7vGWK5RfB/fDwEGgRf84DY/ieqNha6msMV99UsEMQhJ1RwbCd8AYBj3QMgnE3VZnfQvm4oKVCJTYlqIgg== ignore-by-default@^1.0.1: version "1.0.1" @@ -8516,7 +8543,7 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" -nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.4: +nanoid@^3.1.23, nanoid@^3.3.4: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== @@ -8549,31 +8576,31 @@ next-sitemap@^2.5.14: "@corex/deepmerge" "^2.6.148" minimist "^1.2.6" -next@12.2.2: - version "12.2.2" - resolved "https://registry.yarnpkg.com/next/-/next-12.2.2.tgz#029bf5e4a18a891ca5d05b189b7cd983fd22c072" - integrity sha512-zAYFY45aBry/PlKONqtlloRFqU/We3zWYdn2NoGvDZkoYUYQSJC8WMcalS5C19MxbCZLUVCX7D7a6gTGgl2yLg== +next@12.2.5: + version "12.2.5" + resolved "https://registry.yarnpkg.com/next/-/next-12.2.5.tgz#14fb5975e8841fad09553b8ef41fe1393602b717" + integrity sha512-tBdjqX5XC/oFs/6gxrZhjmiq90YWizUYU6qOWAfat7zJwrwapJ+BYgX2PmiacunXMaRpeVT4vz5MSPSLgNkrpA== dependencies: - "@next/env" "12.2.2" - "@swc/helpers" "0.4.2" + "@next/env" "12.2.5" + "@swc/helpers" "0.4.3" caniuse-lite "^1.0.30001332" - postcss "8.4.5" - styled-jsx "5.0.2" - use-sync-external-store "1.1.0" + postcss "8.4.14" + styled-jsx "5.0.4" + use-sync-external-store "1.2.0" optionalDependencies: - "@next/swc-android-arm-eabi" "12.2.2" - "@next/swc-android-arm64" "12.2.2" - "@next/swc-darwin-arm64" "12.2.2" - "@next/swc-darwin-x64" "12.2.2" - "@next/swc-freebsd-x64" "12.2.2" - "@next/swc-linux-arm-gnueabihf" "12.2.2" - "@next/swc-linux-arm64-gnu" "12.2.2" - "@next/swc-linux-arm64-musl" "12.2.2" - "@next/swc-linux-x64-gnu" "12.2.2" - "@next/swc-linux-x64-musl" "12.2.2" - "@next/swc-win32-arm64-msvc" "12.2.2" - "@next/swc-win32-ia32-msvc" "12.2.2" - "@next/swc-win32-x64-msvc" "12.2.2" + "@next/swc-android-arm-eabi" "12.2.5" + "@next/swc-android-arm64" "12.2.5" + "@next/swc-darwin-arm64" "12.2.5" + "@next/swc-darwin-x64" "12.2.5" + "@next/swc-freebsd-x64" "12.2.5" + "@next/swc-linux-arm-gnueabihf" "12.2.5" + "@next/swc-linux-arm64-gnu" "12.2.5" + "@next/swc-linux-arm64-musl" "12.2.5" + "@next/swc-linux-x64-gnu" "12.2.5" + "@next/swc-linux-x64-musl" "12.2.5" + "@next/swc-win32-arm64-msvc" "12.2.5" + "@next/swc-win32-ia32-msvc" "12.2.5" + "@next/swc-win32-x64-msvc" "12.2.5" no-case@^3.0.4: version "3.0.4" @@ -8595,13 +8622,6 @@ node-emoji@^1.10.0: dependencies: lodash "^4.17.21" -node-fetch@2.6.5: - version "2.6.5" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd" - integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ== - dependencies: - whatwg-url "^5.0.0" - node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -9481,16 +9501,7 @@ postcss@8.3.5: nanoid "^3.1.23" source-map-js "^0.6.2" -postcss@8.4.5: - version "8.4.5" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.5.tgz#bae665764dfd4c6fcc24dc0fdf7e7aa00cc77f95" - integrity sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg== - dependencies: - nanoid "^3.1.30" - picocolors "^1.0.0" - source-map-js "^1.0.1" - -postcss@^8.3.11, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.14, postcss@^8.4.7: +postcss@8.4.14, postcss@^8.3.11, postcss@^8.3.5, postcss@^8.3.7, postcss@^8.4.14, postcss@^8.4.7: version "8.4.14" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== @@ -9562,11 +9573,6 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -promise-polyfill@8.1.3: - version "8.1.3" - resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116" - integrity sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g== - promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -9716,7 +9722,7 @@ protobufjs@6.11.2: "@types/node" ">=13.7.0" long "^4.0.0" -protobufjs@^6.10.0, protobufjs@^6.11.2, protobufjs@^6.8.6: +protobufjs@^6.10.0, protobufjs@^6.11.2, protobufjs@^6.11.3, protobufjs@^6.8.6: version "6.11.3" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.3.tgz#637a527205a35caa4f3e2a9a4a13ddffe0e7af74" integrity sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg== @@ -10626,17 +10632,7 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selenium-webdriver@4.0.0-rc-1: - version "4.0.0-rc-1" - resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.0.0-rc-1.tgz#b1e7e5821298c8a071e988518dd6b759f0c41281" - integrity sha512-bcrwFPRax8fifRP60p7xkWDGSJJoMkPAzufMlk5K2NyLPht/YZzR2WcIk1+3gR8VOCLlst1P2PI+MXACaFzpIw== - dependencies: - jszip "^3.6.0" - rimraf "^3.0.2" - tmp "^0.2.1" - ws ">=7.4.6" - -selenium-webdriver@^4.0.0-beta.2: +selenium-webdriver@4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.2.tgz#d463b4335632d2ea41a9e988e435a55dc41f5314" integrity sha512-e4Ap8vQvhipgBB8Ry9zBiKGkU6kHKyNnWiavGGLKkrdW81Zv7NVMtFOL/j3yX0G8QScM7XIXijKssNd4EUxSOw== @@ -10897,7 +10893,7 @@ source-map-js@^0.6.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== -source-map-js@^1.0.1, source-map-js@^1.0.2: +source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== @@ -11168,10 +11164,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" -styled-jsx@5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.2.tgz#ff230fd593b737e9e68b630a694d460425478729" - integrity sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ== +styled-jsx@5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.4.tgz#5b1bd0b9ab44caae3dd1361295559706e044aa53" + integrity sha512-sDFWLbg4zR+UkNzfk5lPilyIgtpddfxXEULxhujorr5jtePTUqiPDc5BC0v1NRqTr/WaFBGQQUoYToGlF4B2KQ== stylehacks@^5.1.0: version "5.1.0" @@ -11744,10 +11740,10 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" -use-sync-external-store@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz#3343c3fe7f7e404db70f8c687adf5c1652d34e82" - integrity sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ== +use-sync-external-store@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" @@ -12018,11 +12014,6 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== -whatwg-fetch@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" - integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== - whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" From 40f1c09002901c95b587e2d2530809fea1d0ec2c Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Wed, 31 Aug 2022 01:56:03 +0000 Subject: [PATCH 162/279] Auto-remove unused imports --- web/components/bet-panel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 26a01ea3..913216e9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { clamp, partition, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' @@ -32,7 +32,6 @@ import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveBinaryShares } from './use-save-binary-shares' import { BetSignUpPrompt } from './sign-up-prompt' -import { isIOS } from 'web/lib/util/device' import { ProbabilityOrNumericInput } from './probability-input' import { track } from 'web/lib/service/analytics' import { useUnfilledBets } from 'web/hooks/use-bets' From 7dddff52b86f7c3fc7bddb554ea93d45b44c0829 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 30 Aug 2022 20:28:30 -0700 Subject: [PATCH 163/279] Tidying some feed code up (#818) * Clean up some markup & dead code * Order comments in Firestore instead of on client * Order bets in Firestore instead of on client * Make indexes file up to date with production --- firestore.indexes.json | 150 ++++++++++++++++++ web/components/contract/contract-overview.tsx | 2 +- web/components/feed/feed-liquidity.tsx | 41 +++-- web/components/user-link.tsx | 4 +- web/lib/firebase/bets.ts | 30 ++-- web/lib/firebase/comments.ts | 30 ++-- web/pages/[username]/[contractSlug].tsx | 3 - web/pages/embed/[username]/[contractSlug].tsx | 2 - 8 files changed, 198 insertions(+), 64 deletions(-) diff --git a/firestore.indexes.json b/firestore.indexes.json index 80b08996..bcee41d5 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -26,9 +26,55 @@ "collectionGroup": "bets", "queryScope": "COLLECTION_GROUP", "fields": [ + { + "fieldPath": "isFilled", + "order": "ASCENDING" + }, { "fieldPath": "userId", "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "bets", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "bets", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isCancelled", + "order": "ASCENDING" + }, + { + "fieldPath": "isFilled", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "challenges", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "creatorId", + "order": "ASCENDING" }, { "fieldPath": "createdTime", @@ -54,6 +100,34 @@ } ] }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "comments", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", @@ -82,6 +156,42 @@ } ] }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "creatorId", + "order": "ASCENDING" + }, + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "groupSlugs", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", @@ -128,6 +238,46 @@ } ] }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "closeTime", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "contracts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isResolved", + "order": "ASCENDING" + }, + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "popularityScore", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "contracts", "queryScope": "COLLECTION", diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index bf62f77e..a7d3102f 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -126,7 +126,7 @@ export const ContractOverview = (props: { </Col> <div className={'my-1 md:my-2'}></div> {(isBinary || isPseudoNumeric) && ( - <ContractProbGraph contract={contract} bets={bets} /> + <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> )}{' '} {(outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && ( diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index ee2e34e5..e2a80624 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import dayjs from 'dayjs' import { User } from 'common/user' import { useUser, useUserById } from 'web/hooks/use-user' @@ -24,26 +25,23 @@ export function FeedLiquidity(props: { const isSelf = user?.id === userId return ( - <> - <Row className="flex w-full gap-2 pt-3"> - {isSelf ? ( - <Avatar avatarUrl={user.avatarUrl} username={user.username} /> - ) : bettor ? ( - <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> - ) : ( - <div className="relative px-1"> - <EmptyAvatar /> - </div> - )} - <div className={'min-w-0 flex-1 py-1.5'}> - <LiquidityStatusText - liquidity={liquidity} - isSelf={isSelf} - bettor={bettor} - /> + <Row className="flex w-full gap-2 pt-3"> + {isSelf ? ( + <Avatar avatarUrl={user.avatarUrl} username={user.username} /> + ) : bettor ? ( + <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> + ) : ( + <div className="relative px-1"> + <EmptyAvatar /> </div> - </Row> - </> + )} + <LiquidityStatusText + liquidity={liquidity} + isSelf={isSelf} + bettor={bettor} + className={'flex-1'} + /> + </Row> ) } @@ -51,8 +49,9 @@ export function LiquidityStatusText(props: { liquidity: LiquidityProvision isSelf: boolean bettor?: User + className?: string }) { - const { liquidity, bettor, isSelf } = props + const { liquidity, bettor, isSelf, className } = props const { amount, createdTime } = liquidity // TODO: Withdrawn liquidity will never be shown, since liquidity amounts currently are zeroed out upon withdrawal. @@ -60,7 +59,7 @@ export function LiquidityStatusText(props: { const money = formatMoney(Math.abs(amount)) return ( - <div className="text-sm text-gray-500"> + <div className={clsx(className, 'text-sm text-gray-500')}> {bettor ? ( <UserLink name={bettor.name} username={bettor.username} /> ) : ( diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index 796bb367..a3bd9e9f 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -24,11 +24,10 @@ function shortenName(name: string) { export function UserLink(props: { name: string username: string - showUsername?: boolean className?: string short?: boolean }) { - const { name, username, showUsername, className, short } = props + const { name, username, className, short } = props const shortName = short ? shortenName(name) : name return ( <SiteLink @@ -36,7 +35,6 @@ export function UserLink(props: { className={clsx('z-10 truncate', className)} > {shortName} - {showUsername && ` (@${username})`} </SiteLink> ) } diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 2a095d32..7f44786a 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -28,9 +28,9 @@ function getBetsCollection(contractId: string) { } export async function listAllBets(contractId: string) { - const bets = await getValues<Bet>(getBetsCollection(contractId)) - bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) - return bets + return await getValues<Bet>( + query(getBetsCollection(contractId), orderBy('createdTime', 'desc')) + ) } const DAY_IN_MS = 24 * 60 * 60 * 1000 @@ -64,10 +64,10 @@ export function listenForBets( contractId: string, setBets: (bets: Bet[]) => void ) { - return listenForValues<Bet>(getBetsCollection(contractId), (bets) => { - bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) - setBets(bets) - }) + return listenForValues<Bet>( + query(getBetsCollection(contractId), orderBy('createdTime', 'desc')), + setBets + ) } export async function getUserBets( @@ -147,12 +147,10 @@ export function listenForUserContractBets( ) { const betsQuery = query( collection(db, 'contracts', contractId, 'bets'), - where('userId', '==', userId) + where('userId', '==', userId), + orderBy('createdTime', 'desc') ) - return listenForValues<Bet>(betsQuery, (bets) => { - bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) - setBets(bets) - }) + return listenForValues<Bet>(betsQuery, setBets) } export function listenForUnfilledBets( @@ -162,12 +160,10 @@ export function listenForUnfilledBets( const betsQuery = query( collection(db, 'contracts', contractId, 'bets'), where('isFilled', '==', false), - where('isCancelled', '==', false) + where('isCancelled', '==', false), + orderBy('createdTime', 'desc') ) - return listenForValues<LimitBet>(betsQuery, (bets) => { - bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) - setBets(bets) - }) + return listenForValues<LimitBet>(betsQuery, setBets) } export function withoutAnteBets(contract: Contract, bets?: Bet[]) { diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 70785858..aab4de85 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -92,17 +92,15 @@ function getCommentsOnGroupCollection(groupId: string) { } export async function listAllComments(contractId: string) { - const comments = await getValues<Comment>(getCommentsCollection(contractId)) - comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - return comments + return await getValues<Comment>( + query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')) + ) } export async function listAllCommentsOnGroup(groupId: string) { - const comments = await getValues<GroupComment>( - getCommentsOnGroupCollection(groupId) + return await getValues<GroupComment>( + query(getCommentsOnGroupCollection(groupId), orderBy('createdTime', 'desc')) ) - comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - return comments } export function listenForCommentsOnContract( @@ -110,23 +108,21 @@ export function listenForCommentsOnContract( setComments: (comments: ContractComment[]) => void ) { return listenForValues<ContractComment>( - getCommentsCollection(contractId), - (comments) => { - comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - setComments(comments) - } + query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')), + setComments ) } + export function listenForCommentsOnGroup( groupId: string, setComments: (comments: GroupComment[]) => void ) { return listenForValues<GroupComment>( - getCommentsOnGroupCollection(groupId), - (comments) => { - comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - setComments(comments) - } + query( + getCommentsOnGroupCollection(groupId), + orderBy('createdTime', 'desc') + ), + setComments ) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index f7a5c5c5..f3c48a68 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -168,9 +168,6 @@ export function ContractPageContent( [bets] ) - // Sort for now to see if bug is fixed. - comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - const tips = useTipTxns({ contractId: contract.id }) const [showConfetti, setShowConfetti] = useState(false) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 8044ec6e..3f91baf7 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -71,8 +71,6 @@ export default function ContractEmbedPage(props: { const contract = useContractWithPreload(props.contract) const { bets } = props - bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) - if (!contract) { return <Custom404 /> } From 849402ed700d91b020fbd99451a1fb2cf78f5221 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 30 Aug 2022 23:45:55 -0500 Subject: [PATCH 164/279] Rearrange home sections. Load more in carousel. --- common/user.ts | 1 + web/components/carousel.tsx | 16 +- web/components/contract-search.tsx | 14 +- web/package.json | 2 + web/pages/experimental/home.tsx | 189 ---------------- web/pages/experimental/home/_arrange-home.tsx | 127 +++++++++++ .../experimental/home/_double-carousel.tsx | 52 +++++ web/pages/experimental/home/index.tsx | 204 ++++++++++++++++++ yarn.lock | 95 +++++++- 9 files changed, 500 insertions(+), 200 deletions(-) delete mode 100644 web/pages/experimental/home.tsx create mode 100644 web/pages/experimental/home/_arrange-home.tsx create mode 100644 web/pages/experimental/home/_double-carousel.tsx create mode 100644 web/pages/experimental/home/index.tsx diff --git a/common/user.ts b/common/user.ts index e3c9d181..0e333278 100644 --- a/common/user.ts +++ b/common/user.ts @@ -34,6 +34,7 @@ export type User = { followerCountCached: number followedCategories?: string[] + homeSections?: { visible: string[]; hidden: string[] } referredByUserId?: string referredByContractId?: string diff --git a/web/components/carousel.tsx b/web/components/carousel.tsx index 7ca19c66..9719ba06 100644 --- a/web/components/carousel.tsx +++ b/web/components/carousel.tsx @@ -3,9 +3,14 @@ import clsx from 'clsx' import { throttle } from 'lodash' import { ReactNode, useRef, useState, useEffect } from 'react' import { Row } from './layout/row' +import { VisibilityObserver } from 'web/components/visibility-observer' -export function Carousel(props: { children: ReactNode; className?: string }) { - const { children, className } = props +export function Carousel(props: { + children: ReactNode + loadMore?: () => void + className?: string +}) { + const { children, loadMore, className } = props const ref = useRef<HTMLDivElement>(null) @@ -38,6 +43,13 @@ export function Carousel(props: { children: ReactNode; className?: string }) { onScroll={onScroll} > {children} + + {loadMore && ( + <VisibilityObserver + className="relative -left-96" + onVisibilityUpdated={(visible) => visible && loadMore()} + /> + )} </Row> {!atFront && ( <div diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 097a3b44..75983f29 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -38,7 +38,7 @@ const searchClient = algoliasearch( const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' -const SORTS = [ +export const SORTS = [ { label: 'Newest', value: 'newest' }, { label: 'Trending', value: 'score' }, { label: 'Most traded', value: 'most-traded' }, @@ -83,9 +83,11 @@ export function ContractSearch(props: { persistPrefix?: string useQueryUrlParam?: boolean isWholePage?: boolean - maxItems?: number noControls?: boolean - renderContracts?: (contracts: Contract[] | undefined) => ReactNode + renderContracts?: ( + contracts: Contract[] | undefined, + loadMore: () => void + ) => ReactNode }) { const { user, @@ -100,7 +102,6 @@ export function ContractSearch(props: { persistPrefix, useQueryUrlParam, isWholePage, - maxItems, noControls, renderContracts, } = props @@ -184,8 +185,7 @@ export function ContractSearch(props: { const contracts = state.pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) - const renderedContracts = - state.pages.length === 0 ? undefined : contracts.slice(0, maxItems) + const renderedContracts = state.pages.length === 0 ? undefined : contracts if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return <ContractSearchFirestore additionalFilter={additionalFilter} /> @@ -206,7 +206,7 @@ export function ContractSearch(props: { noControls={noControls} /> {renderContracts ? ( - renderContracts(renderedContracts) + renderContracts(renderedContracts, performQuery) ) : ( <ContractsGrid contracts={renderedContracts} diff --git a/web/package.json b/web/package.json index 36001355..114ded1e 100644 --- a/web/package.json +++ b/web/package.json @@ -49,6 +49,7 @@ "next": "12.2.5", "node-fetch": "3.2.4", "react": "17.0.2", + "react-beautiful-dnd": "13.1.1", "react-confetti": "6.0.1", "react-dom": "17.0.2", "react-expanding-textarea": "2.3.5", @@ -66,6 +67,7 @@ "@types/lodash": "4.14.178", "@types/node": "16.11.11", "@types/react": "17.0.43", + "@types/react-beautiful-dnd": "13.1.2", "@types/react-dom": "17.0.2", "@types/string-similarity": "^4.0.0", "autoprefixer": "10.2.6", diff --git a/web/pages/experimental/home.tsx b/web/pages/experimental/home.tsx deleted file mode 100644 index 887cb4c6..00000000 --- a/web/pages/experimental/home.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react' -import Router from 'next/router' -import { PlusSmIcon } from '@heroicons/react/solid' - -import { Page } from 'web/components/page' -import { Col } from 'web/components/layout/col' -import { ContractSearch } from 'web/components/contract-search' -import { User } from 'common/user' -import { getUserAndPrivateUser } 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 { Button } from 'web/components/button' -import { useMemberGroups } from 'web/hooks/use-group' -import { Group } from 'common/group' -import { Carousel } from 'web/components/carousel' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { ContractCard } from 'web/components/contract/contract-card' -import { range } from 'lodash' -import { Contract } from 'common/contract' -import { ShowTime } from 'web/components/contract/contract-details' -import { GroupLinkItem } from '../groups' -import { SiteLink } from 'web/components/site-link' - -export const getServerSideProps: GetServerSideProps = async (ctx) => { - const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null - return { props: { auth } } -} - -const Home = (props: { auth: { user: User } | null }) => { - const user = props.auth ? props.auth.user : null - - useTracking('view home') - - useSaveReferral() - - const memberGroups = (useMemberGroups(user?.id) ?? []).filter( - (group) => group.contractIds.length > 0 - ) - - return ( - <Page> - <Col className="mx-4 mt-4 gap-4 sm:mx-10 xl:w-[125%]"> - <SearchSection label="Trending" sort="score" user={user} /> - <SearchSection label="Newest" sort="newest" user={user} /> - <SearchSection label="Closing soon" sort="close-date" user={user} /> - {memberGroups.map((group) => ( - <GroupSection key={group.id} group={group} user={user} /> - ))} - </Col> - <button - type="button" - className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" - onClick={() => { - Router.push('/create') - track('mobile create button') - }} - > - <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> - </button> - </Page> - ) -} - -function SearchSection(props: { - label: string - user: User | null - sort: Sort -}) { - const { label, user, sort } = props - const href = `/home?s=${sort}` - - return ( - <Col> - <SiteLink className="mb-2 text-xl" href={href}> - {label} - </SiteLink> - <ContractSearch - user={user} - defaultSort={sort} - maxItems={12} - noControls - renderContracts={(contracts) => - contracts ? ( - <DoubleCarousel - contracts={contracts} - seeMoreUrl={href} - showTime={ - sort === 'close-date' || sort === 'resolve-date' - ? sort - : undefined - } - /> - ) : ( - <LoadingIndicator /> - ) - } - /> - </Col> - ) -} - -function GroupSection(props: { group: Group; user: User | null }) { - const { group, user } = props - - return ( - <Col> - <GroupLinkItem className="mb-2 text-xl" group={group} /> - <ContractSearch - user={user} - defaultSort={'score'} - additionalFilter={{ groupSlug: group.slug }} - maxItems={12} - noControls - renderContracts={(contracts) => - contracts ? ( - <DoubleCarousel - contracts={contracts} - seeMoreUrl={`/group/${group.slug}`} - /> - ) : ( - <LoadingIndicator /> - ) - } - /> - </Col> - ) -} - -function DoubleCarousel(props: { - contracts: Contract[] - seeMoreUrl?: string - showTime?: ShowTime -}) { - const { contracts, seeMoreUrl, showTime } = props - return ( - <Carousel className="-mx-4 mt-2 sm:-mx-10"> - <div className="shrink-0 sm:w-6" /> - {contracts.length >= 6 - ? range(0, Math.floor(contracts.length / 2)).map((col) => { - const i = col * 2 - return ( - <Col> - <ContractCard - key={contracts[i].id} - contract={contracts[i]} - className="mb-2 max-h-[200px] w-96 shrink-0" - questionClass="line-clamp-3" - trackingPostfix=" tournament" - showTime={showTime} - /> - <ContractCard - key={contracts[i + 1].id} - contract={contracts[i + 1]} - className="mb-2 max-h-[200px] w-96 shrink-0" - questionClass="line-clamp-3" - trackingPostfix=" tournament" - showTime={showTime} - /> - </Col> - ) - }) - : contracts.map((c) => ( - <ContractCard - key={c.id} - contract={c} - className="mb-2 max-h-[200px] w-96 shrink-0" - questionClass="line-clamp-3" - trackingPostfix=" tournament" - showTime={showTime} - /> - ))} - <Button - className="self-center whitespace-nowrap" - color="blue" - size="sm" - onClick={() => seeMoreUrl && Router.push(seeMoreUrl)} - > - See more - </Button> - </Carousel> - ) -} - -export default Home diff --git a/web/pages/experimental/home/_arrange-home.tsx b/web/pages/experimental/home/_arrange-home.tsx new file mode 100644 index 00000000..cfae0142 --- /dev/null +++ b/web/pages/experimental/home/_arrange-home.tsx @@ -0,0 +1,127 @@ +import clsx from 'clsx' +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' +import { MenuIcon } from '@heroicons/react/solid' + +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Subtitle } from 'web/components/subtitle' +import { useMemberGroups } from 'web/hooks/use-group' +import { filterDefined } from 'common/util/array' +import { keyBy } from 'lodash' +import { User } from 'common/user' + +export function ArrangeHome(props: { + user: User | null + homeSections: { visible: string[]; hidden: string[] } + setHomeSections: (homeSections: { + visible: string[] + hidden: string[] + }) => void +}) { + const { + user, + homeSections: { visible, hidden }, + setHomeSections, + } = props + + const memberGroups = useMemberGroups(user?.id) ?? [] + + const items = [ + { label: 'Trending', id: 'score' }, + { label: 'Newest', id: 'newest' }, + { label: 'Close date', id: 'close-date' }, + ...memberGroups.map((g) => ({ + label: g.name, + id: g.id, + })), + ] + const itemsById = keyBy(items, 'id') + + const [visibleItems, hiddenItems] = [ + filterDefined(visible.map((id) => itemsById[id])), + filterDefined(hidden.map((id) => itemsById[id])), + ] + + // Add unmentioned items to the visible list. + visibleItems.push( + ...items.filter( + (item) => !visibleItems.includes(item) && !hiddenItems.includes(item) + ) + ) + + return ( + <DragDropContext + onDragEnd={(e) => { + console.log('drag end', e) + const { destination, source, draggableId } = e + if (!destination) return + + const item = itemsById[draggableId] + + const newHomeSections = { + visible: visibleItems.map((item) => item.id), + hidden: hiddenItems.map((item) => item.id), + } + + const sourceSection = source.droppableId as 'visible' | 'hidden' + newHomeSections[sourceSection].splice(source.index, 1) + + const destSection = destination.droppableId as 'visible' | 'hidden' + newHomeSections[destSection].splice(destination.index, 0, item.id) + + setHomeSections(newHomeSections) + }} + > + <Row className="relative max-w-lg gap-4"> + <DraggableList items={visibleItems} title="Visible" /> + <DraggableList items={hiddenItems} title="Hidden" /> + </Row> + </DragDropContext> + ) +} + +function DraggableList(props: { + title: string + items: { id: string; label: string }[] +}) { + const { title, items } = props + return ( + <Droppable droppableId={title.toLowerCase()}> + {(provided, snapshot) => ( + <Col + {...provided.droppableProps} + ref={provided.innerRef} + className={clsx( + 'width-[220px] flex-1 items-start rounded bg-gray-50 p-2', + snapshot.isDraggingOver && 'bg-gray-100' + )} + > + <Subtitle text={title} className="mx-2 !my-2" /> + {items.map((item, index) => ( + <Draggable key={item.id} draggableId={item.id} index={index}> + {(provided, snapshot) => ( + <div + ref={provided.innerRef} + {...provided.draggableProps} + {...provided.dragHandleProps} + style={provided.draggableProps.style} + className={clsx( + 'flex flex-row items-center gap-4 rounded bg-gray-50 p-2', + snapshot.isDragging && 'z-[9000] bg-gray-300' + )} + > + <MenuIcon + className="h-5 w-5 flex-shrink-0 text-gray-500" + aria-hidden="true" + />{' '} + {item.label} + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </Col> + )} + </Droppable> + ) +} diff --git a/web/pages/experimental/home/_double-carousel.tsx b/web/pages/experimental/home/_double-carousel.tsx new file mode 100644 index 00000000..da01eb5a --- /dev/null +++ b/web/pages/experimental/home/_double-carousel.tsx @@ -0,0 +1,52 @@ +import { Contract } from 'common/contract' +import { range } from 'lodash' +import { Carousel } from 'web/components/carousel' +import { ContractCard } from 'web/components/contract/contract-card' +import { ShowTime } from 'web/components/contract/contract-details' +import { Col } from 'web/components/layout/col' + +export function DoubleCarousel(props: { + contracts: Contract[] + seeMoreUrl?: string + showTime?: ShowTime + loadMore?: () => void +}) { + const { contracts, showTime, loadMore } = props + return ( + <Carousel className="-mx-4 mt-2 sm:-mx-10" loadMore={loadMore}> + <div className="shrink-0 sm:w-6" /> + {contracts.length >= 6 + ? range(0, Math.floor(contracts.length / 2)).map((col) => { + const i = col * 2 + return ( + <Col key={contracts[i].id}> + <ContractCard + contract={contracts[i]} + className="mb-2 w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + <ContractCard + contract={contracts[i + 1]} + className="mb-2 w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + </Col> + ) + }) + : contracts.map((c) => ( + <ContractCard + key={c.id} + contract={c} + className="mb-2 max-h-[220px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + showTime={showTime} + /> + ))} + </Carousel> + ) +} diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx new file mode 100644 index 00000000..1683b8d8 --- /dev/null +++ b/web/pages/experimental/home/index.tsx @@ -0,0 +1,204 @@ +import React, { useState } from 'react' +import Router from 'next/router' +import { PencilIcon, PlusSmIcon } from '@heroicons/react/solid' + +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 './_double-carousel' +import clsx from 'clsx' +import { Button } from 'web/components/button' +import { ArrangeHome } from './_arrange-home' +import { Title } from 'web/components/title' +import { Row } from 'web/components/layout/row' + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const creds = await authenticateOnServer(ctx) + const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null + return { props: { auth } } +} + +const Home = (props: { auth: { user: User } | null }) => { + const user = useUser() ?? props.auth?.user ?? null + + useTracking('view home') + + useSaveReferral() + + const memberGroups = useMemberGroups(user?.id) ?? [] + + 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) + } + + const [isEditing, setIsEditing] = useState(false) + + return ( + <Page> + <Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[125%]"> + <Row className={'w-full items-center justify-between'}> + <Title text={isEditing ? 'Edit your home page' : 'Home'} /> + + <EditDoneButton isEditing={isEditing} setIsEditing={setIsEditing} /> + </Row> + + {isEditing ? ( + <> + <ArrangeHome + user={user} + homeSections={homeSections} + setHomeSections={updateHomeSections} + /> + </> + ) : ( + homeSections.visible.map((id) => { + const sort = SORTS.find((sort) => sort.value === id) + if (sort) + return ( + <SearchSection + label={sort.label} + sort={sort.value} + user={user} + /> + ) + + const group = memberGroups.find((g) => g.id === id) + if (group) return <GroupSection group={group} user={user} /> + + return null + }) + )} + </Col> + <button + type="button" + className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" + onClick={() => { + Router.push('/create') + track('mobile create button') + }} + > + <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> + </button> + </Page> + ) +} + +function SearchSection(props: { + label: string + user: User | null + sort: Sort +}) { + const { label, user, sort } = props + const href = `/home?s=${sort}` + + return ( + <Col> + <SiteLink className="mb-2 text-xl" href={href}> + {label} + </SiteLink> + <ContractSearch + user={user} + defaultSort={sort} + 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 /> + ) + } + /> + </Col> + ) +} + +function GroupSection(props: { group: Group; user: User | null }) { + const { group, user } = props + + return ( + <Col> + <GroupLinkItem className="mb-2 text-xl" group={group} /> + <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 /> + ) + } + /> + </Col> + ) +} + +function EditDoneButton(props: { + isEditing: boolean + setIsEditing: (isEditing: boolean) => void + className?: string +}) { + const { isEditing, setIsEditing, 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> + ) +} + +export default Home diff --git a/yarn.lock b/yarn.lock index 0381fd46..be83129b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1310,6 +1310,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" + integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.12.7", "@babel/template@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" @@ -3316,6 +3323,14 @@ resolved "https://registry.yarnpkg.com/@types/hogan.js/-/hogan.js-3.0.1.tgz#64c54407b30da359763e14877f5702b8ae85d61c" integrity sha512-D03i/2OY7kGyMq9wdQ7oD8roE49z/ZCZThe/nbahtvuqCNZY9T2MfedOWyeBdbEpY2W8Gnh/dyJLdFtUCOkYbg== +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -3428,6 +3443,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/react-beautiful-dnd@13.1.2": + version "13.1.2" + resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz#510405abb09f493afdfd898bf83995dc6385c130" + integrity sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg== + dependencies: + "@types/react" "*" + "@types/react-dom@17.0.2": version "17.0.2" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.2.tgz#35654cf6c49ae162d5bc90843d5437dc38008d43" @@ -3435,6 +3457,16 @@ dependencies: "@types/react" "*" +"@types/react-redux@^7.1.20": + version "7.1.24" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0" + integrity sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react-router-config@*": version "5.0.6" resolved "https://registry.yarnpkg.com/@types/react-router-config/-/react-router-config-5.0.6.tgz#87c5c57e72d241db900d9734512c50ccec062451" @@ -4992,6 +5024,13 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + css-declaration-sorter@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.2.2.tgz#bfd2f6f50002d6a3ae779a87d3a0c5d5b10e0f02" @@ -7071,7 +7110,7 @@ hogan.js@^3.0.2: mkdirp "0.3.0" nopt "1.0.10" -hoist-non-react-statics@^3.1.0: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -8377,6 +8416,11 @@ memfs@^3.1.2, memfs@^3.4.3: dependencies: fs-monkey "1.0.3" +memoize-one@^5.1.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -9851,6 +9895,11 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +raf-schd@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" + integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== + raf@^3.1.0: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" @@ -9905,6 +9954,19 @@ react-base16-styling@^0.6.0: lodash.flow "^3.3.0" pure-color "^1.2.0" +react-beautiful-dnd@13.1.1: + version "13.1.1" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" + integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ== + dependencies: + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-confetti@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/react-confetti/-/react-confetti-6.0.1.tgz#d4f57b5a021dd908a6243b8f63b6009b00818d10" @@ -10012,6 +10074,11 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-json-view@^1.21.3: version "1.21.3" resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.21.3.tgz#f184209ee8f1bf374fb0c41b0813cff54549c475" @@ -10057,6 +10124,18 @@ react-query@3.39.0: broadcast-channel "^3.4.1" match-sorter "^6.0.2" +react-redux@^7.2.0: + version "7.2.8" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.8.tgz#a894068315e65de5b1b68899f9c6ee0923dd28de" + integrity sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/react-redux" "^7.1.20" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^17.0.2" + react-router-config@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" @@ -10208,6 +10287,13 @@ recursive-readdir@^2.2.2: dependencies: minimatch "3.0.4" +redux@^4.0.0, redux@^4.0.4: + version "4.2.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13" + integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA== + dependencies: + "@babel/runtime" "^7.9.2" + regenerate-unicode-properties@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56" @@ -11306,7 +11392,7 @@ thunky@^1.0.2: resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== -tiny-invariant@^1.0.2: +tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: version "1.2.0" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== @@ -11740,6 +11826,11 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-memo-one@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20" + integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ== + use-sync-external-store@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" From ccb6fd291e42e892641af5e81e9ff811e4f71abd Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 30 Aug 2022 23:53:12 -0500 Subject: [PATCH 165/279] Move components out of /pages into /components --- .../home/_arrange-home.tsx => components/arrange-home.tsx} | 0 .../_double-carousel.tsx => components/double-carousel.tsx} | 0 web/pages/experimental/home/index.tsx | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename web/{pages/experimental/home/_arrange-home.tsx => components/arrange-home.tsx} (100%) rename web/{pages/experimental/home/_double-carousel.tsx => components/double-carousel.tsx} (100%) diff --git a/web/pages/experimental/home/_arrange-home.tsx b/web/components/arrange-home.tsx similarity index 100% rename from web/pages/experimental/home/_arrange-home.tsx rename to web/components/arrange-home.tsx diff --git a/web/pages/experimental/home/_double-carousel.tsx b/web/components/double-carousel.tsx similarity index 100% rename from web/pages/experimental/home/_double-carousel.tsx rename to web/components/double-carousel.tsx diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 1683b8d8..7bd0d614 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -19,10 +19,10 @@ 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 './_double-carousel' +import { DoubleCarousel } from '../../../components/double-carousel' import clsx from 'clsx' import { Button } from 'web/components/button' -import { ArrangeHome } from './_arrange-home' +import { ArrangeHome } from '../../../components/arrange-home' import { Title } from 'web/components/title' import { Row } from 'web/components/layout/row' From a3569280a4a56527a03f3754efb73011ac7a6e4a Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 31 Aug 2022 00:30:31 -0500 Subject: [PATCH 166/279] Add your bets section to /experimental/home --- web/components/arrange-home.tsx | 71 ++++++++++++++++----------- web/components/contract-search.tsx | 5 ++ web/pages/experimental/home/index.tsx | 29 ++++++++--- 3 files changed, 71 insertions(+), 34 deletions(-) diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index cfae0142..2c43788c 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -9,6 +9,7 @@ import { useMemberGroups } from 'web/hooks/use-group' import { filterDefined } from 'common/util/array' import { keyBy } from 'lodash' import { User } from 'common/user' +import { Group } from 'common/group' export function ArrangeHome(props: { user: User | null @@ -18,35 +19,12 @@ export function ArrangeHome(props: { hidden: string[] }) => void }) { - const { - user, - homeSections: { visible, hidden }, - setHomeSections, - } = props + const { user, homeSections, setHomeSections } = props - const memberGroups = useMemberGroups(user?.id) ?? [] - - const items = [ - { label: 'Trending', id: 'score' }, - { label: 'Newest', id: 'newest' }, - { label: 'Close date', id: 'close-date' }, - ...memberGroups.map((g) => ({ - label: g.name, - id: g.id, - })), - ] - const itemsById = keyBy(items, 'id') - - const [visibleItems, hiddenItems] = [ - filterDefined(visible.map((id) => itemsById[id])), - filterDefined(hidden.map((id) => itemsById[id])), - ] - - // Add unmentioned items to the visible list. - visibleItems.push( - ...items.filter( - (item) => !visibleItems.includes(item) && !hiddenItems.includes(item) - ) + const groups = useMemberGroups(user?.id) ?? [] + const { itemsById, visibleItems, hiddenItems } = getHomeItems( + groups, + homeSections ) return ( @@ -125,3 +103,40 @@ function DraggableList(props: { </Droppable> ) } + +export const getHomeItems = ( + groups: Group[], + homeSections: { visible: string[]; hidden: string[] } +) => { + const items = [ + { label: 'Trending', id: 'score' }, + { label: 'Newest', id: 'newest' }, + { label: 'Close date', id: 'close-date' }, + { label: 'Your bets', id: 'your-bets' }, + ...groups.map((g) => ({ + label: g.name, + id: g.id, + })), + ] + const itemsById = keyBy(items, 'id') + + const { visible, hidden } = homeSections + + const [visibleItems, hiddenItems] = [ + filterDefined(visible.map((id) => itemsById[id])), + filterDefined(hidden.map((id) => itemsById[id])), + ] + + // Add unmentioned items to the visible list. + visibleItems.push( + ...items.filter( + (item) => !visibleItems.includes(item) && !hiddenItems.includes(item) + ) + ) + + return { + visibleItems, + hiddenItems, + itemsById, + } +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 75983f29..4b9f0713 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -65,6 +65,7 @@ type AdditionalFilter = { tag?: string excludeContractIds?: string[] groupSlug?: string + yourBets?: boolean } export function ContractSearch(props: { @@ -296,6 +297,10 @@ function ContractSearchControls(props: { additionalFilter?.groupSlug ? `groupLinks.slug:${additionalFilter.groupSlug}` : '', + additionalFilter?.yourBets && user + ? // Show contracts bet on by the user + `uniqueBettorIds:${user.id}` + : '', ] const facetFilters = query ? additionalFilters diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 7bd0d614..ae45d6ac 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -22,7 +22,7 @@ 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 } from '../../../components/arrange-home' +import { ArrangeHome, getHomeItems } from '../../../components/arrange-home' import { Title } from 'web/components/title' import { Row } from 'web/components/layout/row' @@ -39,11 +39,12 @@ const Home = (props: { auth: { user: User } | null }) => { useSaveReferral() - const memberGroups = useMemberGroups(user?.id) ?? [] + const groups = useMemberGroups(user?.id) ?? [] const [homeSections, setHomeSections] = useState( user?.homeSections ?? { visible: [], hidden: [] } ) + const { visibleItems } = getHomeItems(groups, homeSections) const updateHomeSections = (newHomeSections: { visible: string[] @@ -74,19 +75,33 @@ const Home = (props: { auth: { user: User } | null }) => { /> </> ) : ( - homeSections.visible.map((id) => { + visibleItems.map((item) => { + const { id } = item + if (id === 'your-bets') { + return ( + <SearchSection + key={id} + label={'Your bets'} + 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} /> ) - const group = memberGroups.find((g) => g.id === id) - if (group) return <GroupSection group={group} user={user} /> + const group = groups.find((g) => g.id === id) + if (group) + return <GroupSection key={id} group={group} user={user} /> return null }) @@ -110,8 +125,9 @@ function SearchSection(props: { label: string user: User | null sort: Sort + yourBets?: boolean }) { - const { label, user, sort } = props + const { label, user, sort, yourBets } = props const href = `/home?s=${sort}` return ( @@ -122,6 +138,7 @@ function SearchSection(props: { <ContractSearch user={user} defaultSort={sort} + additionalFilter={yourBets ? { yourBets: true } : undefined} noControls // persistPrefix={`experimental-home-${sort}`} renderContracts={(contracts, loadMore) => From d336383a937e66ca6715196189a45e87053f9640 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 31 Aug 2022 01:02:10 -0700 Subject: [PATCH 167/279] Fix my foolish bug --- web/pages/embed/[username]/[contractSlug].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 3f91baf7..a496bf91 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -153,7 +153,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {(isBinary || isPseudoNumeric) && ( <ContractProbGraph contract={contract} - bets={bets} + bets={[...bets].reverse()} height={graphHeight} /> )} From 27b46f4306b66114a3bc40b8298f764419fb142d Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 31 Aug 2022 01:16:57 -0700 Subject: [PATCH 168/279] Fix order of comments in threads and under answers --- web/components/feed/contract-activity.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 744f06aa..0878e570 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -84,7 +84,10 @@ export function ContractCommentsActivity(props: { user={user} contract={contract} parentComment={parent} - threadComments={commentsByParentId[parent.id] ?? []} + threadComments={sortBy( + commentsByParentId[parent.id] ?? [], + (c) => c.createdTime + )} tips={tips} bets={bets} betsByUserId={betsByUserId} @@ -132,7 +135,10 @@ export function FreeResponseContractCommentsActivity(props: { contract={contract} user={user} answer={answer} - answerComments={commentsByOutcome[answer.number.toString()] ?? []} + answerComments={sortBy( + commentsByOutcome[answer.number.toString()] ?? [], + (c) => c.createdTime + )} tips={tips} betsByUserId={betsByUserId} commentsByUserId={commentsByUserId} From 91e5abe76a78bd9d7428c62ce304a662d3530de8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 31 Aug 2022 08:03:51 -0600 Subject: [PATCH 169/279] Add query to help avoid timeout --- functions/src/reset-betting-streaks.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index 56e450fa..94f834b7 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -15,7 +15,10 @@ export const resetBettingStreaksForUsers = functions.pubsub }) const resetBettingStreaksInternal = async () => { - const usersSnap = await firestore.collection('users').get() + const usersSnap = await firestore + .collection('users') + .where('currentBettingStreak', '>', 0) + .get() const users = usersSnap.docs.map((doc) => doc.data() as User) From 5df594e46a153508fb5aea4fc1486f53e1520d7b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 31 Aug 2022 08:29:35 -0600 Subject: [PATCH 170/279] Make details fit on one line, make group a link --- web/components/contract/contract-details.tsx | 47 +++++++++++--------- web/components/user-link.tsx | 6 +-- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 2e76531b..df2b77cf 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -5,7 +5,6 @@ import { TrendingUpIcon, UserGroupIcon, } from '@heroicons/react/outline' -import Router from 'next/router' import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' @@ -162,13 +161,32 @@ export function ContractDetails(props: { const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 - const groupInfo = ( - <Row> - <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> - <span className="truncate"> - {groupToDisplay ? groupToDisplay.name : 'No group'} - </span> + const groupInfo = groupToDisplay ? ( + <Row + className={clsx( + 'items-center pr-2', + isMobile ? 'max-w-[140px]' : 'max-w-[250px]' + )} + > + <SiteLink href={groupPath(groupToDisplay.slug)} className={'truncate'}> + <Row> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="items-center truncate">{groupToDisplay.name}</span> + </Row> + </SiteLink> </Row> + ) : ( + <Button + size={'xs'} + className={'max-w-[200px] pr-2'} + color={'gray-white'} + onClick={() => !groupToDisplay && setOpen(true)} + > + <Row> + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="truncate">No Group</span> + </Row> + </Button> ) return ( @@ -199,19 +217,8 @@ export function ContractDetails(props: { <div /> ) : ( <Row> - <Button - size={'xs'} - className={'max-w-[200px] pr-2'} - color={'gray-white'} - onClick={() => - groupToDisplay - ? Router.push(groupPath(groupToDisplay.slug)) - : setOpen(!open) - } - > - {groupInfo} - </Button> - {user && ( + {groupInfo} + {user && groupToDisplay && ( <Button size={'xs'} color={'gray-white'} diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index a3bd9e9f..cc8f1a1f 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -9,14 +9,14 @@ import { formatMoney } from 'common/util/format' function shortenName(name: string) { const firstName = name.split(' ')[0] - const maxLength = 10 + const maxLength = 11 const shortName = - firstName.length >= 4 + firstName.length >= 3 && name.length > maxLength ? firstName.length < maxLength ? firstName : firstName.substring(0, maxLength - 3) + '...' : name.length > maxLength - ? name.substring(0, maxLength) + '...' + ? name.substring(0, maxLength - 3) + '...' : name return shortName } From 37d2be9384118a3392d7cd14992f4ee05c325151 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 31 Aug 2022 08:49:35 -0600 Subject: [PATCH 171/279] Show only relative time if same day on close date --- web/components/contract/contract-details.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index df2b77cf..7226aace 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -429,10 +429,13 @@ function EditableCloseDate(props: { className={isCreator ? 'cursor-pointer' : ''} onClick={() => isCreator && setIsEditingCloseTime(true)} > - {isSameYear - ? dayJsCloseTime.format('MMM D') - : dayJsCloseTime.format('MMM D, YYYY')} - {isSameDay && <> ({fromNow(closeTime)})</>} + {isSameDay ? ( + <span className={'capitalize'}> {fromNow(closeTime)}</span> + ) : isSameYear ? ( + dayJsCloseTime.format('MMM D') + ) : ( + dayJsCloseTime.format('MMM D, YYYY') + )} </span> </DateTimeTooltip> )} From 5a9d8e3f5d4627fc11490866b6ab8ae47b3a902c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 31 Aug 2022 09:27:37 -0600 Subject: [PATCH 172/279] Show how much you've tipped a market --- web/components/contract/like-market-button.tsx | 15 +++++++++++---- web/hooks/use-tip-txns.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx index 0fed0518..6ea6996d 100644 --- a/web/components/contract/like-market-button.tsx +++ b/web/components/contract/like-market-button.tsx @@ -1,6 +1,6 @@ import { HeartIcon } from '@heroicons/react/outline' import { Button } from 'web/components/button' -import React from 'react' +import React, { useMemo } from 'react' import { Contract } from 'common/contract' import { User } from 'common/user' import { useUserLikes } from 'web/hooks/use-likes' @@ -11,13 +11,20 @@ import { LIKE_TIP_AMOUNT } from 'common/like' import clsx from 'clsx' import { Col } from 'web/components/layout/col' import { firebaseLogin } from 'web/lib/firebase/users' +import { useMarketTipTxns } from 'web/hooks/use-tip-txns' +import { sum } from 'lodash' export function LikeMarketButton(props: { contract: Contract user: User | null | undefined }) { const { contract, user } = props - + const tips = useMarketTipTxns(contract.id).filter( + (txn) => txn.fromId === user?.id + ) + const totalTipped = useMemo(() => { + return sum(tips.map((tip) => tip.amount)) + }, [tips]) const likes = useUserLikes(user?.id) const userLikedContractIds = likes ?.filter((l) => l.type === 'contract') @@ -36,7 +43,7 @@ export function LikeMarketButton(props: { color={'gray-white'} onClick={onLike} > - <Col className={'sm:flex-row sm:gap-x-2'}> + <Col className={'items-center sm:flex-row sm:gap-x-2'}> <HeartIcon className={clsx( 'h-6 w-6', @@ -47,7 +54,7 @@ export function LikeMarketButton(props: { : '' )} /> - Tip + Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} </Col> </Button> ) diff --git a/web/hooks/use-tip-txns.ts b/web/hooks/use-tip-txns.ts index 50542402..8d26176f 100644 --- a/web/hooks/use-tip-txns.ts +++ b/web/hooks/use-tip-txns.ts @@ -29,3 +29,15 @@ export function useTipTxns(on: { }) }, [txns]) } + +export function useMarketTipTxns(contractId: string): TipTxn[] { + const [txns, setTxns] = useState<TipTxn[]>([]) + + useEffect(() => { + return listenForTipTxns(contractId, (txns) => { + setTxns(txns.filter((txn) => !txn.data.commentId)) + }) + }, [contractId]) + + return txns +} From 149204f6ca1f3162bd8c3f97af02f1b9baacc9d5 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 31 Aug 2022 11:17:36 -0700 Subject: [PATCH 173/279] Fix my other foolish bug --- web/components/contract/contract-overview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a7d3102f..272de6c5 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -131,7 +131,7 @@ export const ContractOverview = (props: { {(outcomeType === 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && ( <Col className={'mb-1 gap-y-2'}> - <AnswersGraph contract={contract} bets={bets} /> + <AnswersGraph contract={contract} bets={[...bets].reverse()} /> <ExtraMobileContractDetails contract={contract} user={user} From d06b725f52355dd3da28910d67e7cc7181677814 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 31 Aug 2022 11:29:49 -0700 Subject: [PATCH 174/279] Let admins add and edit posts to any group (#820) - show add post UI to admins - change firebase permissions --- firestore.rules | 6 +++--- web/components/groups/group-about-post.tsx | 12 +++--------- web/pages/group/[...slugs]/index.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/firestore.rules b/firestore.rules index 7b263e1a..e42e3ed7 100644 --- a/firestore.rules +++ b/firestore.rules @@ -162,7 +162,7 @@ service cloud.firestore { match /groups/{groupId} { allow read; - allow update: if request.auth.uid == resource.data.creatorId + allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]); @@ -183,11 +183,11 @@ service cloud.firestore { match /posts/{postId} { allow read; - allow update: if request.auth.uid == resource.data.creatorId + allow update: if isAdmin() || request.auth.uid == resource.data.creatorId && request.resource.data.diff(resource.data) .affectedKeys() .hasOnly(['name', 'content']); - allow delete: if request.auth.uid == resource.data.creatorId; + allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; } } } diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-about-post.tsx index 1b42c04d..ed5c20cc 100644 --- a/web/components/groups/group-about-post.tsx +++ b/web/components/groups/group-about-post.tsx @@ -1,4 +1,3 @@ -import { useAdmin } from 'web/hooks/use-admin' import { Row } from '../layout/row' import { Content } from '../editor' import { TextEditor, useTextEditor } from 'web/components/editor' @@ -16,20 +15,15 @@ import { usePost } from 'web/hooks/use-post' export function GroupAboutPost(props: { group: Group - isCreator: boolean + isEditable: boolean post: Post }) { - const { group, isCreator } = props + const { group, isEditable } = props const post = usePost(group.aboutPostId) ?? props.post - const isAdmin = useAdmin() - - if (group.aboutPostId == null && !isCreator) { - return <p className="text-center">No post has been created </p> - } return ( <div className="rounded-md bg-white p-4"> - {isCreator || isAdmin ? ( + {isEditable ? ( <RichEditGroupAboutPost group={group} post={post} /> ) : ( <Content content={post.content} /> diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 5c22dbb6..4b391b36 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -50,6 +50,7 @@ import { getPost } from 'web/lib/firebase/posts' 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' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -158,6 +159,7 @@ export default function GroupPage(props: { const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost const user = useUser() + const isAdmin = useAdmin() useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -186,14 +188,12 @@ export default function GroupPage(props: { const aboutTab = ( <Col> - {group.aboutPostId != null || isCreator ? ( + {(group.aboutPostId != null || isCreator || isAdmin) && ( <GroupAboutPost group={group} - isCreator={!!isCreator} + isEditable={!!isCreator || isAdmin} post={aboutPost} /> - ) : ( - <div></div> )} <Spacer h={3} /> <GroupOverview From 83696cca213333a2488bb54e63634fd9be843ad9 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 31 Aug 2022 15:35:34 -0500 Subject: [PATCH 175/279] Fix dayjs fromNow bug (it requires plugin, so use our util instead) --- web/components/relative-timestamp.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/components/relative-timestamp.tsx b/web/components/relative-timestamp.tsx index 53cf2a3a..d4b1c189 100644 --- a/web/components/relative-timestamp.tsx +++ b/web/components/relative-timestamp.tsx @@ -1,16 +1,15 @@ import { DateTimeTooltip } from './datetime-tooltip' -import dayjs from 'dayjs' import React from 'react' +import { fromNow } from 'web/lib/util/time' export function RelativeTimestamp(props: { time: number }) { const { time } = props - const dayJsTime = dayjs(time) return ( <DateTimeTooltip className="ml-1 whitespace-nowrap text-gray-400" time={time} > - {dayJsTime.fromNow()} + {fromNow(time)} </DateTimeTooltip> ) } From 3660830ec14ebc0696b7252eb764ebbb46969ee4 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 31 Aug 2022 15:41:34 -0500 Subject: [PATCH 176/279] Don't server side render Notifications page for improved perf --- web/pages/notifications.tsx | 63 ++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index f1995568..2b2e8d7a 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,5 +1,6 @@ import { Tabs } from 'web/components/layout/tabs' import React, { useEffect, useMemo, useState } from 'react' +import Router from 'next/router' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' @@ -12,7 +13,6 @@ import { MANIFOLD_USERNAME, PrivateUser, } from 'common/user' -import { getUserAndPrivateUser } from 'web/lib/firebase/users' import clsx from 'clsx' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' @@ -39,11 +39,10 @@ import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' import { safeLocalStorage } from 'web/lib/util/local' -import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { MultiUserTipLink, MultiUserLinkInfo, @@ -54,14 +53,12 @@ import { LoadingIndicator } from 'web/components/loading-indicator' export const NOTIFICATIONS_PER_PAGE = 30 const HIGHLIGHT_CLASS = 'bg-indigo-50' -export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } -}) +export default function Notifications() { + const privateUser = usePrivateUser() -export default function Notifications(props: { - auth: { privateUser: PrivateUser } -}) { - const { privateUser } = props.auth + useEffect(() => { + if (privateUser === null) Router.push('/') + }) return ( <Page> @@ -69,28 +66,30 @@ export default function Notifications(props: { <Title text={'Notifications'} className={'hidden md:block'} /> <SEO title="Notifications" description="Manifold user notifications" /> - <div> - <Tabs - currentPageForAnalytics={'notifications'} - labelClassName={'pb-2 pt-1 '} - className={'mb-0 sm:mb-2'} - defaultIndex={0} - tabs={[ - { - title: 'Notifications', - content: <NotificationsList privateUser={privateUser} />, - }, - { - title: 'Settings', - content: ( - <div className={''}> - <NotificationSettings /> - </div> - ), - }, - ]} - /> - </div> + {privateUser && ( + <div> + <Tabs + currentPageForAnalytics={'notifications'} + labelClassName={'pb-2 pt-1 '} + className={'mb-0 sm:mb-2'} + defaultIndex={0} + tabs={[ + { + title: 'Notifications', + content: <NotificationsList privateUser={privateUser} />, + }, + { + title: 'Settings', + content: ( + <div className={''}> + <NotificationSettings /> + </div> + ), + }, + ]} + /> + </div> + )} </div> </Page> ) From 7c8b33597ae6cd7566cc05782bfb9097e405e8dd Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 31 Aug 2022 14:33:24 -0700 Subject: [PATCH 177/279] Add "Duplicate Contract" into "..." menu --- web/components/contract/contract-info-dialog.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index aaa3cad6..f376a04a 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -16,6 +16,8 @@ import { SiteLink } from '../site-link' import { firestoreConsolePath } from 'common/envs/constants' import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' +import { DuplicateContractButton } from '../copy-contract-button' +import { Row } from '../layout/row' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -185,6 +187,9 @@ export function ContractInfoDialog(props: { </tbody> </table> + <Row className="flex-wrap"> + <DuplicateContractButton contract={contract} /> + </Row> {contract.mechanism === 'cpmm-1' && !contract.resolution && ( <LiquidityPanel contract={contract} /> )} From 26aba26da538fa978f1b0f205f5e09647709e362 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 31 Aug 2022 15:38:55 -0600 Subject: [PATCH 178/279] force long polling (#824) --- web/lib/firebase/init.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index bf712a8f..b9c96a9b 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,13 +1,18 @@ -import { getFirestore } from '@firebase/firestore' import { initializeApp, getApps, getApp } from 'firebase/app' import { getStorage } from 'firebase/storage' import { FIREBASE_CONFIG } from 'common/envs/constants' -import { connectFirestoreEmulator } from 'firebase/firestore' +import { + connectFirestoreEmulator, + initializeFirestore, +} from 'firebase/firestore' import { connectFunctionsEmulator, getFunctions } from 'firebase/functions' // Initialize Firebase export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) -export const db = getFirestore() + +export const db = initializeFirestore(app, { + experimentalForceLongPolling: true, +}) export const functions = getFunctions() export const storage = getStorage() From 74b6df2e4430d42b24fe741d6c22d990ebc62e0c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 31 Aug 2022 16:18:48 -0600 Subject: [PATCH 179/279] Unwatch applies to email comment notifs too --- functions/src/create-notification.ts | 14 ++++++++++++++ functions/src/on-create-comment-on-contract.ts | 16 +++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 9c5d98c1..8ed14704 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -723,3 +723,17 @@ export const createLikeNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export async function filterUserIdsForOnlyFollowerIds( + userIds: string[], + contractId: string +) { + // get contract follower documents and check here if they're a follower + const contractFollowersSnap = await firestore + .collection(`contracts/${contractId}/follows`) + .get() + const contractFollowersIds = contractFollowersSnap.docs.map( + (doc) => doc.data().id + ) + return userIds.filter((id) => contractFollowersIds.includes(id)) +} diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 8651bde0..663a7977 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -6,7 +6,10 @@ import { ContractComment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { + createCommentOrAnswerOrUpdatedContractNotification, + filterUserIdsForOnlyFollowerIds, +} from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -95,10 +98,13 @@ export const onCreateCommentOnContract = functions } ) - const recipientUserIds = uniq([ - contract.creatorId, - ...comments.map((comment) => comment.userId), - ]).filter((id) => id !== comment.userId) + const recipientUserIds = await filterUserIdsForOnlyFollowerIds( + uniq([ + contract.creatorId, + ...comments.map((comment) => comment.userId), + ]).filter((id) => id !== comment.userId), + contractId + ) await Promise.all( recipientUserIds.map((userId) => From 7a9b1599098fecfef99517484f9ce16d9ec430a2 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 31 Aug 2022 15:40:23 -0700 Subject: [PATCH 180/279] Update awesome-manifold.md --- docs/docs/awesome-manifold.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 458b81ee..cb96cfff 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -24,3 +24,11 @@ A list of community-created projects built on, or related to, Manifold Markets. - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon - [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets +- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae) + +## Writeups +- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander +- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki +- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania +- [What I learned about running a betting market game night contest](https://shakeddown.wordpress.com/2022/08/04/what-i-learned-about-running-a-betting-market-game-night-contest/) by shakeddown +- [Free-riding on prediction markets](https://pedunculate.substack.com/p/free-riding-on-prediction-markets) by John Roxton From 5514eeff2d12518796de02a7731bfb56e75e739b Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 31 Aug 2022 16:18:53 -0700 Subject: [PATCH 181/279] Update awesome-manifold.md --- docs/docs/awesome-manifold.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index cb96cfff..cc012002 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -32,3 +32,8 @@ A list of community-created projects built on, or related to, Manifold Markets. - [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania - [What I learned about running a betting market game night contest](https://shakeddown.wordpress.com/2022/08/04/what-i-learned-about-running-a-betting-market-game-night-contest/) by shakeddown - [Free-riding on prediction markets](https://pedunculate.substack.com/p/free-riding-on-prediction-markets) by John Roxton + +## Art + +- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png) +- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg) From bc1ec414deb80b93042af0cae3d5010fd98f1824 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 31 Aug 2022 16:29:42 -0700 Subject: [PATCH 182/279] Update awesome-manifold.md --- docs/docs/awesome-manifold.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index cc012002..7a30fed6 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -15,6 +15,7 @@ A list of community-created projects built on, or related to, Manifold Markets. ## API / Dev - [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API + - [PyManifold fork](https://github.com/gappleto97/PyManifold/) - Fork maintained by [@LivInTheLookingGlass](https://manifold.markets/LivInTheLookingGlass) - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets From 58e671e640251ccc7eda049f978a09e6e996eef2 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 31 Aug 2022 17:18:35 -0700 Subject: [PATCH 183/279] Upload dropped images --- web/components/editor.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 5f056f8b..d7836c34 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -108,10 +108,7 @@ export function useTextEditor(props: { editor?.setOptions({ editorProps: { handlePaste(view, event) { - const imageFiles = Array.from(event.clipboardData?.files ?? []).filter( - (file) => file.type.startsWith('image') - ) - + const imageFiles = getImages(event.clipboardData) if (imageFiles.length) { event.preventDefault() upload.mutate(imageFiles) @@ -126,6 +123,13 @@ export function useTextEditor(props: { return // Otherwise, use default paste handler }, + handleDrop(_view, event, _slice, moved) { + // if dragged from outside + if (!moved) { + event.preventDefault() + upload.mutate(getImages(event.dataTransfer)) + } + }, }, }) @@ -136,6 +140,9 @@ export function useTextEditor(props: { return { editor, upload } } +const getImages = (data: DataTransfer | null) => + Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image')) + function isValidIframe(text: string) { return /^<iframe.*<\/iframe>$/.test(text) } From ee76f4188b35646b8b56197f10300de7134ad729 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Wed, 31 Aug 2022 21:57:11 -0500 Subject: [PATCH 184/279] For you: remove contracts bet on by anyone you follow. --- web/components/contract-search.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 4b9f0713..f8b7622e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -323,10 +323,6 @@ function ContractSearchControls(props: { .map((slug) => `groupLinks.slug:${slug}`) // Show contracts created by users the user follows .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) - // Show contracts bet on by users the user follows - .concat( - follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] - ) : '', // Subtract contracts you bet on from For you. state.pillFilter === 'personal' && user From e0ebdc644db92bddf64638a405380dcc58ef31b3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 31 Aug 2022 22:33:37 -0500 Subject: [PATCH 185/279] market close email: remove mention of creator fee --- functions/src/email-templates/market-close.html | 3 +-- functions/src/emails.ts | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 01a53e98..fa44c1d5 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -351,8 +351,7 @@ font-size: 16px; margin: 0; " /> - Resolve your market to earn {{creatorFee}} as the - creator commission. + Please resolve your market. <br style=" font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; diff --git a/functions/src/emails.ts b/functions/src/emails.ts index ff313794..c1fd9aac 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -339,10 +339,6 @@ export const sendMarketCloseEmail = async ( userId, name: firstName, volume: formatMoney(volume), - creatorFee: - mechanism === 'dpm-2' - ? `${DPM_CREATOR_FEE * 100}% of the profits` - : formatMoney(collectedFees.creatorFee), } ) } From 2c3cd3444494c707016722ff915c004ac5c3b06c Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Thu, 1 Sep 2022 03:34:22 +0000 Subject: [PATCH 186/279] Auto-remove unused imports --- functions/src/emails.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index c1fd9aac..7b38d6bd 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -4,7 +4,6 @@ import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' import { formatLargeNumber, From 7c1e663b264b05ed62aaf942e4df2f987d1c0fd1 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Wed, 31 Aug 2022 20:52:12 -0700 Subject: [PATCH 187/279] Editor tweaks (#829) * Show border around selected embeds * Make editor tooltips not animate --- web/components/editor.tsx | 10 ++++++---- web/components/tooltip.tsx | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index d7836c34..c15d17b1 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -73,7 +73,9 @@ export function useTextEditor(props: { const editorClass = clsx( proseClass, !simple && 'min-h-[6em]', - 'outline-none pt-2 px-4' + 'outline-none pt-2 px-4', + 'prose-img:select-auto', + '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds ) const editor = useEditor( @@ -164,7 +166,7 @@ export function TextEditor(props: { <EditorContent editor={editor} /> {/* Toolbar, with buttons for images and embeds */} <div className="flex h-9 items-center gap-5 pl-4 pr-1"> - <Tooltip className="flex items-center" text="Add image" noTap> + <Tooltip text="Add image" noTap noFade> <FileUploadButton onFiles={upload.mutate} className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" @@ -172,7 +174,7 @@ export function TextEditor(props: { <PhotographIcon className="h-5 w-5" aria-hidden="true" /> </FileUploadButton> </Tooltip> - <Tooltip className="flex items-center" text="Add embed" noTap> + <Tooltip text="Add embed" noTap noFade> <button type="button" onClick={() => setIframeOpen(true)} @@ -186,7 +188,7 @@ export function TextEditor(props: { <CodeIcon className="h-5 w-5" aria-hidden="true" /> </button> </Tooltip> - <Tooltip className="flex items-center" text="Add market" noTap> + <Tooltip text="Add market" noTap noFade> <button type="button" onClick={() => setMarketOpen(true)} diff --git a/web/components/tooltip.tsx b/web/components/tooltip.tsx index 4dd1f6e2..418be88e 100644 --- a/web/components/tooltip.tsx +++ b/web/components/tooltip.tsx @@ -21,8 +21,9 @@ export function Tooltip(props: { className?: string placement?: Placement noTap?: boolean + noFade?: boolean }) { - const { text, children, className, placement = 'top', noTap } = props + const { text, children, className, placement = 'top', noTap, noFade } = props const arrowRef = useRef(null) @@ -64,10 +65,10 @@ export function Tooltip(props: { {/* conditionally render tooltip and fade in/out */} <Transition show={open} - enter="transition ease-out duration-200" - enterFrom="opacity-0 " + enter="transition ease-out duration-50" + enterFrom="opacity-0" enterTo="opacity-100" - leave="transition ease-in duration-150" + leave={noFade ? '' : 'transition ease-in duration-150'} leaveFrom="opacity-100" leaveTo="opacity-0" // div attributes From 2a17bcb8b259d687b86ef693009d7a428d862d89 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 31 Aug 2022 23:00:37 -0500 Subject: [PATCH 188/279] eslint --- functions/src/emails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 7b38d6bd..b37f8da0 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -321,7 +321,7 @@ export const sendMarketCloseEmail = async ( const { username, name, id: userId } = user const firstName = name.split(' ')[0] - const { question, slug, volume, mechanism, collectedFees } = contract + const { question, slug, volume } = contract const url = `https://${DOMAIN}/${username}/${slug}` const emailType = 'market-resolve' From 879d6fb2dd7b6dd371e3e03cbe4b06adf14f4ccb Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 31 Aug 2022 23:20:20 -0500 Subject: [PATCH 189/279] bury profile stats in Comments until we find a better place for them --- web/components/user-page.tsx | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 8312f16e..fd00888e 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -253,7 +253,18 @@ export function UserPage(props: { user: User }) { }, { title: 'Comments', - content: <UserCommentsList user={user} />, + 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> + ), }, { title: 'Bets', @@ -264,20 +275,6 @@ export function UserPage(props: { user: User }) { </> ), }, - { - title: 'Social', - content: ( - <Row - className={'mt-2 flex-wrap items-center justify-center gap-6'} - > - <FollowingButton user={user} /> - <FollowersButton user={user} /> - <ReferralsButton user={user} /> - <GroupsButton user={user} /> - <UserLikesButton user={user} /> - </Row> - ), - }, ]} /> </Col> From 42548cea2ac59f5c94c0bd5e579f8e9bfb162e45 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 31 Aug 2022 21:59:58 -0700 Subject: [PATCH 190/279] Fix prefetching to not populate useless state (#827) --- web/hooks/use-contracts.ts | 8 ++++++++ web/hooks/use-portfolio-history.ts | 19 ++++++++++++++++--- web/hooks/use-prefetch.ts | 12 ++++++------ web/hooks/use-user-bets.ts | 6 ++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 3ec1c56c..83be4636 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -11,6 +11,7 @@ import { listenForNewContracts, getUserBetContractsQuery, } from 'web/lib/firebase/contracts' +import { QueryClient } from 'react-query' export const useContracts = () => { const [contracts, setContracts] = useState<Contract[] | undefined>() @@ -92,6 +93,13 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => { : undefined } +const queryClient = new QueryClient() + +export const prefetchUserBetContracts = (userId: string) => + queryClient.prefetchQuery(['contracts', 'bets', userId], () => + getUserBetContractsQuery(userId) + ) + export const useUserBetContracts = (userId: string) => { const result = useFirestoreQueryData( ['contracts', 'bets', userId], diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts index d01ca29b..5abfdf11 100644 --- a/web/hooks/use-portfolio-history.ts +++ b/web/hooks/use-portfolio-history.ts @@ -1,11 +1,24 @@ +import { QueryClient } from 'react-query' import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { DAY_MS, HOUR_MS } from 'common/util/time' import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users' -export const usePortfolioHistory = (userId: string, period: Period) => { - const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS - const cutoff = periodToCutoff(nowRounded, period).valueOf() +const queryClient = new QueryClient() +const getCutoff = (period: Period) => { + const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS + return periodToCutoff(nowRounded, period).valueOf() +} + +export const prefetchPortfolioHistory = (userId: string, period: Period) => { + const cutoff = getCutoff(period) + return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () => + getPortfolioHistoryQuery(userId, cutoff) + ) +} + +export const usePortfolioHistory = (userId: string, period: Period) => { + const cutoff = getCutoff(period) const result = useFirestoreQueryData( ['portfolio-history', userId, cutoff], getPortfolioHistoryQuery(userId, cutoff) diff --git a/web/hooks/use-prefetch.ts b/web/hooks/use-prefetch.ts index e22e13eb..3724d456 100644 --- a/web/hooks/use-prefetch.ts +++ b/web/hooks/use-prefetch.ts @@ -1,11 +1,11 @@ -import { useUserBetContracts } from './use-contracts' -import { usePortfolioHistory } from './use-portfolio-history' -import { useUserBets } from './use-user-bets' +import { prefetchUserBetContracts } from './use-contracts' +import { prefetchPortfolioHistory } from './use-portfolio-history' +import { prefetchUserBets } from './use-user-bets' export function usePrefetch(userId: string | undefined) { const maybeUserId = userId ?? '' - useUserBets(maybeUserId) - useUserBetContracts(maybeUserId) - usePortfolioHistory(maybeUserId, 'weekly') + prefetchUserBets(maybeUserId) + prefetchUserBetContracts(maybeUserId) + prefetchPortfolioHistory(maybeUserId, 'weekly') } diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index ff1b23b3..a989636f 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -1,3 +1,4 @@ +import { QueryClient } from 'react-query' import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useEffect, useState } from 'react' import { @@ -6,6 +7,11 @@ import { listenForUserContractBets, } from 'web/lib/firebase/bets' +const queryClient = new QueryClient() + +export const prefetchUserBets = (userId: string) => + queryClient.prefetchQuery(['bets', userId], () => getUserBetsQuery(userId)) + export const useUserBets = (userId: string) => { const result = useFirestoreQueryData( ['bets', userId], From 0568322c82fe0a39ee8b6d6f7c002a0262d59a1c Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Wed, 31 Aug 2022 22:13:26 -0700 Subject: [PATCH 191/279] Dramatically improve server auth stuff (#826) --- common/envs/constants.ts | 5 + web/components/auth-context.tsx | 22 ++- web/lib/firebase/auth.ts | 74 ---------- web/lib/firebase/server-auth.ts | 198 ++++++++------------------ web/pages/[username]/index.tsx | 2 +- web/pages/create.tsx | 2 +- web/pages/experimental/home/index.tsx | 2 +- web/pages/home.tsx | 2 +- web/pages/links.tsx | 2 +- web/pages/profile.tsx | 2 +- 10 files changed, 84 insertions(+), 227 deletions(-) delete mode 100644 web/lib/firebase/auth.ts diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 89d040e8..ba460d58 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' +export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace( + /-/g, + '_' +)}` + // Manifold's domain or any subdomains thereof export const CORS_ORIGIN_MANIFOLD = new RegExp( '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 7347d039..0e9fbd0e 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -8,17 +8,20 @@ import { getUserAndPrivateUser, setCachedReferralInfoForUser, } from 'web/lib/firebase/users' -import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth' import { createUser } from 'web/lib/firebase/api' import { randomString } from 'common/util/random' import { identifyUser, setUserProperty } from 'web/lib/service/analytics' import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' +import { AUTH_COOKIE_NAME } from 'common/envs/constants' +import { setCookie } from 'web/lib/util/cookie' // Either we haven't looked up the logged in user yet (undefined), or we know // the user is not logged in (null), or we know the user is logged in. type AuthUser = undefined | null | UserAndPrivateUser +const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' + // Proxy localStorage in case it's not available (eg in incognito iframe) const localStorage = typeof window !== 'undefined' @@ -38,6 +41,16 @@ const ensureDeviceToken = () => { return deviceToken } +export const setUserCookie = (cookie: string | undefined) => { + const data = setCookie(AUTH_COOKIE_NAME, cookie ?? '', [ + ['path', '/'], + ['max-age', (cookie === undefined ? 0 : TEN_YEARS_SECS).toString()], + ['samesite', 'lax'], + ['secure'], + ]) + document.cookie = data +} + export const AuthContext = createContext<AuthUser>(undefined) export function AuthProvider(props: { @@ -59,10 +72,7 @@ export function AuthProvider(props: { auth, async (fbUser) => { if (fbUser) { - setTokenCookies({ - id: await fbUser.getIdToken(), - refresh: fbUser.refreshToken, - }) + setUserCookie(JSON.stringify(fbUser.toJSON())) let current = await getUserAndPrivateUser(fbUser.uid) if (!current.user || !current.privateUser) { const deviceToken = ensureDeviceToken() @@ -75,7 +85,7 @@ export function AuthProvider(props: { setCachedReferralInfoForUser(current.user) } else { // User logged out; reset to null - deleteTokenCookies() + setUserCookie(undefined) setAuthUser(null) localStorage.removeItem(CACHED_USER_KEY) } diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts deleted file mode 100644 index 5363aa08..00000000 --- a/web/lib/firebase/auth.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { PROJECT_ID } from 'common/envs/constants' -import { setCookie, getCookies } from '../util/cookie' -import { IncomingMessage, ServerResponse } from 'http' - -const ONE_HOUR_SECS = 60 * 60 -const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 -const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const -const TOKEN_AGES = { - id: ONE_HOUR_SECS, - refresh: TEN_YEARS_SECS, - custom: ONE_HOUR_SECS, -} as const -export type TokenKind = typeof TOKEN_KINDS[number] - -const getAuthCookieName = (kind: TokenKind) => { - const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_') - return `FIREBASE_TOKEN_${suffix}` -} - -const COOKIE_NAMES = Object.fromEntries( - TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)]) -) as Record<TokenKind, string> - -const getCookieDataIsomorphic = (req?: IncomingMessage) => { - if (req != null) { - return req.headers.cookie ?? '' - } else if (document != null) { - return document.cookie - } else { - throw new Error( - 'Neither request nor document is available; no way to get cookies.' - ) - } -} - -const setCookieDataIsomorphic = (cookies: string[], res?: ServerResponse) => { - if (res != null) { - res.setHeader('Set-Cookie', cookies) - } else if (document != null) { - for (const ck of cookies) { - document.cookie = ck - } - } else { - throw new Error( - 'Neither response nor document is available; no way to set cookies.' - ) - } -} - -export const getTokensFromCookies = (req?: IncomingMessage) => { - const cookies = getCookies(getCookieDataIsomorphic(req)) - return Object.fromEntries( - TOKEN_KINDS.map((k) => [k, cookies[COOKIE_NAMES[k]]]) - ) as Partial<Record<TokenKind, string>> -} - -export const setTokenCookies = ( - cookies: Partial<Record<TokenKind, string | undefined>>, - res?: ServerResponse -) => { - const data = TOKEN_KINDS.filter((k) => k in cookies).map((k) => { - const maxAge = cookies[k] ? TOKEN_AGES[k as TokenKind] : 0 - return setCookie(COOKIE_NAMES[k], cookies[k] ?? '', [ - ['path', '/'], - ['max-age', maxAge.toString()], - ['samesite', 'lax'], - ['secure'], - ]) - }) - setCookieDataIsomorphic(data, res) -} - -export const deleteTokenCookies = (res?: ServerResponse) => - setTokenCookies({ id: undefined, refresh: undefined, custom: undefined }, res) diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts index ff6592e2..989767d0 100644 --- a/web/lib/firebase/server-auth.ts +++ b/web/lib/firebase/server-auth.ts @@ -1,165 +1,81 @@ -import fetch from 'node-fetch' import { IncomingMessage, ServerResponse } from 'http' -import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants' -import { getFunctionUrl } from 'common/api' -import { UserCredential } from 'firebase/auth' -import { - getTokensFromCookies, - setTokenCookies, - deleteTokenCookies, -} from './auth' +import { Auth as FirebaseAuth, User as FirebaseUser } from 'firebase/auth' +import { AUTH_COOKIE_NAME } from 'common/envs/constants' +import { getCookies } from 'web/lib/util/cookie' import { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult, } from 'next' -// server firebase SDK -import * as admin from 'firebase-admin' - // client firebase SDK import { app as clientApp } from './init' -import { getAuth, signInWithCustomToken } from 'firebase/auth' - -const ensureApp = async () => { - // Note: firebase-admin can only be imported from a server context, - // because it relies on Node standard library dependencies. - if (admin.apps.length === 0) { - // never initialize twice - return admin.initializeApp({ projectId: PROJECT_ID }) - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return admin.apps[0]! -} - -const requestFirebaseIdToken = async (refreshToken: string) => { - // See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token - const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token') - refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey) - const result = await fetch(refreshUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - }), - }) - if (!result.ok) { - throw new Error(`Could not refresh ID token: ${await result.text()}`) - } - return (await result.json()) as { id_token: string; refresh_token: string } -} - -const requestManifoldCustomToken = async (idToken: string) => { - const functionUrl = getFunctionUrl('getcustomtoken') - const result = await fetch(functionUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${idToken}`, - }, - }) - if (!result.ok) { - throw new Error(`Could not get custom token: ${await result.text()}`) - } - return (await result.json()) as { token: string } -} +import { getAuth, updateCurrentUser } from 'firebase/auth' type RequestContext = { req: IncomingMessage res: ServerResponse } -const authAndRefreshTokens = async (ctx: RequestContext) => { - const adminAuth = (await ensureApp()).auth() - const clientAuth = getAuth(clientApp) - console.debug('Initialized Firebase auth libraries.') +// The Firebase SDK doesn't really support persisting the logged-in state between +// devices, or anything like that. To get it from the client to the server: +// +// 1. We pack up the user by calling (the undocumented) User.toJSON(). This is the +// same way the Firebase SDK saves it to disk, so it's gonna have the right stuff. +// +// 2. We put it into a cookie and read the cookie out here. +// +// 3. We use the Firebase "persistence manager" to write the cookie value into the persistent +// store on the server (an in-memory store), just as if the SDK had saved the user itself. +// +// 4. We ask the persistence manager for the current user, which reads what we just wrote, +// and creates a real puffed-up internal user object from the serialized user. +// +// 5. We set that user to be the current Firebase user in the SDK. +// +// 6. We ask for the ID token, which will refresh it if necessary (i.e. if this cookie +// is from an old browser session), so that we know the SDK is prepared to do real +// Firebase queries. +// +// This strategy should be robust, since it's repurposing Firebase's internal persistence +// machinery, but the details may eventually need updating for new versions of the SDK. +// +// References: +// Persistence manager: https://github.com/firebase/firebase-js-sdk/blob/39f4635ebc07316661324145f1b8c27f9bd7aedb/packages/auth/src/core/persistence/persistence_user_manager.ts#L64 +// Token manager: https://github.com/firebase/firebase-js-sdk/blob/39f4635ebc07316661324145f1b8c27f9bd7aedb/packages/auth/src/core/user/token_manager.ts#L76 - let { id, refresh, custom } = getTokensFromCookies(ctx.req) - - // step 0: if you have no refresh token you are logged out - if (refresh == null) { - console.debug('User is unauthenticated.') - return null - } - - console.debug('User may be authenticated; checking cookies.') - - // step 1: given a valid refresh token, ensure a valid ID token - if (id != null) { - // if they have an ID token, throw it out if it's invalid/expired - try { - await adminAuth.verifyIdToken(id) - console.debug('Verified ID token.') - } catch { - id = undefined - console.debug('Invalid existing ID token.') +interface FirebaseAuthInternal extends FirebaseAuth { + persistenceManager: { + fullUserKey: string + getCurrentUser: () => Promise<FirebaseUser | null> + persistence: { + _set: (k: string, obj: Record<string, unknown>) => Promise<void> } } - if (id == null) { - // ask for a new one from google using the refresh token - try { - const resp = await requestFirebaseIdToken(refresh) - console.debug('Obtained fresh ID token from Firebase.') - id = resp.id_token - refresh = resp.refresh_token - } catch (e) { - // big unexpected problem -- functionally, they are not logged in - console.error(e) - return null - } - } - - // step 2: given a valid ID token, ensure a valid custom token, and sign in - // to the client SDK with the custom token - if (custom != null) { - // sign in with this token, or throw it out if it's invalid/expired - try { - const creds = await signInWithCustomToken(clientAuth, custom) - console.debug('Signed in with custom token.') - return { creds, id, refresh, custom } - } catch { - custom = undefined - console.debug('Invalid existing custom token.') - } - } - if (custom == null) { - // ask for a new one from our cloud functions using the ID token, then sign in - try { - const resp = await requestManifoldCustomToken(id) - console.debug('Obtained fresh custom token from backend.') - custom = resp.token - const creds = await signInWithCustomToken(clientAuth, custom) - console.debug('Signed in with custom token.') - return { creds, id, refresh, custom } - } catch (e) { - // big unexpected problem -- functionally, they are not logged in - console.error(e) - return null - } - } - return null } export const authenticateOnServer = async (ctx: RequestContext) => { - console.debug('Server authentication sequence starting.') - const tokens = await authAndRefreshTokens(ctx) - console.debug('Finished checking and refreshing tokens.') - const creds = tokens?.creds - try { - if (tokens == null) { - deleteTokenCookies(ctx.res) - console.debug('Not logged in; cleared token cookies.') - } else { - setTokenCookies(tokens, ctx.res) - console.debug('Logged in; set current token cookies.') - } - } catch (e) { - // definitely not supposed to happen, but let's be maximally robust - console.error(e) + const user = getCookies(ctx.req.headers.cookie ?? '')[AUTH_COOKIE_NAME] + if (user == null) { + console.debug('User is unauthenticated.') + return null + } + try { + const deserializedUser = JSON.parse(user) + const clientAuth = getAuth(clientApp) as FirebaseAuthInternal + const persistenceManager = clientAuth.persistenceManager + const persistence = persistenceManager.persistence + await persistence._set(persistenceManager.fullUserKey, deserializedUser) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const fbUser = (await persistenceManager.getCurrentUser())! + await fbUser.getIdToken() // forces a refresh if necessary + await updateCurrentUser(clientAuth, fbUser) + console.debug('Signed in with user from cookie.') + return fbUser + } catch (e) { + console.error(e) + return null } - return creds ?? null } // note that we might want to define these types more generically if we want better @@ -167,7 +83,7 @@ export const authenticateOnServer = async (ctx: RequestContext) => { type GetServerSidePropsAuthed<P> = ( context: GetServerSidePropsContext, - creds: UserCredential + creds: FirebaseUser ) => Promise<GetServerSidePropsResult<P>> export const redirectIfLoggedIn = <P extends { [k: string]: any }>( diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index bf6e8442..9c8adc39 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -17,7 +17,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) const username = ctx.params!.username as string // eslint-disable-line @typescript-eslint/no-non-null-assertion const [auth, user] = (await Promise.all([ - creds != null ? getUserAndPrivateUser(creds.user.uid) : null, + creds != null ? getUserAndPrivateUser(creds.uid) : null, getUserByUsername(username), ])) as [UserAndPrivateUser | null, User | null] return { props: { auth, user } } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 26709417..8ea76cef 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -36,7 +36,7 @@ import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-an import { MINUTE_MS } from 'common/util/time' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } + return { props: { auth: await getUserAndPrivateUser(creds.uid) } } }) type NewQuestionParams = { diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index ae45d6ac..7adc9ef1 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -28,7 +28,7 @@ import { Row } from 'web/components/layout/row' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null + const auth = creds ? await getUserAndPrivateUser(creds.uid) : null return { props: { auth } } } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 65161398..ff4854d7 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -15,7 +15,7 @@ import { usePrefetch } from 'web/hooks/use-prefetch' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) - const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null + const auth = creds ? await getUserAndPrivateUser(creds.uid) : null return { props: { auth } } } diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 4c4a0be1..96ccab48 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -31,7 +31,7 @@ import { UserLink } from 'web/components/user-link' const LINKS_PER_PAGE = 24 export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } + return { props: { auth: await getUserAndPrivateUser(creds.uid) } } }) export function getManalinkUrl(slug: string) { diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index ca1f3489..240fe8fa 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -23,7 +23,7 @@ import Textarea from 'react-expanding-textarea' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { - return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } + return { props: { auth: await getUserAndPrivateUser(creds.uid) } } }) function EditUserField(props: { From fec4e19c1d3816693a38bd1f24f000643115d0aa Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 07:01:02 -0600 Subject: [PATCH 192/279] Selectively force long polling for ios only --- web/lib/firebase/init.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index b9c96a9b..44bc3a2a 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -10,9 +10,28 @@ import { connectFunctionsEmulator, getFunctions } from 'firebase/functions' // Initialize Firebase export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) -export const db = initializeFirestore(app, { - experimentalForceLongPolling: true, -}) +function iOS() { + if (typeof navigator === 'undefined') { + // we're on the server, do whatever + return false + } + return ( + [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod', + ].includes(navigator.platform) || + // iPad on iOS 13 detection + (navigator.userAgent.includes('Mac') && 'ontouchend' in document) + ) +} +// Necessary for ios, see: https://github.com/firebase/firebase-js-sdk/issues/6118 +const opts = iOS() ? { experimentalForceLongPolling: true } : {} +export const db = initializeFirestore(app, opts) + export const functions = getFunctions() export const storage = getStorage() From a8d7e91a022b64f8d50d54963889734ceb9201e4 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 07:01:49 -0600 Subject: [PATCH 193/279] Clean comments --- web/lib/firebase/init.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 44bc3a2a..6740f8c6 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -12,7 +12,7 @@ export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) function iOS() { if (typeof navigator === 'undefined') { - // we're on the server, do whatever + // We're on the server, proceed normally return false } return ( @@ -28,7 +28,7 @@ function iOS() { (navigator.userAgent.includes('Mac') && 'ontouchend' in document) ) } -// Necessary for ios, see: https://github.com/firebase/firebase-js-sdk/issues/6118 +// Long polling is necessary for ios, see: https://github.com/firebase/firebase-js-sdk/issues/6118 const opts = iOS() ? { experimentalForceLongPolling: true } : {} export const db = initializeFirestore(app, opts) From 5dec6b4a22d0f97800499ab075a20bd6fbc2adb3 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 07:23:43 -0600 Subject: [PATCH 194/279] Medium includes 10 bettors --- web/components/contract/contract-details.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7226aace..c61a0fd1 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -296,9 +296,9 @@ export function ExtraMobileContractDetails(props: { const uniqueBettors = uniqueBettorCount ?? 0 const { resolvedDate } = contractMetrics(contract) const volumeTranslation = - volume > 800 || uniqueBettors > 20 + volume > 800 || uniqueBettors >= 20 ? 'High' - : volume > 300 || uniqueBettors > 10 + : volume > 300 || uniqueBettors >= 10 ? 'Medium' : 'Low' From a7c8b8aec4d008997d7f14500111f96aaa6a0538 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 07:34:02 -0600 Subject: [PATCH 195/279] Hide bet panel when signed out --- web/components/bet-panel.tsx | 47 ++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 913216e9..d596dd46 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -67,27 +67,32 @@ export function BetPanel(props: { className )} > - <QuickOrLimitBet - isLimitOrder={isLimitOrder} - setIsLimitOrder={setIsLimitOrder} - hideToggle={!user} - /> - <BuyPanel - hidden={isLimitOrder} - contract={contract} - user={user} - unfilledBets={unfilledBets} - /> - <LimitOrderPanel - hidden={!isLimitOrder} - contract={contract} - user={user} - unfilledBets={unfilledBets} - /> - - <BetSignUpPrompt /> - - {!user && <PlayMoneyDisclaimer />} + {user ? ( + <> + <QuickOrLimitBet + isLimitOrder={isLimitOrder} + setIsLimitOrder={setIsLimitOrder} + hideToggle={!user} + /> + <BuyPanel + hidden={isLimitOrder} + contract={contract} + user={user} + unfilledBets={unfilledBets} + /> + <LimitOrderPanel + hidden={!isLimitOrder} + contract={contract} + user={user} + unfilledBets={unfilledBets} + /> + </> + ) : ( + <> + <BetSignUpPrompt /> + <PlayMoneyDisclaimer /> + </> + )} </Col> {user && unfilledBets.length > 0 && ( From 6706fe73501b42266378c9aa8b27689de85b46a7 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 08:12:46 -0600 Subject: [PATCH 196/279] Show user balance on bet panels --- web/components/answers/answer-bet-panel.tsx | 7 +++++-- web/components/answers/create-answer-panel.tsx | 9 +++++++-- web/components/bet-panel.tsx | 18 ++++++++++++++---- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index f04d752f..c5897056 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useEffect, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { XIcon } from '@heroicons/react/solid' import { Answer } from 'common/answer' @@ -132,7 +132,10 @@ export function AnswerBetPanel(props: { </button> )} </Row> - <div className="my-3 text-left text-sm text-gray-500">Amount </div> + <Row className="my-3 justify-between text-left text-sm text-gray-500"> + Amount + <span>(balance: {formatMoney(user?.balance ?? 0)})</span> + </Row> <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index cef60138..38aeac0e 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useState } from 'react' +import React, { useState } from 'react' import Textarea from 'react-expanding-textarea' import { findBestMatch } from 'string-similarity' @@ -149,7 +149,12 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { {text && ( <> <Col className="mt-1 gap-2"> - <div className="text-sm text-gray-500">Bet amount</div> + <Row className="my-3 justify-between text-left text-sm text-gray-500"> + Bet Amount + <span className={'sm:hidden'}> + (balance: {formatMoney(user?.balance ?? 0)}) + </span> + </Row>{' '} <BuyAmountInput amount={betAmount} onChange={setBetAmount} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index d596dd46..f958ed87 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -280,7 +280,12 @@ function BuyPanel(props: { isPseudoNumeric={isPseudoNumeric} /> - <div className="my-3 text-left text-sm text-gray-500">Amount</div> + <Row className="my-3 justify-between text-left text-sm text-gray-500"> + Amount + <span className={'xl:hidden'}> + (balance: {formatMoney(user?.balance ?? 0)}) + </span> + </Row> <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} @@ -598,9 +603,14 @@ function LimitOrderPanel(props: { </div> )} - <div className="mt-1 mb-3 text-left text-sm text-gray-500"> - Max amount<span className="ml-1 text-red-500">*</span> - </div> + <Row className="mt-1 mb-3 justify-between text-left text-sm text-gray-500"> + <span> + Max amount<span className="ml-1 text-red-500">*</span> + </span> + <span className={'xl:hidden'}> + (balance: {formatMoney(user?.balance ?? 0)}) + </span> + </Row> <BuyAmountInput inputClassName="w-full max-w-none" amount={betAmount} From c6eac97b64fb7a4867049b8a747814a7b8bc652f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 08:29:56 -0600 Subject: [PATCH 197/279] Show group based on most recent creator added group --- web/components/contract/contract-details.tsx | 22 ++++++------------- .../groups/contract-groups-list.tsx | 7 +++--- web/lib/firebase/groups.ts | 14 ++++++++++++ 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index c61a0fd1..7dbfc809 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -27,7 +27,7 @@ import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { SiteLink } from 'web/components/site-link' -import { groupPath } from 'web/lib/firebase/groups' +import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' @@ -52,10 +52,10 @@ export function MiscDetails(props: { isResolved, createdTime, resolutionTime, - groupLinks, } = 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"> @@ -83,12 +83,12 @@ export function MiscDetails(props: { <NewContractBadge /> )} - {!hideGroupLink && groupLinks && groupLinks.length > 0 && ( + {!hideGroupLink && groupToDisplay && ( <SiteLink - href={groupPath(groupLinks[0].slug)} + href={groupPath(groupToDisplay.slug)} className="truncate text-sm text-gray-400" > - {groupLinks[0].name} + {groupToDisplay.name} </SiteLink> )} </Row> @@ -148,19 +148,15 @@ export function ContractDetails(props: { creatorName, creatorUsername, creatorId, - groupLinks, creatorAvatarUrl, resolutionTime, } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) - - const groupToDisplay = - groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null const user = useUser() const [open, setOpen] = useState(false) const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 - + const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( <Row className={clsx( @@ -236,11 +232,7 @@ export function ContractDetails(props: { 'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6' } > - <ContractGroupsList - groupLinks={groupLinks ?? []} - contract={contract} - user={user} - /> + <ContractGroupsList contract={contract} user={user} /> </Col> </Modal> diff --git a/web/components/groups/contract-groups-list.tsx b/web/components/groups/contract-groups-list.tsx index 79f2390f..7bbcfa7c 100644 --- a/web/components/groups/contract-groups-list.tsx +++ b/web/components/groups/contract-groups-list.tsx @@ -13,15 +13,14 @@ import { import { User } from 'common/user' import { Contract } from 'common/contract' import { SiteLink } from 'web/components/site-link' -import { GroupLink } from 'common/group' import { useGroupsWithContract } from 'web/hooks/use-group' export function ContractGroupsList(props: { - groupLinks: GroupLink[] contract: Contract user: User | null | undefined }) { - const { groupLinks, user, contract } = props + const { user, contract } = props + const { groupLinks } = contract const groups = useGroupsWithContract(contract) return ( <Col className={'gap-2'}> @@ -35,7 +34,7 @@ export function ContractGroupsList(props: { options={{ showSelector: true, showLabel: false, - ignoreGroupIds: groupLinks.map((g) => g.groupId), + ignoreGroupIds: groupLinks?.map((g) => g.groupId), }} setSelectedGroup={(group) => group && addContractToGroup(group, contract, user.id) diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 28515a35..4d22e0ee 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -208,3 +208,17 @@ export function canModifyGroupContracts(group: Group, userId: string) { group.anyoneCanJoin ) } + +export function getGroupLinkToDisplay(contract: Contract) { + const { groupLinks } = contract + const sortedGroupLinks = groupLinks?.sort( + (a, b) => b.createdTime - a.createdTime + ) + const groupCreatorAdded = sortedGroupLinks?.find( + (g) => g.userId === contract.creatorId + ) + const groupToDisplay = groupCreatorAdded + ? groupCreatorAdded + : sortedGroupLinks?.[0] ?? null + return groupToDisplay +} From 0823414360b4e196396a0471eac71187e7c50e5d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 08:52:49 -0600 Subject: [PATCH 198/279] Adjust group name padding on mobile --- web/components/contract/contract-details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 7dbfc809..a2432397 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -160,7 +160,7 @@ export function ContractDetails(props: { const groupInfo = groupToDisplay ? ( <Row className={clsx( - 'items-center pr-2', + 'items-center pr-0 sm:pr-2', isMobile ? 'max-w-[140px]' : 'max-w-[250px]' )} > From fecf976ab965db877495fe98865a39ea794cdb63 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 09:11:14 -0600 Subject: [PATCH 199/279] Show all group contracts if less than 5 open --- web/pages/group/[...slugs]/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4b391b36..c9581be5 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -62,7 +62,11 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const contracts = (group && (await listContractsByGroupSlug(group.slug))) ?? [] - + const now = Date.now() + const suggestedFilter = + contracts.filter((c) => (c.closeTime ?? 0) > now).length < 5 + ? 'all' + : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) const bets = await Promise.all( @@ -92,6 +96,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { topCreators, messages, aboutPost, + suggestedFilter, }, revalidate: 60, // regenerate after a minute @@ -131,6 +136,7 @@ export default function GroupPage(props: { topCreators: User[] messages: GroupComment[] aboutPost: Post + suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -141,6 +147,7 @@ export default function GroupPage(props: { creatorScores: {}, topCreators: [], messages: [], + suggestedFilter: 'open', } const { creator, @@ -149,6 +156,7 @@ export default function GroupPage(props: { topTraders, creatorScores, topCreators, + suggestedFilter, } = props const router = useRouter() @@ -210,7 +218,7 @@ export default function GroupPage(props: { <ContractSearch user={user} defaultSort={'newest'} - defaultFilter={'open'} + defaultFilter={suggestedFilter} additionalFilter={{ groupSlug: group.slug }} /> ) From 8922b370cc2e562e796ae3c58a2eb5e7f7609af1 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 10:02:41 -0600 Subject: [PATCH 200/279] Show challenge on desktop, simplify modal --- .../challenges/create-challenge-modal.tsx | 111 +++++++----------- .../contract/extra-contract-actions-row.tsx | 28 ++++- web/components/contract/share-modal.tsx | 11 +- 3 files changed, 74 insertions(+), 76 deletions(-) diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index 6f91a6d4..72a8fd7b 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -18,7 +18,6 @@ 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' @@ -26,6 +25,7 @@ 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,8 +110,9 @@ 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(), @@ -147,7 +148,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{' '} @@ -157,7 +158,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={true} + autoFocus={!isMobile} maxLength={MAX_QUESTION_LENGTH} value={challengeInfo.question} onChange={(e) => @@ -170,89 +171,59 @@ 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 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' } > - <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, + 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> - </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> - )} + </Col> <div className="mt-8"> If the challenge is accepted, whoever is right will earn{' '} <span className="font-semibold"> diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 4f362d84..2a5de1c0 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -11,14 +11,21 @@ import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' 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/lib/challenge' export function ExtraContractActionsRow(props: { contract: Contract user: User | undefined | null }) { const { user, contract } = props - + const { outcomeType, resolution } = contract const [isShareOpen, setShareOpen] = useState(false) + const [openCreateChallengeModal, setOpenCreateChallengeModal] = + useState(false) + const showChallenge = + user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED return ( <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> @@ -45,6 +52,25 @@ export function ExtraContractActionsRow(props: { user={user} /> </Button> + {showChallenge && ( + <Button + size="lg" + color="gray-white" + className={'flex hidden max-w-xs self-center sm:inline-block'} + onClick={withTracking( + () => setOpenCreateChallengeModal(true), + 'click challenge button' + )} + > + <span>⚔️ Challenge</span> + <CreateChallengeModal + isOpen={openCreateChallengeModal} + setOpen={setOpenCreateChallengeModal} + user={user} + contract={contract} + /> + </Button> + )} <FollowMarketButton contract={contract} user={user} /> {user?.id !== contract.creatorId && ( diff --git a/web/components/contract/share-modal.tsx b/web/components/contract/share-modal.tsx index 5bae101d..ff3f41ae 100644 --- a/web/components/contract/share-modal.tsx +++ b/web/components/contract/share-modal.tsx @@ -45,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{' '} @@ -57,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!', { @@ -68,17 +68,18 @@ export function ShareModal(props: { > {linkIcon} Copy link </Button> + <Row className={'justify-center'}>or</Row> {showChallenge && ( <Button - size="lg" - color="gray-white" + size="2xl" + color="gradient" className={'mb-2 flex max-w-xs self-center'} onClick={withTracking( () => setOpenCreateChallengeModal(true), 'click challenge button' )} > - <span>⚔️ Challenge a friend</span> + <span>⚔️ Challenge</span> <CreateChallengeModal isOpen={openCreateChallengeModal} setOpen={(open) => { From 7310cf3d4a6a29e202e72e79790d62a139891643 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 10:11:08 -0600 Subject: [PATCH 201/279] fix import --- web/components/contract/extra-contract-actions-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 2a5de1c0..2ae370b1 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -13,7 +13,7 @@ import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog 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/lib/challenge' +import { CHALLENGES_ENABLED } from 'common/challenge' export function ExtraContractActionsRow(props: { contract: Contract From 96be4e89925525d472d1e0f20cbc581e0ad62137 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Thu, 1 Sep 2022 17:47:45 +0100 Subject: [PATCH 202/279] Add embedded ContractGrid to Posts (#822) * Add embedded market grids * Hacky way to set height I haven't figured out a way yet to get the height of the actual iframe's content, so I did some bad estimate for now to unblock shipping the feature, while I continue investigating. --- common/util/tiptap-iframe.ts | 10 +++++- web/components/contract/contracts-grid.tsx | 3 +- web/components/editor/market-modal.tsx | 17 +++++++--- web/components/share-embed-button.tsx | 13 ++++++-- web/pages/create-post.tsx | 2 +- web/pages/embed/grid/[...slugs]/index.tsx | 37 ++++++++++++++++++++++ 6 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 web/pages/embed/grid/[...slugs]/index.tsx diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts index 5af63d2f..9e260821 100644 --- a/common/util/tiptap-iframe.ts +++ b/common/util/tiptap-iframe.ts @@ -35,7 +35,7 @@ export default Node.create<IframeOptions>({ HTMLAttributes: { class: 'iframe-wrapper' + ' ' + wrapperClasses, // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: - style: 'padding-bottom: 20rem;', + style: 'padding-bottom: 20rem; ', }, } }, @@ -48,6 +48,9 @@ export default Node.create<IframeOptions>({ frameborder: { default: 0, }, + height: { + default: 0, + }, allowfullscreen: { default: this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen, @@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({ }, renderHTML({ HTMLAttributes }) { + this.options.HTMLAttributes.style = + this.options.HTMLAttributes.style + + ' height: ' + + HTMLAttributes.height + + ';' return [ 'div', this.options.HTMLAttributes, diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 2f804644..3a09a167 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -27,6 +27,7 @@ export function ContractsGrid(props: { } highlightOptions?: ContractHighlightOptions trackingPostfix?: string + breakpointColumns?: { [key: string]: number } }) { const { contracts, @@ -67,7 +68,7 @@ export function ContractsGrid(props: { <Col className="gap-8"> <Masonry // Show only 1 column on tailwind's md breakpoint (768px) - breakpointCols={{ default: 2, 768: 1 }} + breakpointCols={props.breakpointColumns ?? { default: 2, 768: 1 }} className="-ml-4 flex w-auto" columnClassName="pl-4 bg-clip-padding" > diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index a81953de..31c437b1 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -7,7 +7,7 @@ import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Row } from '../layout/row' import { LoadingIndicator } from '../loading-indicator' -import { embedCode } from '../share-embed-button' +import { embedContractCode, embedContractGridCode } from '../share-embed-button' import { insertContent } from './utils' export function MarketModal(props: { @@ -28,7 +28,11 @@ export function MarketModal(props: { async function doneAddingContracts() { setLoading(true) - insertContent(editor, ...contracts.map(embedCode)) + if (contracts.length == 1) { + insertContent(editor, embedContractCode(contracts[0])) + } else if (contracts.length > 1) { + insertContent(editor, embedContractGridCode(contracts)) + } setLoading(false) setOpen(false) setContracts([]) @@ -42,9 +46,14 @@ export function MarketModal(props: { {!loading && ( <Row className="grow justify-end gap-4"> - {contracts.length > 0 && ( + {contracts.length == 1 && ( <Button onClick={doneAddingContracts} color={'indigo'}> - Embed {contracts.length} question + Embed 1 question + </Button> + )} + {contracts.length > 1 && ( + <Button onClick={doneAddingContracts} color={'indigo'}> + Embed grid of {contracts.length} question {contracts.length > 1 && 's'} </Button> )} diff --git a/web/components/share-embed-button.tsx b/web/components/share-embed-button.tsx index cfbe78f0..a42ffc34 100644 --- a/web/components/share-embed-button.tsx +++ b/web/components/share-embed-button.tsx @@ -9,11 +9,18 @@ import { DOMAIN } from 'common/envs/constants' import { copyToClipboard } from 'web/lib/util/copy' import { track } from 'web/lib/service/analytics' -export function embedCode(contract: Contract) { +export function embedContractCode(contract: Contract) { const title = contract.question const src = `https://${DOMAIN}/embed${contractPath(contract)}` + return `<iframe src="${src}" title="${title}" frameborder="0"></iframe>` +} - return `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>` +export function embedContractGridCode(contracts: Contract[]) { + const height = (contracts.length - (contracts.length % 2)) * 100 + 'px' + const src = `http://${DOMAIN}/embed/grid/${contracts + .map((c) => c.slug) + .join('/')}` + return `<iframe height="${height}" src="${src}" title="Grid of contracts" frameborder="0"></iframe>` } export function ShareEmbedButton(props: { contract: Contract }) { @@ -26,7 +33,7 @@ export function ShareEmbedButton(props: { contract: Contract }) { as="div" className="relative z-10 flex-shrink-0" onMouseUp={() => { - copyToClipboard(embedCode(contract)) + copyToClipboard(embedContractCode(contract)) toast.success('Embed code copied!', { icon: codeIcon, }) diff --git a/web/pages/create-post.tsx b/web/pages/create-post.tsx index f88f56a5..01147cc0 100644 --- a/web/pages/create-post.tsx +++ b/web/pages/create-post.tsx @@ -41,7 +41,7 @@ export default function CreatePost() { return ( <Page> - <div className="mx-auto w-full max-w-2xl"> + <div className="mx-auto w-full max-w-3xl"> <div className="rounded-lg px-6 py-4 sm:py-0"> <Title className="!mt-0" text="Create a post" /> <form> diff --git a/web/pages/embed/grid/[...slugs]/index.tsx b/web/pages/embed/grid/[...slugs]/index.tsx new file mode 100644 index 00000000..7500665f --- /dev/null +++ b/web/pages/embed/grid/[...slugs]/index.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { Contract, getContractFromSlug } from 'web/lib/firebase/contracts' +import { ContractsGrid } from 'web/components/contract/contracts-grid' + +export async function getStaticProps(props: { params: { slugs: string[] } }) { + const { slugs } = props.params + + const contracts = (await Promise.all( + slugs.map((slug) => + getContractFromSlug(slug) != null ? getContractFromSlug(slug) : [] + ) + )) as Contract[] + + return { + props: { + contracts, + }, + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function ContractGridPage(props: { contracts: Contract[] }) { + const { contracts } = props + + return ( + <> + <ContractsGrid + contracts={contracts} + breakpointColumns={{ default: 2, 650: 1 }} + /> + </> + ) +} From 1208694d2d0d8eced31d77b5c2aa0ca75443826b Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Thu, 1 Sep 2022 17:54:46 +0100 Subject: [PATCH 203/279] http to https to avoid blocked requests (#833) --- web/components/share-embed-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/share-embed-button.tsx b/web/components/share-embed-button.tsx index a42ffc34..79c63d5a 100644 --- a/web/components/share-embed-button.tsx +++ b/web/components/share-embed-button.tsx @@ -17,7 +17,7 @@ export function embedContractCode(contract: Contract) { export function embedContractGridCode(contracts: Contract[]) { const height = (contracts.length - (contracts.length % 2)) * 100 + 'px' - const src = `http://${DOMAIN}/embed/grid/${contracts + const src = `https://${DOMAIN}/embed/grid/${contracts .map((c) => c.slug) .join('/')}` return `<iframe height="${height}" src="${src}" title="Grid of contracts" frameborder="0"></iframe>` From 8d853815d675de395a88e3e80085a2ccf68d0019 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 13:55:24 -0600 Subject: [PATCH 204/279] Show resolution on og card image (#834) * Handle resolved markets * Add in group names as hashtags --- common/contract-details.ts | 45 +++++--- og-image/api/_lib/challenge-template.ts | 84 +------------- og-image/api/_lib/parser.ts | 2 + og-image/api/_lib/template-css.ts | 81 +++++++++++++ og-image/api/_lib/template.ts | 147 +++++++++--------------- og-image/api/_lib/types.ts | 1 + 6 files changed, 172 insertions(+), 188 deletions(-) create mode 100644 og-image/api/_lib/template-css.ts diff --git a/common/contract-details.ts b/common/contract-details.ts index 02af6359..c231b1e4 100644 --- a/common/contract-details.ts +++ b/common/contract-details.ts @@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) { export function contractTextDetails(contract: Contract) { // eslint-disable-next-line @typescript-eslint/no-var-requires const dayjs = require('dayjs') - const { closeTime, tags } = contract + const { closeTime, groupLinks } = contract const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - const hashtags = tags.map((tag) => `#${tag}`) + const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`) return ( `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + @@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) { ).format('MMM D, h:mma')}` : '') + ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + (groupHashtags ? ` • ${groupHashtags.join(' ')}` : '') ) } @@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => { creatorAvatarUrl, description, numericValue, + resolution, } } @@ -103,6 +104,7 @@ export type OgCardProps = { creatorUsername: string creatorAvatarUrl?: string numericValue?: string + resolution?: string } export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { @@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { creatorOutcome, acceptorOutcome, } = challenge || {} + const { + probability, + numericValue, + resolution, + creatorAvatarUrl, + question, + metadata, + creatorUsername, + creatorName, + } = props const { userName, userAvatarUrl } = acceptances?.[0] ?? {} const probabilityParam = - props.probability === undefined + probability === undefined ? '' - : `&probability=${encodeURIComponent(props.probability ?? '')}` + : `&probability=${encodeURIComponent(probability ?? '')}` const numericValueParam = - props.numericValue === undefined + numericValue === undefined ? '' - : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + : `&numericValue=${encodeURIComponent(numericValue ?? '')}` const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined + creatorAvatarUrl === undefined ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + : `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}` const challengeUrlParams = challenge ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + @@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` : '' + const resolutionUrlParam = resolution + ? `&resolution=${encodeURIComponent(resolution)}` + : '' + // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + - `?question=${encodeURIComponent(props.question)}` + + `?question=${encodeURIComponent(question)}` + probabilityParam + numericValueParam + - `&metadata=${encodeURIComponent(props.metadata)}` + - `&creatorName=${encodeURIComponent(props.creatorName)}` + + `&metadata=${encodeURIComponent(metadata)}` + + `&creatorName=${encodeURIComponent(creatorName)}` + creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + - challengeUrlParams + `&creatorUsername=${encodeURIComponent(creatorUsername)}` + + challengeUrlParams + + resolutionUrlParam ) } diff --git a/og-image/api/_lib/challenge-template.ts b/og-image/api/_lib/challenge-template.ts index 6dc43ac1..647d69b6 100644 --- a/og-image/api/_lib/challenge-template.ts +++ b/og-image/api/_lib/challenge-template.ts @@ -1,85 +1,5 @@ -import { sanitizeHtml } from './sanitizer' import { ParsedRequest } from './types' - -function getCss(theme: string, fontSize: string) { - let background = 'white' - let foreground = 'black' - let radial = 'lightgray' - - if (theme === 'dark') { - background = 'black' - foreground = 'white' - radial = 'dimgray' - } - // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` - return ` - @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); - - body { - background: ${background}; - background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); - background-size: 100px 100px; - height: 100vh; - font-family: "Readex Pro", sans-serif; - } - - code { - color: #D400FF; - font-family: 'Vera'; - white-space: pre-wrap; - letter-spacing: -5px; - } - - code:before, code:after { - content: '\`'; - } - - .logo-wrapper { - display: flex; - align-items: center; - align-content: center; - justify-content: center; - justify-items: center; - } - - .logo { - margin: 0 75px; - } - - .plus { - color: #BBB; - font-family: Times New Roman, Verdana; - font-size: 100px; - } - - .spacer { - margin: 150px; - } - - .emoji { - height: 1em; - width: 1em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - } - - .heading { - font-family: 'Major Mono Display', monospace; - font-size: ${sanitizeHtml(fontSize)}; - font-style: normal; - color: ${foreground}; - line-height: 1.8; - } - - .font-major-mono { - font-family: "Major Mono Display", monospace; - } - - .text-primary { - color: #11b981; - } - ` -} +import { getTemplateCss } from './template-css' export function getChallengeHtml(parsedReq: ParsedRequest) { const { @@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) { <script src="https://cdn.tailwindcss.com"></script> </head> <style> - ${getCss(theme, fontSize)} + ${getTemplateCss(theme, fontSize)} </style> <body> <div class="px-24"> diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index 6d5c9b3d..131a3cc4 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) { creatorName, creatorUsername, creatorAvatarUrl, + resolution, // Challenge attributes: challengerAmount, @@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) { question: getString(question) || 'Will you create a prediction market on Manifold?', + resolution: getString(resolution), probability: getString(probability), numericValue: getString(numericValue) || '', metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', diff --git a/og-image/api/_lib/template-css.ts b/og-image/api/_lib/template-css.ts new file mode 100644 index 00000000..f4ca6660 --- /dev/null +++ b/og-image/api/_lib/template-css.ts @@ -0,0 +1,81 @@ +import { sanitizeHtml } from './sanitizer' + +export function getTemplateCss(theme: string, fontSize: string) { + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' + + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' + } + // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` + return ` + @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); + + body { + background: ${background}; + background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); + background-size: 100px 100px; + height: 100vh; + font-family: "Readex Pro", sans-serif; + } + + code { + color: #D400FF; + font-family: 'Vera'; + white-space: pre-wrap; + letter-spacing: -5px; + } + + code:before, code:after { + content: '\`'; + } + + .logo-wrapper { + display: flex; + align-items: center; + align-content: center; + justify-content: center; + justify-items: center; + } + + .logo { + margin: 0 75px; + } + + .plus { + color: #BBB; + font-family: Times New Roman, Verdana; + font-size: 100px; + } + + .spacer { + margin: 150px; + } + + .emoji { + height: 1em; + width: 1em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; + } + + .heading { + font-family: 'Major Mono Display', monospace; + font-size: ${sanitizeHtml(fontSize)}; + font-style: normal; + color: ${foreground}; + line-height: 1.8; + } + + .font-major-mono { + font-family: "Major Mono Display", monospace; + } + + .text-primary { + color: #11b981; + } + ` +} diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index f59740c5..26f7677e 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -1,85 +1,5 @@ -import { sanitizeHtml } from './sanitizer' import { ParsedRequest } from './types' - -function getCss(theme: string, fontSize: string) { - let background = 'white' - let foreground = 'black' - let radial = 'lightgray' - - if (theme === 'dark') { - background = 'black' - foreground = 'white' - radial = 'dimgray' - } - // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` - return ` - @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); - - body { - background: ${background}; - background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); - background-size: 100px 100px; - height: 100vh; - font-family: "Readex Pro", sans-serif; - } - - code { - color: #D400FF; - font-family: 'Vera'; - white-space: pre-wrap; - letter-spacing: -5px; - } - - code:before, code:after { - content: '\`'; - } - - .logo-wrapper { - display: flex; - align-items: center; - align-content: center; - justify-content: center; - justify-items: center; - } - - .logo { - margin: 0 75px; - } - - .plus { - color: #BBB; - font-family: Times New Roman, Verdana; - font-size: 100px; - } - - .spacer { - margin: 150px; - } - - .emoji { - height: 1em; - width: 1em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - } - - .heading { - font-family: 'Major Mono Display', monospace; - font-size: ${sanitizeHtml(fontSize)}; - font-style: normal; - color: ${foreground}; - line-height: 1.8; - } - - .font-major-mono { - font-family: "Major Mono Display", monospace; - } - - .text-primary { - color: #11b981; - } - ` -} +import { getTemplateCss } from './template-css' export function getHtml(parsedReq: ParsedRequest) { const { @@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) { creatorUsername, creatorAvatarUrl, numericValue, + resolution, } = parsedReq const MAX_QUESTION_CHARS = 100 const truncatedQuestion = @@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) { ? question.slice(0, MAX_QUESTION_CHARS) + '...' : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' + + let resolutionColor = 'text-primary' + let resolutionString = 'Yes' + switch (resolution) { + case 'YES': + break + case 'NO': + resolutionColor = 'text-red-500' + resolutionString = 'No' + break + case 'CANCEL': + resolutionColor = 'text-yellow-500' + resolutionString = 'N/A' + break + case 'MKT': + resolutionColor = 'text-blue-500' + resolutionString = numericValue ? numericValue : probability + break + } + + const resolutionDiv = ` + <span class='text-center ${resolutionColor}'> + <div class="text-8xl"> + ${resolutionString} + </div> + <div class="text-4xl">${ + resolution === 'CANCEL' ? '' : 'resolved' + }</div> + </span>` + + const probabilityDiv = ` + <span class='text-primary text-center'> + <div class="text-8xl">${probability}</div> + <div class="text-4xl">chance</div> + </span>` + + const numericValueDiv = ` + <span class='text-blue-500 text-center'> + <div class="text-8xl ">${numericValue}</div> + <div class="text-4xl">expected</div> + </span> + ` + return `<!DOCTYPE html> <html> <head> @@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) { <script src="https://cdn.tailwindcss.com"></script> </head> <style> - ${getCss(theme, fontSize)} + ${getTemplateCss(theme, fontSize)} </style> <body> <div class="px-24"> @@ -148,21 +112,20 @@ export function getHtml(parsedReq: ParsedRequest) { <div class="text-indigo-700 text-6xl leading-tight"> ${truncatedQuestion} </div> - <div class="flex flex-col text-primary"> - <div class="text-8xl">${probability}</div> - <div class="text-4xl">${probability !== '' ? 'chance' : ''}</div> - <span class='text-blue-500 text-center'> - <div class="text-8xl ">${ - numericValue !== '' && probability === '' ? numericValue : '' - }</div> - <div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div> - </span> + <div class="flex flex-col"> + ${ + resolution + ? resolutionDiv + : numericValue + ? numericValueDiv + : probabilityDiv + } </div> </div> <!-- Metadata --> <div class="absolute bottom-16"> - <div class="text-gray-500 text-3xl"> + <div class="text-gray-500 text-3xl max-w-[80vw]"> ${metadata} </div> </div> diff --git a/og-image/api/_lib/types.ts b/og-image/api/_lib/types.ts index ef0a8135..ac1e7699 100644 --- a/og-image/api/_lib/types.ts +++ b/og-image/api/_lib/types.ts @@ -19,6 +19,7 @@ export interface ParsedRequest { creatorName: string creatorUsername: string creatorAvatarUrl: string + resolution: string // Challenge attributes: challengerAmount: string challengerOutcome: string From 7508d86c73cb972b8c5479ef34a5d65b6eee9406 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 1 Sep 2022 14:42:50 -0700 Subject: [PATCH 205/279] Clean up contract overview code (#823) * Don't call Date.now a million times in answers graph * Refactor contract overview code so that it's easier to understand --- web/components/answers/answers-graph.tsx | 38 ++- .../contract/contract-description.tsx | 6 +- web/components/contract/contract-details.tsx | 10 +- web/components/contract/contract-overview.tsx | 269 ++++++++++-------- .../contract/extra-contract-actions-row.tsx | 10 +- web/pages/[username]/[contractSlug].tsx | 4 + web/pages/embed/[username]/[contractSlug].tsx | 2 +- 7 files changed, 178 insertions(+), 161 deletions(-) diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index dae3a8b5..e4167d11 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: { }) { const { contract, bets, height } = props const { createdTime, resolutionTime, closeTime, answers } = contract + const now = Date.now() const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( bets, contract ) - const isClosed = !!closeTime && Date.now() > closeTime + const isClosed = !!closeTime && now > closeTime const latestTime = dayjs( resolutionTime && isClosed ? Math.min(resolutionTime, closeTime) : isClosed ? closeTime - : resolutionTime ?? Date.now() + : resolutionTime ?? now ) const { width } = useWindowSize() @@ -71,14 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: { const yTickValues = [0, 25, 50, 75, 100] const numXTickValues = isLargeWidth ? 5 : 2 - const startDate = new Date(contract.createdTime) - const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours').toDate() - : latestTime.toDate() - const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 + const startDate = dayjs(contract.createdTime) + const endDate = startDate.add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours') + : latestTime + const includeMinute = endDate.diff(startDate, 'hours') < 2 - const multiYear = !dayjs(startDate).isSame(latestTime, 'year') - const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) + const multiYear = !startDate.isSame(latestTime, 'year') + const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime) return ( <div @@ -96,16 +97,16 @@ export const AnswersGraph = memo(function AnswersGraph(props: { }} xScale={{ type: 'time', - min: startDate, - max: endDate, + min: startDate.toDate(), + max: endDate.toDate(), }} xFormat={(d) => - formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) + formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, format: (time) => - formatTime(+time, multiYear, lessThanAWeek, includeMinute), + formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), }} colors={[ '#fca5a5', // red-300 @@ -158,23 +159,20 @@ function formatPercent(y: DatumValue) { } function formatTime( + now: number, time: number, includeYear: boolean, includeHour: boolean, includeMinute: boolean ) { const d = dayjs(time) - - if ( - d.add(1, 'minute').isAfter(Date.now()) && - d.subtract(1, 'minute').isBefore(Date.now()) - ) + if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) return 'Now' let format: string - if (d.isSame(Date.now(), 'day')) { + if (d.isSame(now, 'day')) { format = '[Today]' - } else if (d.add(1, 'day').isSame(Date.now(), 'day')) { + } else if (d.add(1, 'day').isSame(now, 'day')) { format = '[Yesterday]' } else { format = 'MMM D' diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 9bffed9b..53557305 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -6,6 +6,7 @@ import Textarea from 'react-expanding-textarea' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { exhibitExts, parseTags } from 'common/util/parse' import { useAdmin } from 'web/hooks/use-admin' +import { useUser } from 'web/hooks/use-user' import { updateContract } from 'web/lib/firebase/contracts' import { Row } from '../layout/row' import { Content } from '../editor' @@ -17,11 +18,12 @@ import { insertContent } from '../editor/utils' export function ContractDescription(props: { contract: Contract - isCreator: boolean className?: string }) { - const { contract, isCreator, className } = props + const { contract, className } = props const isAdmin = useAdmin() + const user = useUser() + const isCreator = user?.id === contract.creatorId return ( <div className={clsx('mt-2 text-gray-700', className)}> {isCreator || isAdmin ? ( diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index a2432397..8edf9299 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -30,7 +30,6 @@ import { SiteLink } from 'web/components/site-link' import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' -import { User } from 'common/user' import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' @@ -138,11 +137,9 @@ export function AbbrContractDetails(props: { export function ContractDetails(props: { contract: Contract - user: User | null | undefined - isCreator?: boolean disabled?: boolean }) { - const { contract, isCreator, disabled } = props + const { contract, disabled } = props const { closeTime, creatorName, @@ -153,6 +150,7 @@ export function ContractDetails(props: { } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) const user = useUser() + const isCreator = user?.id === creatorId const [open, setOpen] = useState(false) const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 @@ -279,12 +277,12 @@ export function ContractDetails(props: { export function ExtraMobileContractDetails(props: { contract: Contract - user: User | null | undefined forceShowVolume?: boolean }) { - const { contract, user, forceShowVolume } = props + const { contract, forceShowVolume } = props const { volume, resolutionTime, closeTime, creatorId, uniqueBettorCount } = contract + const user = useUser() const uniqueBettors = uniqueBettorCount ?? 0 const { resolvedDate } = contractMetrics(contract) const volumeTranslation = diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 272de6c5..1bfe84de 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -1,5 +1,4 @@ import React from 'react' -import clsx from 'clsx' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' @@ -16,136 +15,154 @@ import { import { Bet } from 'common/bet' import BetButton from '../bet-button' import { AnswersGraph } from '../answers/answers-graph' -import { Contract, CPMMBinaryContract } from 'common/contract' -import { ContractDescription } from './contract-description' +import { + Contract, + BinaryContract, + CPMMContract, + CPMMBinaryContract, + FreeResponseContract, + MultipleChoiceContract, + NumericContract, + PseudoNumericContract, +} from 'common/contract' import { ContractDetails, ExtraMobileContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' -import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' + +const OverviewQuestion = (props: { text: string }) => ( + <Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> +) + +const BetWidget = (props: { contract: CPMMContract }) => { + const user = useUser() + return ( + <Col> + <BetButton contract={props.contract} /> + {!user && ( + <div className="mt-1 text-center text-sm text-gray-500"> + (with play money!) + </div> + )} + </Col> + ) +} + +const NumericOverview = (props: { contract: NumericContract }) => { + const { contract } = props + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <Row className="justify-between gap-4"> + <OverviewQuestion text={contract.question} /> + <NumericResolutionOrExpectation + contract={contract} + className="hidden items-end xl:flex" + /> + </Row> + <NumericResolutionOrExpectation + className="items-center justify-between gap-4 xl:hidden" + contract={contract} + /> + </Col> + <NumericGraph contract={contract} /> + </Col> + ) +} + +const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { + const { contract, bets } = props + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <Row className="justify-between gap-4"> + <OverviewQuestion text={contract.question} /> + <BinaryResolutionOrChance + className="hidden items-end xl:flex" + contract={contract} + large + /> + </Row> + <Row className="items-center justify-between gap-4 xl:hidden"> + <BinaryResolutionOrChance contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> + {tradingAllowed(contract) && ( + <BetWidget contract={contract as CPMMBinaryContract} /> + )} + </Row> + </Col> + <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + </Col> + ) +} + +const ChoiceOverview = (props: { + contract: FreeResponseContract | MultipleChoiceContract + bets: Bet[] +}) => { + const { contract, bets } = props + const { question, resolution } = contract + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <OverviewQuestion text={question} /> + {resolution && ( + <FreeResponseResolutionOrChance contract={contract} truncate="none" /> + )} + </Col> + <Col className={'mb-1 gap-y-2'}> + <AnswersGraph contract={contract} bets={[...bets].reverse()} /> + <ExtraMobileContractDetails + contract={contract} + forceShowVolume={true} + /> + </Col> + </Col> + ) +} + +const PseudoNumericOverview = (props: { + contract: PseudoNumericContract + bets: Bet[] +}) => { + const { contract, bets } = props + return ( + <Col className="gap-1 md:gap-2"> + <Col className="gap-3 px-2 sm:gap-4"> + <ContractDetails contract={contract} /> + <Row className="justify-between gap-4"> + <OverviewQuestion text={contract.question} /> + <PseudoNumericResolutionOrExpectation + contract={contract} + className="hidden items-end xl:flex" + /> + </Row> + <Row className="items-center justify-between gap-4 xl:hidden"> + <PseudoNumericResolutionOrExpectation contract={contract} /> + <ExtraMobileContractDetails contract={contract} /> + {tradingAllowed(contract) && <BetWidget contract={contract} />} + </Row> + </Col> + <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> + </Col> + ) +} export const ContractOverview = (props: { contract: Contract bets: Bet[] - className?: string }) => { - const { contract, bets, className } = props - const { question, creatorId, outcomeType, resolution } = contract - - const user = useUser() - const isCreator = user?.id === creatorId - - const isBinary = outcomeType === 'BINARY' - const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' - - return ( - <Col className={clsx('mb-6', className)}> - <Col className="gap-3 px-2 sm:gap-4"> - <ContractDetails - contract={contract} - user={user} - isCreator={isCreator} - /> - <Row className="justify-between gap-4"> - <div className="text-2xl text-indigo-700 md:text-3xl"> - <Linkify text={question} /> - </div> - <Row className={'hidden gap-3 xl:flex'}> - {isBinary && ( - <BinaryResolutionOrChance - className="items-end" - contract={contract} - large - /> - )} - - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation - contract={contract} - className="items-end" - /> - )} - - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation - contract={contract} - className="items-end" - /> - )} - </Row> - </Row> - - {isBinary ? ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <BinaryResolutionOrChance contract={contract} /> - <ExtraMobileContractDetails contract={contract} user={user} /> - {tradingAllowed(contract) && ( - <Row> - <Col> - <BetButton contract={contract as CPMMBinaryContract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> - </Row> - )} - </Row> - ) : isPseudoNumeric ? ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <PseudoNumericResolutionOrExpectation contract={contract} /> - <ExtraMobileContractDetails contract={contract} user={user} /> - {tradingAllowed(contract) && ( - <Row> - <Col> - <BetButton contract={contract} /> - {!user && ( - <div className="mt-1 text-center text-sm text-gray-500"> - (with play money!) - </div> - )} - </Col> - </Row> - )} - </Row> - ) : ( - (outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && - resolution && ( - <FreeResponseResolutionOrChance - contract={contract} - truncate="none" - /> - ) - )} - - {outcomeType === 'NUMERIC' && ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <NumericResolutionOrExpectation contract={contract} /> - </Row> - )} - </Col> - <div className={'my-1 md:my-2'}></div> - {(isBinary || isPseudoNumeric) && ( - <ContractProbGraph contract={contract} bets={[...bets].reverse()} /> - )}{' '} - {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && ( - <Col className={'mb-1 gap-y-2'}> - <AnswersGraph contract={contract} bets={[...bets].reverse()} /> - <ExtraMobileContractDetails - contract={contract} - user={user} - forceShowVolume={true} - /> - </Col> - )} - {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} - <ExtraContractActionsRow user={user} contract={contract} /> - <ContractDescription - className="px-2" - contract={contract} - isCreator={isCreator} - /> - </Col> - ) + const { contract, bets } = props + switch (contract.outcomeType) { + case 'BINARY': + return <BinaryOverview contract={contract} bets={bets} /> + case 'NUMERIC': + return <NumericOverview contract={contract} /> + case 'PSEUDO_NUMERIC': + return <PseudoNumericOverview contract={contract} bets={bets} /> + case 'FREE_RESPONSE': + case 'MULTIPLE_CHOICE': + return <ChoiceOverview contract={contract} bets={bets} /> + } } diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index 2ae370b1..f84655ec 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -5,7 +5,7 @@ import { Row } from '../layout/row' import { Contract } from 'web/lib/firebase/contracts' import React, { useState } from 'react' import { Button } from 'web/components/button' -import { User } from 'common/user' +import { useUser } from 'web/hooks/use-user' import { ShareModal } from './share-modal' import { FollowMarketButton } from 'web/components/follow-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button' @@ -15,12 +15,10 @@ import { withTracking } from 'web/lib/service/analytics' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { CHALLENGES_ENABLED } from 'common/challenge' -export function ExtraContractActionsRow(props: { - contract: Contract - user: User | undefined | null -}) { - const { user, contract } = props +export function ExtraContractActionsRow(props: { contract: Contract }) { + const { contract } = props const { outcomeType, resolution } = contract + const user = useUser() const [isShareOpen, setShareOpen] = useState(false) const [openCreateChallengeModal, setOpenCreateChallengeModal] = useState(false) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index f3c48a68..aeb50488 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -36,6 +36,8 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { User } from 'common/user' import { ContractComment } from 'common/comment' import { getOpenGraphProps } from 'common/contract-details' +import { ContractDescription } from 'web/components/contract/contract-description' +import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row' import { ContractLeaderboard, ContractTopTrades, @@ -232,6 +234,8 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={nonChallengeBets} /> + <ExtraContractActionsRow contract={contract} /> + <ContractDescription className="mb-6 px-2" contract={contract} /> {outcomeType === 'NUMERIC' && ( <AlertBox diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index a496bf91..4a94b1db 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -103,7 +103,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { <Spacer h={3} /> <Row className="items-center justify-between gap-4 px-2"> - <ContractDetails contract={contract} user={null} disabled /> + <ContractDetails contract={contract} disabled /> {(isBinary || isPseudoNumeric) && tradingAllowed(contract) && From 00ba3b0c4870f515a0df643c9dbb701c2c912930 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Thu, 1 Sep 2022 16:23:12 -0600 Subject: [PATCH 206/279] Show avatars of tippers and unique bettors (#837) * Show avatars of tippers and unique bettors * Make transparent the avatar bg * fix import --- common/notification.ts | 1 + functions/src/create-notification.ts | 54 +++++++++----- functions/src/on-create-bet.ts | 31 ++++---- web/components/button.tsx | 4 +- .../multi-user-transaction-link.tsx | 74 +++++++++++++++++++ web/components/user-link.tsx | 70 +----------------- web/pages/notifications.tsx | 74 +++++++++---------- 7 files changed, 162 insertions(+), 146 deletions(-) create mode 100644 web/components/multi-user-transaction-link.tsx diff --git a/common/notification.ts b/common/notification.ts index 657ea2c1..9ec320fa 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -15,6 +15,7 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string + data?: string sourceContractTitle?: string sourceContractCreatorUsername?: string diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 8ed14704..131d6e85 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -151,15 +151,6 @@ export const createNotification = async ( } } - const notifyContractCreatorOfUniqueBettorsBonus = async ( - userToReasonTexts: user_to_reason_texts, - userId: string - ) => { - userToReasonTexts[userId] = { - reason: 'unique_bettors_on_your_contract', - } - } - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -192,16 +183,6 @@ export const createNotification = async ( sourceContract ) { await notifyContractCreator(userToReasonTexts, sourceContract) - } else if ( - sourceType === 'bonus' && - sourceUpdateType === 'created' && - sourceContract - ) { - // Note: the daily bonus won't have a contract attached to it - await notifyContractCreatorOfUniqueBettorsBonus( - userToReasonTexts, - sourceContract.creatorId - ) } await createUsersNotifications(userToReasonTexts) @@ -737,3 +718,38 @@ export async function filterUserIdsForOnlyFollowerIds( ) return userIds.filter((id) => contractFollowersIds.includes(id)) } + +export const createUniqueBettorBonusNotification = async ( + contractCreatorId: string, + bettor: User, + txnId: string, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${contractCreatorId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: contractCreatorId, + reason: 'unique_bettors_on_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'bonus', + sourceUpdateType: 'created', + sourceUserName: bettor.name, + sourceUserUsername: bettor.username, + sourceUserAvatarUrl: bettor.avatarUrl, + sourceText: amount.toString(), + sourceSlug: contract.slug, + sourceTitle: contract.question, + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index ff6cf9d9..5dbebfc3 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils' import { createBetFillNotification, createBettingStreakBonusNotification, - createNotification, + createUniqueBettorBonusNotification, } from './create-notification' import { filterDefined } from '../../common/util/array' import { Contract } from '../../common/contract' @@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore log(`Could not find contract ${contractId}`) return } - await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) const bettor = await getUser(bet.userId) if (!bettor) return + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor) await updateBettingStreak(bettor, bet, contract, eventId) @@ -126,7 +126,7 @@ const updateBettingStreak = async ( const updateUniqueBettorsAndGiveCreatorBonus = async ( contract: Contract, eventId: string, - bettorId: string + bettor: User ) => { let previousUniqueBettorIds = contract.uniqueBettorIds @@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( ) } - const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id) - const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) + const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id]) // Update contract unique bettors if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) - isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) + isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, @@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } // No need to give a bonus for the creator's bet - if (!isNewUniqueBettor || bettorId == contract.creatorId) return + if (!isNewUniqueBettor || bettor.id == contract.creatorId) return // Create combined txn for all new unique bettors const bonusTxnDetails = { @@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) } else { log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) - await createNotification( + await createUniqueBettorBonusNotification( + contract.creatorId, + bettor, result.txn.id, - 'bonus', - 'created', - fromUser, - eventId + '-bonus', - result.txn.amount + '', - { - contract, - slug: contract.slug, - title: contract.question, - } + contract, + result.txn.amount, + eventId + '-unique-bettor-bonus' ) } } diff --git a/web/components/button.tsx b/web/components/button.tsx index dbb28122..cb39cba8 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { MouseEventHandler, ReactNode } from 'react' import clsx from 'clsx' export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' @@ -14,7 +14,7 @@ export type ColorType = export function Button(props: { className?: string - onClick?: () => void + onClick?: MouseEventHandler<any> | undefined children?: ReactNode size?: SizeType color?: ColorType diff --git a/web/components/multi-user-transaction-link.tsx b/web/components/multi-user-transaction-link.tsx new file mode 100644 index 00000000..70d273db --- /dev/null +++ b/web/components/multi-user-transaction-link.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { Row } from 'web/components/layout/row' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { formatMoney } from 'common/util/format' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-link' +import { Button } from 'web/components/button' + +export type MultiUserLinkInfo = { + name: string + username: string + avatarUrl: string | undefined + amount: number +} + +export function MultiUserTransactionLink(props: { + userInfos: MultiUserLinkInfo[] + modalLabel: string +}) { + const { userInfos, modalLabel } = props + const [open, setOpen] = useState(false) + const maxShowCount = 5 + return ( + <Row> + <Button + size={'xs'} + color={'gray-white'} + className={'z-10 mr-1 gap-1 bg-transparent'} + onClick={(e) => { + e.stopPropagation() + setOpen(true) + }} + > + <Row className={'gap-1'}> + {userInfos.map((userInfo, index) => + index < maxShowCount ? ( + <Row key={userInfo.username + 'shortened'}> + <Avatar + username={userInfo.username} + size={'sm'} + avatarUrl={userInfo.avatarUrl} + noLink={userInfos.length > 1} + /> + </Row> + ) : ( + <span>& {userInfos.length - maxShowCount} more</span> + ) + )} + </Row> + </Button> + <Modal open={open} setOpen={setOpen} size={'sm'}> + <Col className="items-start gap-4 rounded-md bg-white p-6"> + <span className={'text-xl'}>{modalLabel}</span> + {userInfos.map((userInfo) => ( + <Row + key={userInfo.username + 'list'} + className="w-full items-center gap-2" + > + <span className="text-primary min-w-[3.5rem]"> + +{formatMoney(userInfo.amount)} + </span> + <Avatar + username={userInfo.username} + avatarUrl={userInfo.avatarUrl} + /> + <UserLink name={userInfo.name} username={userInfo.username} /> + </Row> + ))} + </Col> + </Modal> + </Row> + ) +} diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index cc8f1a1f..e1b675a0 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -1,13 +1,7 @@ -import { linkClass, SiteLink } from 'web/components/site-link' +import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' -import { Row } from 'web/components/layout/row' -import { Modal } from 'web/components/layout/modal' -import { Col } from 'web/components/layout/col' -import { useState } from 'react' -import { Avatar } from 'web/components/avatar' -import { formatMoney } from 'common/util/format' -function shortenName(name: string) { +export function shortenName(name: string) { const firstName = name.split(' ')[0] const maxLength = 11 const shortName = @@ -38,63 +32,3 @@ export function UserLink(props: { </SiteLink> ) } - -export type MultiUserLinkInfo = { - name: string - username: string - avatarUrl: string | undefined - amountTipped: number -} - -export function MultiUserTipLink(props: { - userInfos: MultiUserLinkInfo[] - className?: string -}) { - const { userInfos, className } = props - const [open, setOpen] = useState(false) - const maxShowCount = 2 - return ( - <> - <Row - className={clsx('mr-1 inline-flex gap-1', linkClass, className)} - onClick={(e) => { - e.stopPropagation() - setOpen(true) - }} - > - {userInfos.map((userInfo, index) => - index < maxShowCount ? ( - <span key={userInfo.username + 'shortened'} className={linkClass}> - {shortenName(userInfo.name) + - (index < maxShowCount - 1 ? ', ' : '')} - </span> - ) : ( - <span className={linkClass}> - & {userInfos.length - maxShowCount} more - </span> - ) - )} - </Row> - <Modal open={open} setOpen={setOpen} size={'sm'}> - <Col className="items-start gap-4 rounded-md bg-white p-6"> - <span className={'text-xl'}>Who tipped you</span> - {userInfos.map((userInfo) => ( - <Row - key={userInfo.username + 'list'} - className="w-full items-center gap-2" - > - <span className="text-primary min-w-[3.5rem]"> - +{formatMoney(userInfo.amountTipped)} - </span> - <Avatar - username={userInfo.username} - avatarUrl={userInfo.avatarUrl} - /> - <UserLink name={userInfo.name} username={userInfo.username} /> - </Row> - ))} - </Col> - </Modal> - </> - ) -} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 2b2e8d7a..2ec3ac6f 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -43,12 +43,13 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { - MultiUserTipLink, - MultiUserLinkInfo, - UserLink, -} from 'web/components/user-link' +import { UserLink } from 'web/components/user-link' import { LoadingIndicator } from 'web/components/loading-indicator' +import { + MultiUserLinkInfo, + MultiUserTransactionLink, +} from 'web/components/multi-user-transaction-link' +import { Col } from 'web/components/layout/col' export const NOTIFICATIONS_PER_PAGE = 30 const HIGHLIGHT_CLASS = 'bg-indigo-50' @@ -212,7 +213,7 @@ function IncomeNotificationGroupItem(props: { function combineNotificationsByAddingNumericSourceTexts( notifications: Notification[] ) { - const newNotifications = [] + const newNotifications: Notification[] = [] const groupedNotificationsBySourceType = groupBy( notifications, (n) => n.sourceType @@ -228,10 +229,7 @@ function IncomeNotificationGroupItem(props: { for (const sourceTitle in groupedNotificationsBySourceTitle) { const notificationsForSourceTitle = groupedNotificationsBySourceTitle[sourceTitle] - if (notificationsForSourceTitle.length === 1) { - newNotifications.push(notificationsForSourceTitle[0]) - continue - } + let sum = 0 notificationsForSourceTitle.forEach( (notification) => @@ -251,7 +249,7 @@ function IncomeNotificationGroupItem(props: { username: notification.sourceUserUsername, name: notification.sourceUserName, avatarUrl: notification.sourceUserAvatarUrl, - amountTipped: thisSum, + amount: thisSum, } as MultiUserLinkInfo }), (n) => n.username @@ -260,10 +258,8 @@ function IncomeNotificationGroupItem(props: { const newNotification = { ...notificationsForSourceTitle[0], sourceText: sum.toString(), - sourceUserUsername: - uniqueUsers.length > 1 - ? JSON.stringify(uniqueUsers) - : notificationsForSourceTitle[0].sourceType, + sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername, + data: JSON.stringify(uniqueUsers), } newNotifications.push(newNotification) } @@ -372,12 +368,15 @@ function IncomeNotificationItem(props: { justSummary?: boolean }) { const { notification, justSummary } = props - const { sourceType, sourceUserName, sourceUserUsername, sourceText } = - notification + const { sourceType, sourceUserUsername, sourceText, data } = notification const [highlighted] = useState(!notification.isSeen) const { width } = useWindowSize() const isMobile = (width && width < 768) || false const user = useUser() + const isTip = sourceType === 'tip' || sourceType === 'tip_and_like' + const isUniqueBettorBonus = sourceType === 'bonus' + const userLinks: MultiUserLinkInfo[] = + isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : [] useEffect(() => { setNotificationsAsSeen([notification]) @@ -505,29 +504,26 @@ function IncomeNotificationItem(props: { href={getIncomeSourceUrl() ?? ''} className={'absolute left-0 right-0 top-0 bottom-0 z-0'} /> - <Row className={'items-center text-gray-500 sm:justify-start'}> - <div className={'line-clamp-2 flex max-w-xl shrink '}> - <div className={'inline'}> - <span className={'mr-1'}>{incomeNotificationLabel()}</span> - </div> - <span> - {(sourceType === 'tip' || sourceType === 'tip_and_like') && - (sourceUserUsername?.includes(',') ? ( - <MultiUserTipLink - userInfos={JSON.parse(sourceUserUsername)} - /> - ) : ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-1 flex-shrink-0'} - short={true} - /> - ))} - {reasonAndLink(false)} + <Col className={'justify-start text-gray-500'}> + {(isTip || isUniqueBettorBonus) && ( + <MultiUserTransactionLink + userInfos={userLinks} + modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'} + /> + )} + <Row className={'line-clamp-2 flex max-w-xl'}> + <span>{incomeNotificationLabel()}</span> + <span className={'mx-1'}> + {isTip && + (userLinks.length > 1 + ? 'Multiple users' + : userLinks.length > 0 + ? userLinks[0].name + : '')} </span> - </div> - </Row> + <span>{reasonAndLink(false)}</span> + </Row> + </Col> <div className={'border-b border-gray-300 pt-4'} /> </div> </div> From 51fe44f877f0ff6111069899de622b39000ee117 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 1 Sep 2022 16:10:39 -0700 Subject: [PATCH 207/279] Show the number of open markets on each groups page --- web/pages/group/[...slugs]/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index c9581be5..9012b585 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -84,9 +84,12 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { [] const creator = await creatorPromise + // Only count unresolved markets + const contractsCount = contracts.filter((c) => !c.isResolved).length return { props: { + contractsCount, group, members, creator, @@ -127,6 +130,7 @@ const groupSubpages = [ ] as const export default function GroupPage(props: { + contractsCount: number group: Group | null members: User[] creator: User @@ -139,6 +143,7 @@ export default function GroupPage(props: { suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { + contractsCount: 0, group: null, members: [], creator: null, @@ -150,6 +155,7 @@ export default function GroupPage(props: { suggestedFilter: 'open', } const { + contractsCount, creator, members, traderScores, @@ -225,6 +231,7 @@ export default function GroupPage(props: { const tabs = [ { + badge: `${contractsCount}`, title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), From 04e8bb248be5720237a3d478ae6222118d45047e Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Thu, 1 Sep 2022 18:15:10 -0700 Subject: [PATCH 208/279] Fix Salem Center market url --- web/pages/tournaments/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index 4f66cc22..b1f84473 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -69,7 +69,7 @@ const Salem = { }, { marketUrl: - 'https://salemcenter.manifold.markets/SalemCenter/over-100000-monkeypox-cases-in-2022', + 'https://salemcenter.manifold.markets/SalemCenter/supreme-court-ban-race-in-college-a', image: race_pic, }, ], From dca7205a4768bbf4ea02b7b6d5688a1fe26bd575 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 1 Sep 2022 19:37:41 -0700 Subject: [PATCH 209/279] Disable group prefetching from contract links (#836) * Kill dead code * Stop prefetching groups when viewing contract * Tidy markup --- web/components/contract/contract-details.tsx | 58 +++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 8edf9299..e0eda8d6 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -8,6 +8,7 @@ import { import clsx from 'clsx' import { Editor } from '@tiptap/react' import dayjs from 'dayjs' +import Link from 'next/link' import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' @@ -26,7 +27,7 @@ import { Button } from 'web/components/button' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' import { ContractGroupsList } from 'web/components/groups/contract-groups-list' -import { SiteLink } from 'web/components/site-link' +import { linkClass } from 'web/components/site-link' import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' @@ -83,12 +84,11 @@ export function MiscDetails(props: { )} {!hideGroupLink && groupToDisplay && ( - <SiteLink - href={groupPath(groupToDisplay.slug)} - className="truncate text-sm text-gray-400" - > - {groupToDisplay.name} - </SiteLink> + <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> + <a className={clsx(linkClass, 'truncate text-sm text-gray-400')}> + {groupToDisplay.name} + </a> + </Link> )} </Row> ) @@ -116,25 +116,6 @@ export function AvatarDetails(props: { ) } -export function AbbrContractDetails(props: { - contract: Contract - showHotVolume?: boolean - showTime?: ShowTime -}) { - const { contract, showHotVolume, showTime } = props - return ( - <Row className="items-center justify-between"> - <AvatarDetails contract={contract} /> - - <MiscDetails - contract={contract} - showHotVolume={showHotVolume} - showTime={showTime} - /> - </Row> - ) -} - export function ContractDetails(props: { contract: Contract disabled?: boolean @@ -156,19 +137,18 @@ export function ContractDetails(props: { const isMobile = (width ?? 0) < 600 const groupToDisplay = getGroupLinkToDisplay(contract) const groupInfo = groupToDisplay ? ( - <Row - className={clsx( - 'items-center pr-0 sm:pr-2', - isMobile ? 'max-w-[140px]' : 'max-w-[250px]' - )} - > - <SiteLink href={groupPath(groupToDisplay.slug)} className={'truncate'}> - <Row> - <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> - <span className="items-center truncate">{groupToDisplay.name}</span> - </Row> - </SiteLink> - </Row> + <Link prefetch={false} href={groupPath(groupToDisplay.slug)}> + <a + className={clsx( + linkClass, + 'flex flex-row items-center truncate pr-0 sm:pr-2', + isMobile ? 'max-w-[140px]' : 'max-w-[250px]' + )} + > + <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> + <span className="items-center truncate">{groupToDisplay.name}</span> + </a> + </Link> ) : ( <Button size={'xs'} From 4406e53121efeb362e68609fe21768dad34a4dc1 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Thu, 1 Sep 2022 19:38:09 -0700 Subject: [PATCH 210/279] Make prefetching correctly use context cache (#835) --- web/components/following-button.tsx | 13 ++----- web/components/referrals-button.tsx | 6 +-- web/hooks/use-contracts.ts | 13 ++++--- web/hooks/use-portfolio-history.ts | 15 +++++--- web/hooks/use-prefetch.ts | 15 ++++---- web/hooks/use-user-bets.ts | 11 +++--- web/hooks/use-user.ts | 15 ++++---- web/lib/firebase/bets.ts | 38 +++++++------------ web/lib/firebase/contracts.ts | 4 ++ web/lib/firebase/users.ts | 4 ++ .../api/v0/user/[username]/bets/index.ts | 5 ++- 11 files changed, 68 insertions(+), 71 deletions(-) diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx index c9aecbff..fdf739a1 100644 --- a/web/components/following-button.tsx +++ b/web/components/following-button.tsx @@ -2,9 +2,9 @@ import clsx from 'clsx' import { PencilIcon } from '@heroicons/react/outline' import { User } from 'common/user' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useFollowers, useFollows } from 'web/hooks/use-follows' -import { prefetchUsers, useUser } from 'web/hooks/use-user' +import { usePrefetchUsers, useUser } from 'web/hooks/use-user' import { FollowList } from './follow-list' import { Col } from './layout/col' import { Modal } from './layout/modal' @@ -105,16 +105,9 @@ function FollowsDialog(props: { const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } = props - useEffect(() => { - prefetchUsers([...followingIds, ...followerIds]) - }, [followingIds, followerIds]) - const currentUser = useUser() - const discoverUserIds = useDiscoverUsers(user?.id) - useEffect(() => { - prefetchUsers(discoverUserIds) - }, [discoverUserIds]) + usePrefetchUsers([...followingIds, ...followerIds, ...discoverUserIds]) return ( <Modal open={isOpen} setOpen={setIsOpen}> diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index 3cf77cfd..4b4f7095 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { User } from 'common/user' import { useEffect, useState } from 'react' -import { prefetchUsers, useUserById } from 'web/hooks/use-user' +import { usePrefetchUsers, useUserById } from 'web/hooks/use-user' import { Col } from './layout/col' import { Modal } from './layout/modal' import { Tabs } from './layout/tabs' @@ -56,9 +56,7 @@ function ReferralsDialog(props: { } }, [isOpen, referredByUser, user.referredByUserId]) - useEffect(() => { - prefetchUsers(referralIds) - }, [referralIds]) + usePrefetchUsers(referralIds) return ( <Modal open={isOpen} setOpen={setIsOpen}> diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 83be4636..4d7d2f79 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -9,9 +9,10 @@ import { listenForHotContracts, listenForInactiveContracts, listenForNewContracts, + getUserBetContracts, getUserBetContractsQuery, } from 'web/lib/firebase/contracts' -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' export const useContracts = () => { const [contracts, setContracts] = useState<Contract[] | undefined>() @@ -93,12 +94,12 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => { : undefined } -const queryClient = new QueryClient() - -export const prefetchUserBetContracts = (userId: string) => - queryClient.prefetchQuery(['contracts', 'bets', userId], () => - getUserBetContractsQuery(userId) +export const usePrefetchUserBetContracts = (userId: string) => { + const queryClient = useQueryClient() + return queryClient.prefetchQuery(['contracts', 'bets', userId], () => + getUserBetContracts(userId) ) +} export const useUserBetContracts = (userId: string) => { const result = useFirestoreQueryData( diff --git a/web/hooks/use-portfolio-history.ts b/web/hooks/use-portfolio-history.ts index 5abfdf11..1945eb7a 100644 --- a/web/hooks/use-portfolio-history.ts +++ b/web/hooks/use-portfolio-history.ts @@ -1,19 +1,22 @@ -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { DAY_MS, HOUR_MS } from 'common/util/time' -import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users' - -const queryClient = new QueryClient() +import { + getPortfolioHistory, + getPortfolioHistoryQuery, + Period, +} from 'web/lib/firebase/users' const getCutoff = (period: Period) => { const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS return periodToCutoff(nowRounded, period).valueOf() } -export const prefetchPortfolioHistory = (userId: string, period: Period) => { +export const usePrefetchPortfolioHistory = (userId: string, period: Period) => { + const queryClient = useQueryClient() const cutoff = getCutoff(period) return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () => - getPortfolioHistoryQuery(userId, cutoff) + getPortfolioHistory(userId, cutoff) ) } diff --git a/web/hooks/use-prefetch.ts b/web/hooks/use-prefetch.ts index 3724d456..46d78b3c 100644 --- a/web/hooks/use-prefetch.ts +++ b/web/hooks/use-prefetch.ts @@ -1,11 +1,12 @@ -import { prefetchUserBetContracts } from './use-contracts' -import { prefetchPortfolioHistory } from './use-portfolio-history' -import { prefetchUserBets } from './use-user-bets' +import { usePrefetchUserBetContracts } from './use-contracts' +import { usePrefetchPortfolioHistory } from './use-portfolio-history' +import { usePrefetchUserBets } from './use-user-bets' export function usePrefetch(userId: string | undefined) { const maybeUserId = userId ?? '' - - prefetchUserBets(maybeUserId) - prefetchUserBetContracts(maybeUserId) - prefetchPortfolioHistory(maybeUserId, 'weekly') + return Promise.all([ + usePrefetchUserBets(maybeUserId), + usePrefetchUserBetContracts(maybeUserId), + usePrefetchPortfolioHistory(maybeUserId, 'weekly'), + ]) } diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index a989636f..8f0bd9f7 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -1,16 +1,17 @@ -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useEffect, useState } from 'react' import { Bet, + getUserBets, getUserBetsQuery, listenForUserContractBets, } from 'web/lib/firebase/bets' -const queryClient = new QueryClient() - -export const prefetchUserBets = (userId: string) => - queryClient.prefetchQuery(['bets', userId], () => getUserBetsQuery(userId)) +export const usePrefetchUserBets = (userId: string) => { + const queryClient = useQueryClient() + return queryClient.prefetchQuery(['bets', userId], () => getUserBets(userId)) +} export const useUserBets = (userId: string) => { const result = useFirestoreQueryData( diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index b0cb1bc3..b355d87d 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -1,6 +1,6 @@ import { useContext } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' -import { QueryClient } from 'react-query' +import { useQueryClient } from 'react-query' import { doc, DocumentData } from 'firebase/firestore' import { getUser, User, users } from 'web/lib/firebase/users' @@ -28,12 +28,13 @@ export const useUserById = (userId = '_') => { return result.isLoading ? undefined : result.data } -const queryClient = new QueryClient() - -export const prefetchUser = (userId: string) => { - queryClient.prefetchQuery(['users', userId], () => getUser(userId)) +export const usePrefetchUser = (userId: string) => { + return usePrefetchUsers([userId])[0] } -export const prefetchUsers = (userIds: string[]) => { - userIds.forEach(prefetchUser) +export const usePrefetchUsers = (userIds: string[]) => { + const queryClient = useQueryClient() + return userIds.map((userId) => + queryClient.prefetchQuery(['users', userId], () => getUser(userId)) + ) } diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 7f44786a..2da95f9d 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -70,20 +70,16 @@ export function listenForBets( ) } -export async function getUserBets( - userId: string, - options: { includeRedemptions: boolean } -) { - const { includeRedemptions } = options - return getValues<Bet>( - query(collectionGroup(db, 'bets'), where('userId', '==', userId)) - ) - .then((bets) => - bets.filter( - (bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte - ) - ) - .catch((reason) => reason) +export async function getUserBets(userId: string) { + return getValues<Bet>(getUserBetsQuery(userId)) +} + +export function getUserBetsQuery(userId: string) { + return query( + collectionGroup(db, 'bets'), + where('userId', '==', userId), + orderBy('createdTime', 'desc') + ) as Query<Bet> } export async function getBets(options: { @@ -124,22 +120,16 @@ export async function getBets(options: { } export async function getContractsOfUserBets(userId: string) { - const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false }) - const contractIds = uniq(bets.map((bet) => bet.contractId)) + const bets = await getUserBets(userId) + const contractIds = uniq( + bets.filter((b) => !b.isAnte).map((bet) => bet.contractId) + ) const contracts = await Promise.all( contractIds.map((contractId) => getContractFromId(contractId)) ) return filterDefined(contracts) } -export function getUserBetsQuery(userId: string) { - return query( - collectionGroup(db, 'bets'), - where('userId', '==', userId), - orderBy('createdTime', 'desc') - ) as Query<Bet> -} - export function listenForUserContractBets( userId: string, contractId: string, diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 0fea53a0..c7e32f71 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -157,6 +157,10 @@ export function listenForUserContracts( return listenForValues<Contract>(q, setContracts) } +export function getUserBetContracts(userId: string) { + return getValues<Contract>(getUserBetContractsQuery(userId)) +} + export function getUserBetContractsQuery(userId: string) { return query( contracts, diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index fc024e04..4e29fb1c 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -254,6 +254,10 @@ export async function unfollow(userId: string, unfollowedUserId: string) { await deleteDoc(followDoc) } +export function getPortfolioHistory(userId: string, since: number) { + return getValues<PortfolioMetrics>(getPortfolioHistoryQuery(userId, since)) +} + export function getPortfolioHistoryQuery(userId: string, since: number) { return query( collectionGroup(db, 'portfolioHistory'), diff --git a/web/pages/api/v0/user/[username]/bets/index.ts b/web/pages/api/v0/user/[username]/bets/index.ts index 464af52c..57648f4d 100644 --- a/web/pages/api/v0/user/[username]/bets/index.ts +++ b/web/pages/api/v0/user/[username]/bets/index.ts @@ -18,8 +18,9 @@ export default async function handler( return } - const bets = await getUserBets(user.id, { includeRedemptions: false }) + const bets = await getUserBets(user.id) + const visibleBets = bets.filter((b) => !b.isRedemption && !b.isAnte) res.setHeader('Cache-Control', 'max-age=0') - return res.status(200).json(bets) + return res.status(200).json(visibleBets) } From 8029ee49a41636db2253ed127c751d2e411276d2 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 1 Sep 2022 23:06:14 -0500 Subject: [PATCH 211/279] Fix loans bug --- common/loans.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/loans.ts b/common/loans.ts index 05b64474..e05f1c2a 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = ( contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { - const openBets = bets.filter((bet) => bet.isSold || bet.sale) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) return openBets.map((bet) => { const loanAmount = bet.loanAmount ?? 0 From 0cb20d89ed005ef4cd8c75c585bb9851f7e8fa24 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 2 Sep 2022 10:35:41 -0500 Subject: [PATCH 212/279] numeric market labels: LOW/HIGH instead of MIN/MAX; eliminate payout <= MIN, etc. --- web/components/bets-list.tsx | 18 ------------------ web/pages/create.tsx | 6 +++--- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 932d689c..a8bd43f9 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -8,7 +8,6 @@ import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { - formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -483,23 +482,6 @@ export function BetsSummary(props: { <div className="whitespace-nowrap">{formatMoney(noWinnings)}</div> </Col> </> - ) : isPseudoNumeric ? ( - <> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Payout if {'>='} {formatLargeNumber(contract.max)} - </div> - <div className="whitespace-nowrap"> - {formatMoney(yesWinnings)} - </div> - </Col> - <Col> - <div className="whitespace-nowrap text-sm text-gray-500"> - Payout if {'<='} {formatLargeNumber(contract.min)} - </div> - <div className="whitespace-nowrap">{formatMoney(noWinnings)}</div> - </Col> - </> ) : ( <Col> <div className="whitespace-nowrap text-sm text-gray-500"> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 8ea76cef..23a88ec0 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -314,14 +314,14 @@ export function NewContract(props: { <div className="form-control mb-2 items-start"> <label className="label gap-2"> <span className="mb-1">Range</span> - <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> + <InfoTooltip text="The lower and higher bounds of the numeric range. Choose bounds the value could reasonably be expected to hit." /> </label> <Row className="gap-2"> <input type="number" className="input input-bordered w-32" - placeholder="MIN" + placeholder="LOW" onClick={(e) => e.stopPropagation()} onChange={(e) => setMinString(e.target.value)} min={Number.MIN_SAFE_INTEGER} @@ -332,7 +332,7 @@ export function NewContract(props: { <input type="number" className="input input-bordered w-32" - placeholder="MAX" + placeholder="HIGH" onClick={(e) => e.stopPropagation()} onChange={(e) => setMaxString(e.target.value)} min={Number.MIN_SAFE_INTEGER} From 4c429cd5191df0cafc980f5db0694057c20cc847 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:51:14 -0700 Subject: [PATCH 213/279] Remove some old code related to the old feed (#843) --- web/components/feed/find-active-contracts.ts | 99 -------------------- 1 file changed, 99 deletions(-) delete mode 100644 web/components/feed/find-active-contracts.ts diff --git a/web/components/feed/find-active-contracts.ts b/web/components/feed/find-active-contracts.ts deleted file mode 100644 index ad2af970..00000000 --- a/web/components/feed/find-active-contracts.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { groupBy, mapValues, maxBy, sortBy } from 'lodash' -import { Contract } from 'web/lib/firebase/contracts' -import { ContractComment } from 'common/comment' -import { Bet } from 'common/bet' - -const MAX_ACTIVE_CONTRACTS = 75 - -// This does NOT include comment times, since those aren't part of the contract atm. -// TODO: Maybe store last activity time directly in the contract? -// Pros: simplifies this code; cons: harder to tweak "activity" definition later -function lastActivityTime(contract: Contract) { - return Math.max(contract.resolutionTime || 0, contract.createdTime) -} - -// Types of activity to surface: -// - Comment on a market -// - New market created -// - Market resolved -// - Bet on market -export function findActiveContracts( - allContracts: Contract[], - recentComments: ContractComment[], - recentBets: Bet[], - seenContracts: { [contractId: string]: number } -) { - const idToActivityTime = new Map<string, number>() - function record(contractId: string, time: number) { - // Only record if the time is newer - const oldTime = idToActivityTime.get(contractId) - idToActivityTime.set(contractId, Math.max(oldTime ?? 0, time)) - } - - const contractsById = new Map(allContracts.map((c) => [c.id, c])) - - // Record contract activity. - for (const contract of allContracts) { - record(contract.id, lastActivityTime(contract)) - } - - // Add every contract that had a recent comment, too - for (const comment of recentComments) { - if (comment.contractId) { - const contract = contractsById.get(comment.contractId) - if (contract) record(contract.id, comment.createdTime) - } - } - - // Add contracts by last bet time. - const contractBets = groupBy(recentBets, (bet) => bet.contractId) - const contractMostRecentBet = mapValues( - contractBets, - (bets) => maxBy(bets, (bet) => bet.createdTime) as Bet - ) - for (const bet of Object.values(contractMostRecentBet)) { - const contract = contractsById.get(bet.contractId) - if (contract) record(contract.id, bet.createdTime) - } - - let activeContracts = allContracts.filter( - (contract) => - contract.visibility === 'public' && - !contract.isResolved && - (contract.closeTime ?? Infinity) > Date.now() - ) - activeContracts = sortBy( - activeContracts, - (c) => -(idToActivityTime.get(c.id) ?? 0) - ) - - const contractComments = groupBy( - recentComments, - (comment) => comment.contractId - ) - const contractMostRecentComment = mapValues( - contractComments, - (comments) => maxBy(comments, (c) => c.createdTime) as ContractComment - ) - - const prioritizedContracts = sortBy(activeContracts, (c) => { - const seenTime = seenContracts[c.id] - if (!seenTime) { - return 0 - } - - const lastCommentTime = contractMostRecentComment[c.id]?.createdTime - if (lastCommentTime && lastCommentTime > seenTime) { - return 1 - } - - const lastBetTime = contractMostRecentBet[c.id]?.createdTime - if (lastBetTime && lastBetTime > seenTime) { - return 2 - } - - return seenTime - }) - - return prioritizedContracts.slice(0, MAX_ACTIVE_CONTRACTS) -} From 21b9d0efab69735d74ac75eec05a1b1fcce28c0f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:51:27 -0700 Subject: [PATCH 214/279] Clean up some old pre-Amplitude tracking code (#841) --- web/components/bets-list.tsx | 11 +----- web/hooks/use-time-since-first-render.ts | 13 ------- web/lib/firebase/tracking.ts | 43 ------------------------ 3 files changed, 1 insertion(+), 66 deletions(-) delete mode 100644 web/hooks/use-time-since-first-render.ts delete mode 100644 web/lib/firebase/tracking.ts diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index a8bd43f9..b4538767 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash' import dayjs from 'dayjs' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import clsx from 'clsx' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' @@ -34,8 +34,6 @@ import { resolvedPayout, getContractBetNullMetrics, } from 'common/calculate' -import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' -import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' @@ -84,13 +82,6 @@ export function BetsList(props: { user: User }) { const start = page * CONTRACTS_PER_PAGE const end = start + CONTRACTS_PER_PAGE - const getTime = useTimeSinceFirstRender() - useEffect(() => { - if (bets && contractsById && signedInUser) { - trackLatency(signedInUser.id, 'portfolio', getTime()) - } - }, [signedInUser, bets, contractsById, getTime]) - if (!bets || !contractsById) { return <LoadingIndicator /> } diff --git a/web/hooks/use-time-since-first-render.ts b/web/hooks/use-time-since-first-render.ts deleted file mode 100644 index da132146..00000000 --- a/web/hooks/use-time-since-first-render.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react' - -export function useTimeSinceFirstRender() { - const startTimeRef = useRef(0) - useEffect(() => { - startTimeRef.current = Date.now() - }, []) - - return useCallback(() => { - if (!startTimeRef.current) return 0 - return Date.now() - startTimeRef.current - }, []) -} diff --git a/web/lib/firebase/tracking.ts b/web/lib/firebase/tracking.ts deleted file mode 100644 index d1828e01..00000000 --- a/web/lib/firebase/tracking.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { doc, collection, setDoc } from 'firebase/firestore' - -import { db } from './init' -import { ClickEvent, LatencyEvent, View } from 'common/tracking' - -export async function trackView(userId: string, contractId: string) { - const ref = doc(collection(db, 'private-users', userId, 'views')) - - const view: View = { - contractId, - timestamp: Date.now(), - } - - return await setDoc(ref, view) -} - -export async function trackClick(userId: string, contractId: string) { - const ref = doc(collection(db, 'private-users', userId, 'events')) - - const clickEvent: ClickEvent = { - type: 'click', - contractId, - timestamp: Date.now(), - } - - return await setDoc(ref, clickEvent) -} - -export async function trackLatency( - userId: string, - type: 'feed' | 'portfolio', - latency: number -) { - const ref = doc(collection(db, 'private-users', userId, 'latency')) - - const latencyEvent: LatencyEvent = { - type, - latency, - timestamp: Date.now(), - } - - return await setDoc(ref, latencyEvent) -} From b1bb6fab5b71854bdb1b25e041588fec367a5f84 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:51:41 -0700 Subject: [PATCH 215/279] Disable SSR on /home (#839) --- web/pages/home.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index ff4854d7..972aa639 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -4,23 +4,14 @@ import { PencilAltIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' import { ContractSearch } from 'web/components/contract-search' -import { User } from 'common/user' -import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { useTracking } from 'web/hooks/use-tracking' +import { useUser } from 'web/hooks/use-user' 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 { usePrefetch } from 'web/hooks/use-prefetch' -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 = props.auth ? props.auth.user : null +const Home = () => { + const user = useUser() const router = useRouter() useTracking('view home') From a429a98a29c5dd9e61ee578c232070055226fbad Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 12:52:27 -0700 Subject: [PATCH 216/279] Tidy up some dead code and markup in sidebar (#842) --- web/components/create-question-button.tsx | 26 ++++++----------------- web/components/nav/menu.tsx | 10 ++++----- web/components/nav/profile-menu.tsx | 2 +- web/components/nav/sidebar.tsx | 10 +++------ web/components/notifications-icon.tsx | 12 +++++------ 5 files changed, 19 insertions(+), 41 deletions(-) diff --git a/web/components/create-question-button.tsx b/web/components/create-question-button.tsx index c7299904..20225b78 100644 --- a/web/components/create-question-button.tsx +++ b/web/components/create-question-button.tsx @@ -1,27 +1,13 @@ import React from 'react' import Link from 'next/link' -import clsx from 'clsx' - -import { User } from 'web/lib/firebase/users' import { Button } from './button' -export const CreateQuestionButton = (props: { - user: User | null | undefined - overrideText?: string - className?: string - query?: string -}) => { - const { user, overrideText, className, query } = props - - if (!user || user?.isBannedFromPosting) return <></> - +export const CreateQuestionButton = () => { return ( - <div className={clsx('flex justify-center', className)}> - <Link href={`/create${query ? query : ''}`} passHref> - <Button color="gradient" size="xl" className="mt-4"> - {overrideText ?? 'Create a market'} - </Button> - </Link> - </div> + <Link href="/create" passHref> + <Button color="gradient" size="xl" className="mt-4"> + Create a market + </Button> + </Link> ) } diff --git a/web/components/nav/menu.tsx b/web/components/nav/menu.tsx index 07ee5c77..f61ebad9 100644 --- a/web/components/nav/menu.tsx +++ b/web/components/nav/menu.tsx @@ -19,12 +19,10 @@ export function MenuButton(props: { as="div" className={clsx(className ? className : 'relative z-40 flex-shrink-0')} > - <div> - <Menu.Button className="w-full rounded-full"> - <span className="sr-only">Open user menu</span> - {buttonContent} - </Menu.Button> - </div> + <Menu.Button className="w-full rounded-full"> + <span className="sr-only">Open user menu</span> + {buttonContent} + </Menu.Button> <Transition as={Fragment} enter="transition ease-out duration-100" diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 9e869c40..aad17d84 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -11,7 +11,7 @@ export function ProfileSummary(props: { user: User }) { <Link href={`/${user.username}?tab=bets`}> <a onClick={trackCallback('sidebar: profile')} - className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + 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" > <Avatar avatarUrl={user.avatarUrl} username={user.username} noLink /> diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 1b030098..d7adfa28 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -234,11 +234,7 @@ export default function Sidebar(props: { className?: string }) { {!user && <SignInButton className="mb-4" />} - {user && ( - <div className="min-h-[80px] w-full"> - <ProfileSummary user={user} /> - </div> - )} + {user && <ProfileSummary user={user} />} {/* Mobile navigation */} <div className="flex min-h-0 shrink flex-col gap-1 lg:hidden"> @@ -255,7 +251,7 @@ export default function Sidebar(props: { className?: string }) { </div> {/* Desktop navigation */} - <div className="hidden min-h-0 shrink flex-col gap-1 lg:flex"> + <div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex "> {navigationOptions.map((item) => ( <SidebarItem key={item.href} item={item} currentPage={currentPage} /> ))} @@ -264,7 +260,7 @@ export default function Sidebar(props: { className?: string }) { buttonContent={<MoreButton />} /> - {user && <CreateQuestionButton user={user} />} + {user && !user.isBannedFromPosting && <CreateQuestionButton />} </div> </nav> ) diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index 55284e96..2438fbed 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -12,11 +12,9 @@ export default function NotificationsIcon(props: { className?: string }) { const privateUser = usePrivateUser() return ( - <Row className={clsx('justify-center')}> - <div className={'relative'}> - {privateUser && <UnseenNotificationsBubble privateUser={privateUser} />} - <BellIcon className={clsx(props.className)} /> - </div> + <Row className="relative justify-center"> + {privateUser && <UnseenNotificationsBubble privateUser={privateUser} />} + <BellIcon className={clsx(props.className)} /> </Row> ) } @@ -32,11 +30,11 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) { const notifications = useUnseenGroupedNotification(privateUser) if (!notifications || notifications.length === 0 || seen) { - return <div /> + return null } return ( - <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2"> + <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:left-0 lg:-mt-1 lg:ml-2"> {notifications.length > NOTIFICATIONS_PER_PAGE ? `${NOTIFICATIONS_PER_PAGE}+` : notifications.length} From 245627a3476ff86b9bf1f49bc5c059abfe54122d Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 13:00:38 -0700 Subject: [PATCH 217/279] Temporarily patch groups loading to make dev deploy work --- web/pages/groups.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index aaf1374c..9ef2d8ff 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -21,7 +21,11 @@ import { SEO } from 'web/components/SEO' import { UserLink } from 'web/components/user-link' export async function getStaticProps() { - const groups = await listAllGroups().catch((_) => []) + let groups = await listAllGroups().catch((_) => []) + + // mqp: temporary fix to make dev deploy while Ian works on migrating groups away + // from the document array member and contracts representation + groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) const creators = await Promise.all( groups.map((group) => getUser(group.creatorId)) From d1e1937195970dfcf6b2dd86a7b15954c60ee93a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 13:04:00 -0700 Subject: [PATCH 218/279] Remove custom token generation machinery (#840) --- functions/src/get-custom-token.ts | 33 ------------------------------- functions/src/index.ts | 3 --- functions/src/serve.ts | 2 -- 3 files changed, 38 deletions(-) delete mode 100644 functions/src/get-custom-token.ts diff --git a/functions/src/get-custom-token.ts b/functions/src/get-custom-token.ts deleted file mode 100644 index 4aaaac11..00000000 --- a/functions/src/get-custom-token.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as admin from 'firebase-admin' -import { - APIError, - EndpointDefinition, - lookupUser, - parseCredentials, - writeResponseError, -} from './api' - -const opts = { - method: 'GET', - minInstances: 1, - concurrency: 100, - memory: '2GiB', - cpu: 1, -} as const - -export const getcustomtoken: EndpointDefinition = { - opts, - handler: async (req, res) => { - try { - const credentials = await parseCredentials(req) - if (credentials.kind != 'jwt') { - throw new APIError(403, 'API keys cannot mint custom tokens.') - } - const user = await lookupUser(credentials) - const token = await admin.auth().createCustomToken(user.uid) - res.status(200).json({ token: token }) - } catch (e) { - writeResponseError(e, res) - } - }, -} diff --git a/functions/src/index.ts b/functions/src/index.ts index 2ec7f3ce..9a5ec872 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -72,7 +72,6 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' -import { getcustomtoken } from './get-custom-token' import { createpost } from './create-post' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { @@ -98,7 +97,6 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) -const getCustomTokenFunction = toCloudFunction(getcustomtoken) const createPostFunction = toCloudFunction(createpost) export { @@ -122,6 +120,5 @@ export { createCheckoutSessionFunction as createcheckoutsession, getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, - getCustomTokenFunction as getcustomtoken, createPostFunction as createpost, } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index db847a70..a5291f19 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -26,7 +26,6 @@ import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' -import { getcustomtoken } from './get-custom-token' import { createpost } from './create-post' type Middleware = (req: Request, res: Response, next: NextFunction) => void @@ -66,7 +65,6 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) -addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/createpost', createpost) From b6449ad296ebf12385010f5ae75746e5e7062d4a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 2 Sep 2022 15:32:47 -0500 Subject: [PATCH 219/279] fix bet panel warnings --- web/components/bet-panel.tsx | 64 +++++++++++++++++------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index f958ed87..311a6182 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -8,6 +8,7 @@ import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' import { + formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -28,7 +29,7 @@ import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' -import { getFormattedMappedValue } from 'common/pseudo-numeric' +import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveBinaryShares } from './use-save-binary-shares' import { BetSignUpPrompt } from './sign-up-prompt' @@ -256,17 +257,43 @@ function BuyPanel(props: { const resultProb = getCpmmProbability(newPool, newP) const probStayedSame = formatPercent(resultProb) === formatPercent(initialProb) + const probChange = Math.abs(resultProb - initialProb) - const currentPayout = newBet.shares - const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) const format = getFormattedMappedValue(contract) + const getValue = getMappedValue(contract) + const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb)) + const displayedDifference = isPseudoNumeric + ? formatLargeNumber(rawDifference) + : formatPercent(rawDifference) + const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) + const warning = + (betAmount ?? 0) > 10 && + bankrollFraction >= 0.5 && + bankrollFraction <= 1 ? ( + <AlertBox + title="Whoa, there!" + text={`You might not want to spend ${formatPercent( + bankrollFraction + )} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( + user?.balance ?? 0 + )}`} + /> + ) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? ( + <AlertBox + title="Whoa, there!" + text={`Are you sure you want to move the market by ${displayedDifference}?`} + /> + ) : ( + <></> + ) + return ( <Col className={hidden ? 'hidden' : ''}> <div className="my-3 text-left text-sm text-gray-500"> @@ -296,33 +323,7 @@ function BuyPanel(props: { inputRef={inputRef} /> - {(betAmount ?? 0) > 10 && - bankrollFraction >= 0.5 && - bankrollFraction <= 1 ? ( - <AlertBox - title="Whoa, there!" - text={`You might not want to spend ${formatPercent( - bankrollFraction - )} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( - user?.balance ?? 0 - )}`} - /> - ) : ( - '' - )} - - {(betAmount ?? 0) > 10 && probChange >= 0.3 ? ( - <AlertBox - title="Whoa, there!" - text={`Are you sure you want to move the market ${ - isPseudoNumeric && contract.isLogScale - ? 'this much' - : format(probChange) - }?`} - /> - ) : ( - '' - )} + {warning} <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> @@ -351,9 +352,6 @@ function BuyPanel(props: { </> )} </div> - {/* <InfoTooltip - text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`} - /> */} </Row> <div> <span className="mr-2 whitespace-nowrap"> From 00de66cd7910205651e8c7a94f7b154fd79f8683 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 15:59:32 -0500 Subject: [PATCH 220/279] Leaderboard calc: update profit even when portfolio didn't change (#845) * Leaderboard calc: remove didProfitChange optimization that was incorrect * Put back didPortfolioChange for deciding whether to create new history doc. --- functions/src/update-metrics.ts | 60 +++++++++++++++------------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 9ef3fb10..305cd80c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,13 +1,12 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash' +import { groupBy, isEmpty, keyBy, last, sortBy, sum, sumBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' import { calculatePayout } from '../../common/calculate' import { DAY_MS } from '../../common/util/time' -import { last } from 'lodash' import { getLoanUpdates } from '../../common/loans' const firestore = admin.firestore() @@ -88,23 +87,20 @@ export const updateMetricsCore = async () => { currentBets ) const lastPortfolio = last(portfolioHistory) - const didProfitChange = + const didPortfolioChange = lastPortfolio === undefined || lastPortfolio.balance !== newPortfolio.balance || lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || lastPortfolio.investmentValue !== newPortfolio.investmentValue - const newProfit = calculateNewProfit( - portfolioHistory, - newPortfolio, - didProfitChange - ) + const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + return { user, newCreatorVolume, newPortfolio, newProfit, - didProfitChange, + didPortfolioChange, } }) @@ -120,16 +116,20 @@ export const updateMetricsCore = async () => { const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const userUpdates = userMetrics.map( - ({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => { + ({ + user, + newCreatorVolume, + newPortfolio, + newProfit, + didPortfolioChange, + }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { fieldUpdates: { doc: firestore.collection('users').doc(user.id), fields: { creatorVolumeCached: newCreatorVolume, - ...(didProfitChange && { - profitCached: newProfit, - }), + profitCached: newProfit, nextLoanCached, }, }, @@ -140,11 +140,7 @@ export const updateMetricsCore = async () => { .doc(user.id) .collection('portfolioHistory') .doc(), - fields: { - ...(didProfitChange && { - ...newPortfolio, - }), - }, + fields: didPortfolioChange ? newPortfolio : {}, }, } } @@ -171,15 +167,15 @@ const computeVolume = (contractBets: Bet[], since: number) => { const calculateProfitForPeriod = ( startTime: number, - portfolioHistory: PortfolioMetrics[], + descendingPortfolio: PortfolioMetrics[], currentProfit: number ) => { - const startingPortfolio = [...portfolioHistory] - .reverse() // so we search in descending order (most recent first), for efficiency - .find((p) => p.timestamp < startTime) + const startingPortfolio = descendingPortfolio.find( + (p) => p.timestamp < startTime + ) if (startingPortfolio === undefined) { - return 0 + return currentProfit } const startingProfit = calculateTotalProfit(startingPortfolio) @@ -233,28 +229,28 @@ const calculateNewPortfolioMetrics = ( const calculateNewProfit = ( portfolioHistory: PortfolioMetrics[], - newPortfolio: PortfolioMetrics, - didProfitChange: boolean + newPortfolio: PortfolioMetrics ) => { - if (!didProfitChange) { - return {} // early return for performance - } - const allTimeProfit = calculateTotalProfit(newPortfolio) + const descendingPortfolio = sortBy( + portfolioHistory, + (p) => p.timestamp + ).reverse() + const newProfit = { daily: calculateProfitForPeriod( Date.now() - 1 * DAY_MS, - portfolioHistory, + descendingPortfolio, allTimeProfit ), weekly: calculateProfitForPeriod( Date.now() - 7 * DAY_MS, - portfolioHistory, + descendingPortfolio, allTimeProfit ), monthly: calculateProfitForPeriod( Date.now() - 30 * DAY_MS, - portfolioHistory, + descendingPortfolio, allTimeProfit ), allTime: allTimeProfit, From 231d3e65c4a86a345d856dbc521c639ef49952fb Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 16:19:10 -0500 Subject: [PATCH 221/279] Fix incorrect error message for no bets --- web/components/bets-list.tsx | 40 ++++++++++++++++++++--------------- web/components/pagination.tsx | 2 +- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index b4538767..2a9a76a1 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -209,26 +209,27 @@ export function BetsList(props: { user: User }) { <Col className="mt-6 divide-y"> {displayedContracts.length === 0 ? ( - <NoBets user={user} /> + <NoMatchingBets /> ) : ( - displayedContracts.map((contract) => ( - <ContractBets - key={contract.id} - contract={contract} - bets={contractBets[contract.id] ?? []} - metric={sort === 'profit' ? 'profit' : 'value'} - isYourBets={isYourBets} + <> + {displayedContracts.map((contract) => ( + <ContractBets + key={contract.id} + contract={contract} + bets={contractBets[contract.id] ?? []} + metric={sort === 'profit' ? 'profit' : 'value'} + isYourBets={isYourBets} + /> + ))} + <Pagination + page={page} + itemsPerPage={CONTRACTS_PER_PAGE} + totalItems={filteredContracts.length} + setPage={setPage} /> - )) + </> )} </Col> - - <Pagination - page={page} - itemsPerPage={CONTRACTS_PER_PAGE} - totalItems={filteredContracts.length} - setPage={setPage} - /> </Col> ) } @@ -236,7 +237,7 @@ export function BetsList(props: { user: User }) { const NoBets = ({ user }: { user: User }) => { const me = useUser() return ( - <div className="mx-4 text-gray-500"> + <div className="mx-4 py-4 text-gray-500"> {user.id === me?.id ? ( <> You have not made any bets yet.{' '} @@ -250,6 +251,11 @@ const NoBets = ({ user }: { user: User }) => { </div> ) } +const NoMatchingBets = () => ( + <div className="mx-4 py-4 text-gray-500"> + No bets matching the current filter. + </div> +) function ContractBets(props: { contract: Contract diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index 8c008ab0..8dde743c 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -58,7 +58,7 @@ export function Pagination(props: { const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 - if (maxPage === 0) return <Spacer h={4} /> + if (maxPage <= 0) return <Spacer h={4} /> return ( <nav From af68fa6c42d11e340f5680250106273f09be78e5 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 16:20:04 -0500 Subject: [PATCH 222/279] Fix typo in email followup --- functions/src/emails.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index b37f8da0..2c9c6f12 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -186,7 +186,7 @@ export const sendPersonalFollowupEmail = async ( const emailBody = `Hi ${firstName}, -Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far? +Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience on the platform so far? If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh). From 2f53cef36f4b64111c48f022bca6cc23a82d9008 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 18:45:42 -0500 Subject: [PATCH 223/279] Move metrics calculation to common --- common/calculate-metrics.ts | 131 +++++++++++++++++++++++++++++ functions/src/update-metrics.ts | 144 +++----------------------------- 2 files changed, 143 insertions(+), 132 deletions(-) create mode 100644 common/calculate-metrics.ts diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts new file mode 100644 index 00000000..e3b8ea39 --- /dev/null +++ b/common/calculate-metrics.ts @@ -0,0 +1,131 @@ +import { sortBy, sum, sumBy } from 'lodash' +import { calculatePayout } from './calculate' +import { Bet } from './bet' +import { Contract } from './contract' +import { PortfolioMetrics, User } from './user' +import { DAY_MS } from './util/time' + +const computeInvestmentValue = ( + bets: Bet[], + contractsDict: { [k: string]: Contract } +) => { + return sumBy(bets, (bet) => { + const contract = contractsDict[bet.contractId] + if (!contract || contract.isResolved) return 0 + if (bet.sale || bet.isSold) return 0 + + const payout = calculatePayout(contract, bet, 'MKT') + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value + }) +} + +const computeTotalPool = (userContracts: Contract[], startTime = 0) => { + const periodFilteredContracts = userContracts.filter( + (contract) => contract.createdTime >= startTime + ) + return sum( + periodFilteredContracts.map((contract) => sum(Object.values(contract.pool))) + ) +} + +export const computeVolume = (contractBets: Bet[], since: number) => { + return sumBy(contractBets, (b) => + b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 + ) +} + +export const calculateCreatorVolume = (userContracts: Contract[]) => { + const allTimeCreatorVolume = computeTotalPool(userContracts, 0) + const monthlyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 30 * DAY_MS + ) + const weeklyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 7 * DAY_MS + ) + + const dailyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 1 * DAY_MS + ) + + return { + daily: dailyCreatorVolume, + weekly: weeklyCreatorVolume, + monthly: monthlyCreatorVolume, + allTime: allTimeCreatorVolume, + } +} + +export const calculateNewPortfolioMetrics = ( + user: User, + contractsById: { [k: string]: Contract }, + currentBets: Bet[] +) => { + const investmentValue = computeInvestmentValue(currentBets, contractsById) + const newPortfolio = { + investmentValue: investmentValue, + balance: user.balance, + totalDeposits: user.totalDeposits, + timestamp: Date.now(), + userId: user.id, + } + return newPortfolio +} + +const calculateProfitForPeriod = ( + startTime: number, + descendingPortfolio: PortfolioMetrics[], + currentProfit: number +) => { + const startingPortfolio = descendingPortfolio.find( + (p) => p.timestamp < startTime + ) + + if (startingPortfolio === undefined) { + return currentProfit + } + + const startingProfit = calculateTotalProfit(startingPortfolio) + + return currentProfit - startingProfit +} + +const calculateTotalProfit = (portfolio: PortfolioMetrics) => { + return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits +} + +export const calculateNewProfit = ( + portfolioHistory: PortfolioMetrics[], + newPortfolio: PortfolioMetrics +) => { + const allTimeProfit = calculateTotalProfit(newPortfolio) + const descendingPortfolio = sortBy( + portfolioHistory, + (p) => p.timestamp + ).reverse() + + const newProfit = { + daily: calculateProfitForPeriod( + Date.now() - 1 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + weekly: calculateProfitForPeriod( + Date.now() - 7 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + monthly: calculateProfitForPeriod( + Date.now() - 30 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + allTime: allTimeProfit, + } + + return newProfit +} diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 305cd80c..c6673969 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,42 +1,27 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last, sortBy, sum, sumBy } from 'lodash' +import { groupBy, isEmpty, keyBy, last } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract } from '../../common/contract' import { PortfolioMetrics, User } from '../../common/user' -import { calculatePayout } from '../../common/calculate' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { + calculateCreatorVolume, + calculateNewPortfolioMetrics, + calculateNewProfit, + computeVolume, +} from '../../common/calculate-metrics' const firestore = admin.firestore() -const computeInvestmentValue = ( - bets: Bet[], - contractsDict: { [k: string]: Contract } -) => { - return sumBy(bets, (bet) => { - const contract = contractsDict[bet.contractId] - if (!contract || contract.isResolved) return 0 - if (bet.sale || bet.isSold) return 0 +export const updateMetrics = functions + .runWith({ memory: '2GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 15 minutes') + .onRun(updateMetricsCore) - const payout = calculatePayout(contract, bet, 'MKT') - const value = payout - (bet.loanAmount ?? 0) - if (isNaN(value)) return 0 - return value - }) -} - -const computeTotalPool = (userContracts: Contract[], startTime = 0) => { - const periodFilteredContracts = userContracts.filter( - (contract) => contract.createdTime >= startTime - ) - return sum( - periodFilteredContracts.map((contract) => sum(Object.values(contract.pool))) - ) -} - -export const updateMetricsCore = async () => { +export async function updateMetricsCore() { const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ getValues<User>(firestore.collection('users')), getValues<Contract>(firestore.collection('contracts')), @@ -158,108 +143,3 @@ export const updateMetricsCore = async () => { ) log(`Updated metrics for ${users.length} users.`) } - -const computeVolume = (contractBets: Bet[], since: number) => { - return sumBy(contractBets, (b) => - b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 - ) -} - -const calculateProfitForPeriod = ( - startTime: number, - descendingPortfolio: PortfolioMetrics[], - currentProfit: number -) => { - const startingPortfolio = descendingPortfolio.find( - (p) => p.timestamp < startTime - ) - - if (startingPortfolio === undefined) { - return currentProfit - } - - const startingProfit = calculateTotalProfit(startingPortfolio) - - return currentProfit - startingProfit -} - -const calculateTotalProfit = (portfolio: PortfolioMetrics) => { - return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits -} - -const calculateCreatorVolume = (userContracts: Contract[]) => { - const allTimeCreatorVolume = computeTotalPool(userContracts, 0) - const monthlyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 30 * DAY_MS - ) - const weeklyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 7 * DAY_MS - ) - - const dailyCreatorVolume = computeTotalPool( - userContracts, - Date.now() - 1 * DAY_MS - ) - - return { - daily: dailyCreatorVolume, - weekly: weeklyCreatorVolume, - monthly: monthlyCreatorVolume, - allTime: allTimeCreatorVolume, - } -} - -const calculateNewPortfolioMetrics = ( - user: User, - contractsById: { [k: string]: Contract }, - currentBets: Bet[] -) => { - const investmentValue = computeInvestmentValue(currentBets, contractsById) - const newPortfolio = { - investmentValue: investmentValue, - balance: user.balance, - totalDeposits: user.totalDeposits, - timestamp: Date.now(), - userId: user.id, - } - return newPortfolio -} - -const calculateNewProfit = ( - portfolioHistory: PortfolioMetrics[], - newPortfolio: PortfolioMetrics -) => { - const allTimeProfit = calculateTotalProfit(newPortfolio) - const descendingPortfolio = sortBy( - portfolioHistory, - (p) => p.timestamp - ).reverse() - - const newProfit = { - daily: calculateProfitForPeriod( - Date.now() - 1 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - weekly: calculateProfitForPeriod( - Date.now() - 7 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - monthly: calculateProfitForPeriod( - Date.now() - 30 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - allTime: allTimeProfit, - } - - return newProfit -} - -export const updateMetrics = functions - .runWith({ memory: '2GB', timeoutSeconds: 540 }) - .pubsub.schedule('every 15 minutes') - .onRun(updateMetricsCore) From cf508fd8b6f682db20db17e20e83c427951c23bd Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 18:06:48 -0600 Subject: [PATCH 224/279] Members and contracts now subcollections of groups (#847) * Members and contracts now documents * undo loans change? * Handle closed group * Slight refactoring * Don't allow modification of private groups contracts * Add back in numMembers * Update group field names * Update firestore rules * Update firestore rules * Handle updated groups * update start numbers * Lint * Lint --- common/group.ts | 10 +- firestore.rules | 33 +- functions/src/create-group.ts | 14 +- functions/src/create-market.ts | 25 +- functions/src/create-user.ts | 24 +- functions/src/index.ts | 2 - functions/src/on-create-comment-on-group.ts | 46 --- functions/src/on-create-group.ts | 28 -- functions/src/on-update-group.ts | 65 ++- functions/src/scripts/convert-categories.ts | 108 ----- functions/src/scripts/convert-tag-to-group.ts | 63 ++- functions/src/scripts/update-groups.ts | 109 +++++ web/components/contract-search.tsx | 4 +- .../groups/contract-groups-list.tsx | 15 +- web/components/groups/edit-group-button.tsx | 13 +- web/components/groups/group-chat.tsx | 391 ------------------ web/components/groups/groups-button.tsx | 46 +-- web/hooks/use-group.ts | 86 ++-- web/lib/firebase/groups.ts | 136 +++--- web/pages/create.tsx | 4 +- web/pages/group/[...slugs]/index.tsx | 20 +- web/pages/groups.tsx | 95 ++--- web/pages/tournaments/index.tsx | 2 +- 23 files changed, 481 insertions(+), 858 deletions(-) delete mode 100644 functions/src/on-create-comment-on-group.ts delete mode 100644 functions/src/on-create-group.ts delete mode 100644 functions/src/scripts/convert-categories.ts create mode 100644 functions/src/scripts/update-groups.ts delete mode 100644 web/components/groups/group-chat.tsx diff --git a/common/group.ts b/common/group.ts index 181ad153..5c716dba 100644 --- a/common/group.ts +++ b/common/group.ts @@ -6,14 +6,16 @@ export type Group = { creatorId: string // User id createdTime: number mostRecentActivityTime: number - memberIds: string[] // User ids anyoneCanJoin: boolean - contractIds: string[] - + totalContracts: number + totalMembers: number aboutPostId?: string chatDisabled?: boolean - mostRecentChatActivityTime?: number 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/firestore.rules b/firestore.rules index e42e3ed7..15b60d0f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -160,25 +160,40 @@ service cloud.firestore { .hasOnly(['isSeen', 'viewTime']); } - match /groups/{groupId} { + match /{somePath=**}/groupMembers/{memberId} { + allow read; + } + + match /{somePath=**}/groupContracts/{contractId} { + allow read; + } + + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]); - allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin) - && request.resource.data.diff(resource.data) - .affectedKeys() - .hasOnly([ 'contractIds', 'memberIds' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); allow delete: if request.auth.uid == resource.data.creatorId; - function isMember() { - return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds; + match /groupContracts/{contractId} { + allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId + } + + match /groupMembers/{memberId}{ + allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); + allow delete: if request.auth.uid == resource.data.userId; + } + + function isGroupMember() { + return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid)); } + match /comments/{commentId} { allow read; - allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember(); } + } match /posts/{postId} { diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 71c6bd64..fc64aeff 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => { createdTime: Date.now(), mostRecentActivityTime: Date.now(), // TODO: allow users to add contract ids on group creation - contractIds: [], anyoneCanJoin, - memberIds, + totalContracts: 0, + totalMembers: memberIds.length, } await groupRef.create(group) + // create a GroupMemberDoc for each member + await Promise.all( + memberIds.map((memberId) => + groupRef.collection('groupMembers').doc(memberId).create({ + userId: memberId, + createdTime: Date.now(), + }) + ) + ) + return { status: 'success', group: group } }) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index e9804f90..300d91f2 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } group = groupDoc.data() as Group + const groupMembersSnap = await firestore + .collection(`groups/${groupId}/groupMembers`) + .get() + const groupMemberDocs = groupMembersSnap.docs.map( + (doc) => doc.data() as { userId: string; createdTime: number } + ) if ( - !group.memberIds.includes(user.id) && + !groupMemberDocs.map((m) => m.userId).includes(user.id) && !group.anyoneCanJoin && group.creatorId !== user.id ) { @@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => { await contractRef.create(contract) if (group != null) { - if (!group.contractIds.includes(contractRef.id)) { + const groupContractsSnap = await firestore + .collection(`groups/${groupId}/groupContracts`) + .get() + const groupContracts = groupContractsSnap.docs.map( + (doc) => doc.data() as { contractId: string; createdTime: number } + ) + if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { await createGroupLinks(group, [contractRef.id], auth.uid) - const groupDocRef = firestore.collection('groups').doc(group.id) - groupDocRef.update({ - contractIds: uniq([...group.contractIds, contractRef.id]), + const groupContractRef = firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + await groupContractRef.set({ + contractId: contract.id, + createdTime: Date.now(), }) } } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 35394e90..eabe0fd0 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,5 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { uniq } from 'lodash' import { PrivateUser, User } from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' @@ -17,7 +16,7 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' -import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' +import { Group } from '../../common/group' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' const bodySchema = z.object({ @@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => { firestore.collection('groups').where('slug', '==', slug) ) await firestore - .collection('groups') - .doc(groups[0].id) - .update({ - memberIds: uniq(groups[0].memberIds.concat(user.id)), - }) - } - - for (const slug of NEW_USER_GROUP_SLUGS) { - const groups = await getValues<Group>( - firestore.collection('groups').where('slug', '==', slug) - ) - const group = groups[0] - await firestore - .collection('groups') - .doc(group.id) - .update({ - memberIds: uniq(group.memberIds.concat(user.id)), - }) + .collection(`groups/${groups[0].id}/groupMembers`) + .doc(user.id) + .set({ userId: user.id, createdTime: Date.now() }) } } diff --git a/functions/src/index.ts b/functions/src/index.ts index 9a5ec872..be73b6af 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -21,9 +21,7 @@ export * from './on-follow-user' export * from './on-unfollow-user' export * from './on-create-liquidity-provision' export * from './on-update-group' -export * from './on-create-group' export * from './on-update-user' -export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts deleted file mode 100644 index 15f2bbc1..00000000 --- a/functions/src/on-create-comment-on-group.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as functions from 'firebase-functions' -import { GroupComment } from '../../common/comment' -import * as admin from 'firebase-admin' -import { Group } from '../../common/group' -import { User } from '../../common/user' -import { createGroupCommentNotification } from './create-notification' -const firestore = admin.firestore() - -export const onCreateCommentOnGroup = functions.firestore - .document('groups/{groupId}/comments/{commentId}') - .onCreate(async (change, context) => { - const { eventId } = context - const { groupId } = context.params as { - groupId: string - } - - const comment = change.data() as GroupComment - const creatorSnapshot = await firestore - .collection('users') - .doc(comment.userId) - .get() - if (!creatorSnapshot.exists) throw new Error('Could not find user') - - const groupSnapshot = await firestore - .collection('groups') - .doc(groupId) - .get() - if (!groupSnapshot.exists) throw new Error('Could not find group') - - const group = groupSnapshot.data() as Group - await firestore.collection('groups').doc(groupId).update({ - mostRecentChatActivityTime: comment.createdTime, - }) - - await Promise.all( - group.memberIds.map(async (memberId) => { - return await createGroupCommentNotification( - creatorSnapshot.data() as User, - memberId, - comment, - group, - eventId - ) - }) - ) - }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts deleted file mode 100644 index 5209788d..00000000 --- a/functions/src/on-create-group.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createNotification } from './create-notification' -import { Group } from '../../common/group' - -export const onCreateGroup = functions.firestore - .document('groups/{groupId}') - .onCreate(async (change, context) => { - const group = change.data() as Group - const { eventId } = context - - const groupCreator = await getUser(group.creatorId) - if (!groupCreator) throw new Error('Could not find group creator') - // create notifications for all members of the group - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - { - recipients: group.memberIds, - slug: group.slug, - title: group.name, - } - ) - }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index 7e6a5697..93fb5550 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return - if (prevGroup.contractIds.length < group.contractIds.length) { - await firestore - .collection('groups') - .doc(group.id) - .update({ mostRecentContractAddedTime: Date.now() }) - //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url - // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two - } - await firestore .collection('groups') .doc(group.id) .update({ mostRecentActivityTime: Date.now() }) }) +export const onCreateGroupContract = functions.firestore + .document('groups/{groupId}/groupContracts/{contractId}') + .onCreate(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentContractAddedTime: Date.now(), + totalContracts: admin.firestore.FieldValue.increment(1), + }) + }) + +export const onDeleteGroupContract = functions.firestore + .document('groups/{groupId}/groupContracts/{contractId}') + .onDelete(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentContractAddedTime: Date.now(), + totalContracts: admin.firestore.FieldValue.increment(-1), + }) + }) + +export const onCreateGroupMember = functions.firestore + .document('groups/{groupId}/groupMembers/{memberId}') + .onCreate(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentActivityTime: Date.now(), + totalMembers: admin.firestore.FieldValue.increment(1), + }) + }) + +export const onDeleteGroupMember = functions.firestore + .document('groups/{groupId}/groupMembers/{memberId}') + .onDelete(async (change) => { + const groupId = change.ref.parent.parent?.id + if (groupId) + await firestore + .collection('groups') + .doc(groupId) + .update({ + mostRecentActivityTime: Date.now(), + totalMembers: admin.firestore.FieldValue.increment(-1), + }) + }) + export async function removeGroupLinks(group: Group, contractIds: string[]) { for (const contractId of contractIds) { const contract = await getContract(contractId) diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts deleted file mode 100644 index 3436bcbc..00000000 --- a/functions/src/scripts/convert-categories.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as admin from 'firebase-admin' - -import { initAdmin } from './script-init' -import { getValues, isProd } from '../utils' -import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' -import { Group, GroupLink } from 'common/group' -import { uniq } from 'lodash' -import { Contract } from 'common/contract' -import { User } from 'common/user' -import { filterDefined } from 'common/util/array' -import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, - HOUSE_LIQUIDITY_PROVIDER_ID, -} from 'common/antes' - -initAdmin() - -const adminFirestore = admin.firestore() - -const convertCategoriesToGroupsInternal = async (categories: string[]) => { - for (const category of categories) { - const markets = await getValues<Contract>( - adminFirestore - .collection('contracts') - .where('lowercaseTags', 'array-contains', category.toLowerCase()) - ) - const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX - const oldGroup = await getValues<Group>( - adminFirestore.collection('groups').where('slug', '==', slug) - ) - if (oldGroup.length > 0) { - console.log(`Found old group for ${category}`) - await adminFirestore.collection('groups').doc(oldGroup[0].id).delete() - } - - const allUsers = await getValues<User>(adminFirestore.collection('users')) - const groupUsers = filterDefined( - allUsers.map((user: User) => { - if (!user.followedCategories || user.followedCategories.length === 0) - return user.id - if (!user.followedCategories.includes(category.toLowerCase())) - return null - return user.id - }) - ) - - const manifoldAccount = isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - const newGroupRef = await adminFirestore.collection('groups').doc() - const newGroup: Group = { - id: newGroupRef.id, - name: category, - slug, - creatorId: manifoldAccount, - createdTime: Date.now(), - anyoneCanJoin: true, - memberIds: [manifoldAccount], - about: 'Default group for all things related to ' + category, - mostRecentActivityTime: Date.now(), - contractIds: markets.map((market) => market.id), - chatDisabled: true, - } - - await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup) - // Update group with new memberIds to avoid notifying everyone - await adminFirestore - .collection('groups') - .doc(newGroupRef.id) - .update({ - memberIds: uniq(groupUsers), - }) - - for (const market of markets) { - if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id)) - continue // already in that group - - const newGroupLinks = [ - ...(market.groupLinks ?? []), - { - groupId: newGroup.id, - createdTime: Date.now(), - slug: newGroup.slug, - name: newGroup.name, - } as GroupLink, - ] - await adminFirestore - .collection('contracts') - .doc(market.id) - .update({ - groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]), - groupLinks: newGroupLinks, - }) - } - } -} - -async function convertCategoriesToGroups() { - // const defaultCategories = Object.values(DEFAULT_CATEGORIES) - const moreCategories = ['world', 'culture'] - await convertCategoriesToGroupsInternal(moreCategories) -} - -if (require.main === module) { - convertCategoriesToGroups() - .then(() => process.exit()) - .catch(console.log) -} diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index 48f14e27..3240357e 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -4,21 +4,23 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' import { isProd, log } from '../utils' import { getSlug } from '../create-group' -import { Group } from '../../../common/group' +import { Group, GroupLink } from '../../../common/group' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' -const getTaggedContractIds = async (tag: string) => { +const getTaggedContracts = async (tag: string) => { const firestore = admin.firestore() const results = await firestore .collection('contracts') .where('lowercaseTags', 'array-contains', tag.toLowerCase()) .get() - return results.docs.map((d) => d.id) + return results.docs.map((d) => d.data() as Contract) } const createGroup = async ( name: string, about: string, - contractIds: string[] + contracts: Contract[] ) => { const firestore = admin.firestore() const creatorId = isProd() @@ -36,21 +38,60 @@ const createGroup = async ( about, createdTime: now, mostRecentActivityTime: now, - contractIds: contractIds, anyoneCanJoin: true, - memberIds: [], + totalContracts: contracts.length, + totalMembers: 1, } - return await groupRef.create(group) + await groupRef.create(group) + // create a GroupMemberDoc for the creator + const memberDoc = groupRef.collection('groupMembers').doc(creatorId) + await memberDoc.create({ + userId: creatorId, + createdTime: now, + }) + + // create GroupContractDocs for each contractId + await Promise.all( + contracts + .map((c) => c.id) + .map((contractId) => + groupRef.collection('groupContracts').doc(contractId).create({ + contractId, + createdTime: now, + }) + ) + ) + for (const market of contracts) { + if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group + + const newGroupLinks = [ + ...(market.groupLinks ?? []), + { + groupId: group.id, + createdTime: Date.now(), + slug: group.slug, + name: group.name, + } as GroupLink, + ] + await firestore + .collection('contracts') + .doc(market.id) + .update({ + groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) + } + return { status: 'success', group: group } } const convertTagToGroup = async (tag: string, groupName: string) => { log(`Looking up contract IDs with tag ${tag}...`) - const contractIds = await getTaggedContractIds(tag) - log(`${contractIds.length} contracts found.`) - if (contractIds.length > 0) { + const contracts = await getTaggedContracts(tag) + log(`${contracts.length} contracts found.`) + if (contracts.length > 0) { log(`Creating group ${groupName}...`) const about = `Contracts that used to be tagged ${tag}.` - const result = await createGroup(groupName, about, contractIds) + const result = await createGroup(groupName, about, contracts) log(`Done. Group: `, result) } } diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts new file mode 100644 index 00000000..952a0d55 --- /dev/null +++ b/functions/src/scripts/update-groups.ts @@ -0,0 +1,109 @@ +import * as admin from 'firebase-admin' +import { Group } from 'common/group' +import { initAdmin } from 'functions/src/scripts/script-init' +import { log } from '../utils' + +const getGroups = async () => { + const firestore = admin.firestore() + const groups = await firestore.collection('groups').get() + 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 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, + }) +} + +// 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) + } + } +} + +async function updateTotalContractsAndMembers() { + const groups = await getGroups() + for (const group of groups) { + log('updating group total contracts and members', 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 + await groupRef.update({ + totalMembers, + totalContracts, + }) + } +} + +if (require.main === module) { + initAdmin() + // convertGroupFieldsToGroupDocuments() + updateTotalContractsAndMembers() +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index f8b7622e..a0396d2e 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -282,8 +282,8 @@ function ContractSearchControls(props: { : DEFAULT_CATEGORY_GROUPS.map((g) => g.slug) const memberPillGroups = sortBy( - memberGroups.filter((group) => group.contractIds.length > 0), - (group) => group.contractIds.length + memberGroups.filter((group) => group.totalContracts > 0), + (group) => group.totalContracts ).reverse() const pillGroups: { name: string; slug: string }[] = diff --git a/web/components/groups/contract-groups-list.tsx b/web/components/groups/contract-groups-list.tsx index 7bbcfa7c..d39a35d3 100644 --- a/web/components/groups/contract-groups-list.tsx +++ b/web/components/groups/contract-groups-list.tsx @@ -7,13 +7,13 @@ import { Button } from 'web/components/button' import { GroupSelector } from 'web/components/groups/group-selector' import { addContractToGroup, - canModifyGroupContracts, removeContractFromGroup, } from 'web/lib/firebase/groups' import { User } from 'common/user' import { Contract } from 'common/contract' import { SiteLink } from 'web/components/site-link' -import { useGroupsWithContract } from 'web/hooks/use-group' +import { useGroupsWithContract, useMemberGroupIds } from 'web/hooks/use-group' +import { Group } from 'common/group' export function ContractGroupsList(props: { contract: Contract @@ -22,6 +22,15 @@ export function ContractGroupsList(props: { const { user, contract } = props const { groupLinks } = contract const groups = useGroupsWithContract(contract) + const memberGroupIds = useMemberGroupIds(user) + + const canModifyGroupContracts = (group: Group, userId: string) => { + return ( + group.creatorId === userId || + group.anyoneCanJoin || + memberGroupIds?.includes(group.id) + ) + } return ( <Col className={'gap-2'}> <span className={'text-xl text-indigo-700'}> @@ -61,7 +70,7 @@ export function ContractGroupsList(props: { <Button color={'gray-white'} size={'xs'} - onClick={() => removeContractFromGroup(group, contract, user.id)} + onClick={() => removeContractFromGroup(group, contract)} > <XIcon className="h-4 w-4 text-gray-500" /> </Button> diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 834af5ec..6349ad3f 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -3,17 +3,16 @@ import clsx from 'clsx' import { PencilIcon } from '@heroicons/react/outline' import { Group } from 'common/group' -import { deleteGroup, updateGroup } from 'web/lib/firebase/groups' +import { deleteGroup, joinGroup } from 'web/lib/firebase/groups' import { Spacer } from '../layout/spacer' import { useRouter } from 'next/router' import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' -import { uniq } from 'lodash' +import { useMemberIds } from 'web/hooks/use-group' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props - const { memberIds } = group const router = useRouter() const [name, setName] = useState(group.name) @@ -21,7 +20,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { const [open, setOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [addMemberUsers, setAddMemberUsers] = useState<User[]>([]) - + const memberIds = useMemberIds(group.id) function updateOpen(newOpen: boolean) { setAddMemberUsers([]) setOpen(newOpen) @@ -33,11 +32,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { const onSubmit = async () => { setIsSubmitting(true) - await updateGroup(group, { - name, - about, - memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]), - }) + await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id))) setIsSubmitting(false) updateOpen(false) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx deleted file mode 100644 index 9a60c9c7..00000000 --- a/web/components/groups/group-chat.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import { Row } from 'web/components/layout/row' -import { Col } from 'web/components/layout/col' -import { PrivateUser, User } from 'common/user' -import React, { useEffect, memo, useState, useMemo } from 'react' -import { Avatar } from 'web/components/avatar' -import { Group } from 'common/group' -import { Comment, GroupComment } from 'common/comment' -import { createCommentOnGroup } from 'web/lib/firebase/comments' -import { CommentInputTextArea } from 'web/components/feed/feed-comments' -import { track } from 'web/lib/service/analytics' -import { firebaseLogin } from 'web/lib/firebase/users' -import { useRouter } from 'next/router' -import clsx from 'clsx' -import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' -import { Tipper } from 'web/components/tipper' -import { sum } from 'lodash' -import { formatMoney } from 'common/util/format' -import { useWindowSize } from 'web/hooks/use-window-size' -import { Content, useTextEditor } from 'web/components/editor' -import { useUnseenNotifications } from 'web/hooks/use-notifications' -import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' -import { setNotificationsAsSeen } from 'web/pages/notifications' -import { usePrivateUser } from 'web/hooks/use-user' -import { UserLink } from 'web/components/user-link' - -export function GroupChat(props: { - messages: GroupComment[] - user: User | null | undefined - group: Group - tips: CommentTipMap -}) { - const { messages, user, group, tips } = props - - const privateUser = usePrivateUser() - - const { editor, upload } = useTextEditor({ - simple: true, - placeholder: 'Send a message', - }) - const [isSubmitting, setIsSubmitting] = useState(false) - const [scrollToBottomRef, setScrollToBottomRef] = - useState<HTMLDivElement | null>(null) - const [scrollToMessageId, setScrollToMessageId] = useState('') - const [scrollToMessageRef, setScrollToMessageRef] = - useState<HTMLDivElement | null>(null) - const [replyToUser, setReplyToUser] = useState<any>() - - const router = useRouter() - const isMember = user && group.memberIds.includes(user?.id) - - const { width, height } = useWindowSize() - const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) - // Subtract bottom bar when it's showing (less than lg screen) - const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 - const remainingHeight = - (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight - - // array of groups, where each group is an array of messages that are displayed as one - const groupedMessages = useMemo(() => { - // Group messages with createdTime within 2 minutes of each other. - const tempGrouped: GroupComment[][] = [] - for (let i = 0; i < messages.length; i++) { - const message = messages[i] - if (i === 0) tempGrouped.push([message]) - else { - const prevMessage = messages[i - 1] - const diff = message.createdTime - prevMessage.createdTime - const creatorsMatch = message.userId === prevMessage.userId - if (diff < 2 * 60 * 1000 && creatorsMatch) { - tempGrouped.at(-1)?.push(message) - } else { - tempGrouped.push([message]) - } - } - } - - return tempGrouped - }, [messages]) - - useEffect(() => { - scrollToMessageRef?.scrollIntoView() - }, [scrollToMessageRef]) - - useEffect(() => { - if (scrollToBottomRef) - scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 }) - // Must also listen to groupedMessages as they update the height of the messaging window - }, [scrollToBottomRef, groupedMessages]) - - useEffect(() => { - const elementInUrl = router.asPath.split('#')[1] - if (messages.map((m) => m.id).includes(elementInUrl)) { - setScrollToMessageId(elementInUrl) - } - }, [messages, router.asPath]) - - useEffect(() => { - // is mobile? - if (width && width > 720) focusInput() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [width]) - - function onReplyClick(comment: Comment) { - setReplyToUser({ id: comment.userId, username: comment.userUsername }) - } - - async function submitMessage() { - if (!user) { - track('sign in to comment') - return await firebaseLogin() - } - if (!editor || editor.isEmpty || isSubmitting) return - setIsSubmitting(true) - await createCommentOnGroup(group.id, editor.getJSON(), user) - editor.commands.clearContent() - setIsSubmitting(false) - setReplyToUser(undefined) - focusInput() - } - function focusInput() { - editor?.commands.focus() - } - - return ( - <Col ref={setContainerRef} style={{ height: remainingHeight }}> - <Col - className={ - 'w-full flex-1 space-y-2 overflow-x-hidden overflow-y-scroll pt-2' - } - ref={setScrollToBottomRef} - > - {groupedMessages.map((messages) => ( - <GroupMessage - user={user} - key={`group ${messages[0].id}`} - comments={messages} - group={group} - onReplyClick={onReplyClick} - highlight={messages[0].id === scrollToMessageId} - setRef={ - scrollToMessageId === messages[0].id - ? setScrollToMessageRef - : undefined - } - tips={tips[messages[0].id] ?? {}} - /> - ))} - {messages.length === 0 && ( - <div className="p-2 text-gray-500"> - No messages yet. Why not{isMember ? ` ` : ' join and '} - <button - className={'cursor-pointer font-bold text-gray-700'} - onClick={focusInput} - > - add one? - </button> - </div> - )} - </Col> - {user && group.memberIds.includes(user.id) && ( - <div className="flex w-full justify-start gap-2 p-2"> - <div className="mt-1"> - <Avatar - username={user?.username} - avatarUrl={user?.avatarUrl} - size={'sm'} - /> - </div> - <div className={'flex-1'}> - <CommentInputTextArea - editor={editor} - upload={upload} - user={user} - replyToUser={replyToUser} - submitComment={submitMessage} - isSubmitting={isSubmitting} - submitOnEnter - /> - </div> - </div> - )} - - {privateUser && ( - <GroupChatNotificationsIcon - group={group} - privateUser={privateUser} - shouldSetAsSeen={true} - hidden={true} - /> - )} - </Col> - ) -} - -export function GroupChatInBubble(props: { - messages: GroupComment[] - user: User | null | undefined - privateUser: PrivateUser | null | undefined - group: Group - tips: CommentTipMap -}) { - const { messages, user, group, tips, privateUser } = props - const [shouldShowChat, setShouldShowChat] = useState(false) - const router = useRouter() - - useEffect(() => { - const groupsWithChatEmphasis = [ - 'welcome', - 'bugs', - 'manifold-features-25bad7c7792e', - 'updates', - ] - if ( - router.asPath.includes('/chat') || - groupsWithChatEmphasis.includes( - router.asPath.split('/group/')[1].split('/')[0] - ) - ) { - setShouldShowChat(true) - } - // Leave chat open between groups if user is using chat? - else { - setShouldShowChat(false) - } - }, [router.asPath]) - - return ( - <Col - className={clsx( - 'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4', - shouldShowChat ? 'p-2m z-10 h-screen bg-white' : '' - )} - > - {shouldShowChat && ( - <GroupChat messages={messages} user={user} group={group} tips={tips} /> - )} - <button - type="button" - className={clsx( - 'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' + - ' border-transparent p-3 text-white shadow-sm lg:p-4' + - ' focus:outline-none focus:ring-2 focus:ring-offset-2 ' + - ' bottom-[70px] ', - shouldShowChat - ? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto ' - : ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500' - )} - onClick={() => { - // router.push('/chat') - setShouldShowChat(!shouldShowChat) - track('mobile group chat button') - }} - > - {!shouldShowChat ? ( - <UsersIcon className="h-10 w-10" aria-hidden="true" /> - ) : ( - <ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} /> - )} - {privateUser && ( - <GroupChatNotificationsIcon - group={group} - privateUser={privateUser} - shouldSetAsSeen={shouldShowChat} - hidden={false} - /> - )} - </button> - </Col> - ) -} - -function GroupChatNotificationsIcon(props: { - group: Group - privateUser: PrivateUser - shouldSetAsSeen: boolean - hidden: boolean -}) { - const { privateUser, group, shouldSetAsSeen, hidden } = props - const notificationsForThisGroup = useUnseenNotifications( - privateUser - // Disabled tracking by customHref for now. - // { - // customHref: `/group/${group.slug}`, - // } - ) - - useEffect(() => { - if (!notificationsForThisGroup) return - - notificationsForThisGroup.forEach((notification) => { - if ( - (shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) || - // old style chat notif that simply ended with the group slug - notification.isSeenOnHref?.endsWith(group.slug) - ) { - setNotificationsAsSeen([notification]) - } - }) - }, [group.slug, notificationsForThisGroup, shouldSetAsSeen]) - - return ( - <div - className={ - !hidden && - notificationsForThisGroup && - notificationsForThisGroup.length > 0 && - !shouldSetAsSeen - ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' - : 'hidden' - } - ></div> - ) -} - -const GroupMessage = memo(function GroupMessage_(props: { - user: User | null | undefined - comments: GroupComment[] - group: Group - onReplyClick?: (comment: Comment) => void - setRef?: (ref: HTMLDivElement) => void - highlight?: boolean - tips: CommentTips -}) { - const { comments, onReplyClick, group, setRef, highlight, user, tips } = props - const first = comments[0] - const { id, userUsername, userName, userAvatarUrl, createdTime } = first - - const isCreatorsComment = user && first.userId === user.id - return ( - <Col - ref={setRef} - className={clsx( - isCreatorsComment ? 'mr-2 self-end' : '', - 'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]', - highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : '' - )} - > - <Row className={'items-center'}> - {!isCreatorsComment && ( - <Col> - <Avatar - className={'mx-2 ml-2.5'} - size={'xs'} - username={userUsername} - avatarUrl={userAvatarUrl} - /> - </Col> - )} - {!isCreatorsComment ? ( - <UserLink username={userUsername} name={userName} /> - ) : ( - <span className={'ml-2.5'}>{'You'}</span> - )} - <CopyLinkDateTimeComponent - prefix={'group'} - slug={group.slug} - createdTime={createdTime} - elementId={id} - /> - </Row> - <div className="mt-2 text-base text-black"> - {comments.map((comment) => ( - <Content - key={comment.id} - content={comment.content || comment.text} - smallImage - /> - ))} - </div> - <Row> - {!isCreatorsComment && onReplyClick && ( - <button - className={ - 'self-start py-1 text-xs font-bold text-gray-500 hover:underline' - } - onClick={() => onReplyClick(first)} - > - Reply - </button> - )} - {isCreatorsComment && sum(Object.values(tips)) > 0 && ( - <span className={'text-primary'}> - {formatMoney(sum(Object.values(tips)))} - </span> - )} - {!isCreatorsComment && <Tipper comment={first} tips={tips} />} - </Row> - </Col> - ) -}) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index bb94c9ed..810a70bc 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' import { User } from 'common/user' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useUser } from 'web/hooks/use-user' import { withTracking } from 'web/lib/service/analytics' import { Row } from 'web/components/layout/row' -import { useMemberGroups } from 'web/hooks/use-group' +import { useMemberGroups, useMemberIds } from 'web/hooks/use-group' import { TextButton } from 'web/components/text-button' import { Group } from 'common/group' import { Modal } from 'web/components/layout/modal' @@ -17,9 +17,7 @@ import toast from 'react-hot-toast' export function GroupsButton(props: { user: User }) { const { user } = props const [isOpen, setIsOpen] = useState(false) - const groups = useMemberGroups(user.id, undefined, { - by: 'mostRecentChatActivityTime', - }) + const groups = useMemberGroups(user.id) return ( <> @@ -91,34 +89,12 @@ export function JoinOrLeaveGroupButton(props: { }) { const { group, small, className } = props const currentUser = useUser() - const [isMember, setIsMember] = useState<boolean>(false) - useEffect(() => { - if (currentUser && group.memberIds.includes(currentUser.id)) { - setIsMember(group.memberIds.includes(currentUser.id)) - } - }, [currentUser, group]) - - const onJoinGroup = () => { - if (!currentUser) return - setIsMember(true) - joinGroup(group, currentUser.id).catch(() => { - setIsMember(false) - toast.error('Failed to join group') - }) - } - const onLeaveGroup = () => { - if (!currentUser) return - setIsMember(false) - leaveGroup(group, currentUser.id).catch(() => { - setIsMember(true) - toast.error('Failed to leave group') - }) - } - + const memberIds = useMemberIds(group.id) + const isMember = memberIds?.includes(currentUser?.id ?? '') ?? false const smallStyle = 'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500' - if (!currentUser || isMember === undefined) { + if (!currentUser) { if (!group.anyoneCanJoin) return <div className={clsx(className, 'text-gray-500')}>Closed</div> return ( @@ -130,6 +106,16 @@ export function JoinOrLeaveGroupButton(props: { </button> ) } + const onJoinGroup = () => { + joinGroup(group, currentUser.id).catch(() => { + toast.error('Failed to join group') + }) + } + const onLeaveGroup = () => { + leaveGroup(group, currentUser.id).catch(() => { + toast.error('Failed to leave group') + }) + } if (isMember) { return ( diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index aeeaf2ab..001c29c3 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -2,16 +2,21 @@ import { useEffect, useState } from 'react' import { Group } from 'common/group' import { User } from 'common/user' import { + GroupMemberDoc, + groupMembers, listenForGroup, + listenForGroupContractDocs, listenForGroups, + listenForMemberGroupIds, listenForMemberGroups, listenForOpenGroups, listGroups, } from 'web/lib/firebase/groups' -import { getUser, getUsers } from 'web/lib/firebase/users' +import { getUser } from 'web/lib/firebase/users' import { filterDefined } from 'common/util/array' import { Contract } from 'common/contract' import { uniq } from 'lodash' +import { listenForValues } from 'web/lib/firebase/utils' export const useGroup = (groupId: string | undefined) => { const [group, setGroup] = useState<Group | null | undefined>() @@ -43,29 +48,12 @@ export const useOpenGroups = () => { return groups } -export const useMemberGroups = ( - userId: string | null | undefined, - options?: { withChatEnabled: boolean }, - sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } -) => { +export const useMemberGroups = (userId: string | null | undefined) => { const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() useEffect(() => { if (userId) - return listenForMemberGroups( - userId, - (groups) => { - if (options?.withChatEnabled) - return setMemberGroups( - filterDefined( - groups.filter((group) => group.chatDisabled !== true) - ) - ) - return setMemberGroups(groups) - }, - sort - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options?.withChatEnabled, sort?.by, userId]) + return listenForMemberGroups(userId, (groups) => setMemberGroups(groups)) + }, [userId]) return memberGroups } @@ -77,16 +65,8 @@ export const useMemberGroupIds = (user: User | null | undefined) => { useEffect(() => { if (user) { - const key = `member-groups-${user.id}` - const memberGroupJson = localStorage.getItem(key) - if (memberGroupJson) { - setMemberGroupIds(JSON.parse(memberGroupJson)) - } - - return listenForMemberGroups(user.id, (Groups) => { - const groupIds = Groups.map((group) => group.id) + return listenForMemberGroupIds(user.id, (groupIds) => { setMemberGroupIds(groupIds) - localStorage.setItem(key, JSON.stringify(groupIds)) }) } }, [user]) @@ -94,26 +74,29 @@ export const useMemberGroupIds = (user: User | null | undefined) => { return memberGroupIds } -export function useMembers(group: Group, max?: number) { +export function useMembers(groupId: string | undefined) { const [members, setMembers] = useState<User[]>([]) useEffect(() => { - const { memberIds } = group - if (memberIds.length > 0) { - listMembers(group, max).then((members) => setMembers(members)) - } - }, [group, max]) + if (groupId) + listenForValues<GroupMemberDoc>(groupMembers(groupId), (memDocs) => { + const memberIds = memDocs.map((memDoc) => memDoc.userId) + Promise.all(memberIds.map((id) => getUser(id))).then((users) => { + setMembers(users) + }) + }) + }, [groupId]) return members } -export async function listMembers(group: Group, max?: number) { - const { memberIds } = group - const numToRetrieve = max ?? memberIds.length - if (memberIds.length === 0) return [] - if (numToRetrieve > 100) - return (await getUsers()).filter((user) => - group.memberIds.includes(user.id) - ) - return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser)) +export function useMemberIds(groupId: string | null) { + const [memberIds, setMemberIds] = useState<string[]>([]) + useEffect(() => { + if (groupId) + return listenForValues<GroupMemberDoc>(groupMembers(groupId), (docs) => { + setMemberIds(docs.map((doc) => doc.userId)) + }) + }, [groupId]) + return memberIds } export const useGroupsWithContract = (contract: Contract) => { @@ -128,3 +111,16 @@ export const useGroupsWithContract = (contract: Contract) => { return groups } + +export function useGroupContractIds(groupId: string) { + const [contractIds, setContractIds] = useState<string[]>([]) + + useEffect(() => { + if (groupId) + return listenForGroupContractDocs(groupId, (docs) => + setContractIds(docs.map((doc) => doc.contractId)) + ) + }, [groupId]) + + return contractIds +} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 4d22e0ee..ef67ff14 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -1,13 +1,17 @@ import { + collection, + collectionGroup, deleteDoc, deleteField, doc, getDocs, + onSnapshot, query, + setDoc, updateDoc, where, } from 'firebase/firestore' -import { sortBy, uniq } from 'lodash' +import { uniq } from 'lodash' import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group' import { coll, @@ -18,8 +22,15 @@ import { } from './utils' import { Contract } from 'common/contract' import { 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' export const groups = coll<Group>('groups') +export const groupMembers = (groupId: string) => + collection(groups, groupId, 'groupMembers') +export const groupContracts = (groupId: string) => + collection(groups, groupId, 'groupContracts') export function groupPath( groupSlug: string, @@ -33,6 +44,9 @@ export function groupPath( return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } +export type GroupContractDoc = { contractId: string; createdTime: number } +export type GroupMemberDoc = { userId: string; createdTime: number } + export function updateGroup(group: Group, updates: Partial<Group>) { return updateDoc(doc(groups, group.id), updates) } @@ -57,6 +71,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) { return listenForValues(groups, setGroups) } +export function listenForGroupContractDocs( + groupId: string, + setContractDocs: (docs: GroupContractDoc[]) => void +) { + return listenForValues(groupContracts(groupId), setContractDocs) +} + export function listenForOpenGroups(setGroups: (groups: Group[]) => void) { return listenForValues( query(groups, where('anyoneCanJoin', '==', true)), @@ -68,6 +89,12 @@ 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 @@ -81,33 +108,32 @@ export function listenForGroup( return listenForValue(doc(groups, groupId), setGroup) } -export function listenForMemberGroups( +export function listenForMemberGroupIds( userId: string, - setGroups: (groups: Group[]) => void, - sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' } + setGroupIds: (groupIds: string[]) => void ) { - const q = query(groups, where('memberIds', 'array-contains', userId)) - const sorter = (group: Group) => { - if (sort?.by === 'mostRecentChatActivityTime') { - return group.mostRecentChatActivityTime ?? group.createdTime - } - if (sort?.by === 'mostRecentContractAddedTime') { - return group.mostRecentContractAddedTime ?? group.createdTime - } - return group.mostRecentActivityTime - } - return listenForValues<Group>(q, (groups) => { - const sorted = sortBy(groups, [(group) => -sorter(group)]) - setGroups(sorted) + const q = query( + collectionGroup(db, 'groupMembers'), + where('userId', '==', userId) + ) + return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => { + if (snapshot.metadata.fromCache) return + + const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id) + + setGroupIds(filterDefined(values)) }) } -export async function listenForGroupsWithContractId( - contractId: string, +export function listenForMemberGroups( + userId: string, setGroups: (groups: Group[]) => void ) { - const q = query(groups, where('contractIds', 'array-contains', contractId)) - return listenForValues<Group>(q, setGroups) + return listenForMemberGroupIds(userId, (groupIds) => { + return Promise.all(groupIds.map(getGroup)).then((groups) => { + setGroups(filterDefined(groups)) + }) + }) } export async function addUserToGroupViaId(groupId: string, userId: string) { @@ -121,19 +147,18 @@ export async function addUserToGroupViaId(groupId: string, userId: string) { } export async function joinGroup(group: Group, userId: string): Promise<void> { - const { memberIds } = group - if (memberIds.includes(userId)) return // already a member - - const newMemberIds = [...memberIds, userId] - return await updateGroup(group, { memberIds: uniq(newMemberIds) }) + // create a new member document in grouoMembers collection + const memberDoc = doc(groupMembers(group.id), userId) + return await setDoc(memberDoc, { + userId, + createdTime: Date.now(), + }) } export async function leaveGroup(group: Group, userId: string): Promise<void> { - const { memberIds } = group - if (!memberIds.includes(userId)) return // not a member - - const newMemberIds = memberIds.filter((id) => id !== userId) - return await updateGroup(group, { memberIds: uniq(newMemberIds) }) + // delete the member document in groupMembers collection + const memberDoc = doc(groupMembers(group.id), userId) + return await deleteDoc(memberDoc) } export async function addContractToGroup( @@ -141,7 +166,6 @@ export async function addContractToGroup( contract: Contract, userId: string ) { - if (!canModifyGroupContracts(group, userId)) return const newGroupLinks = [ ...(contract.groupLinks ?? []), { @@ -158,25 +182,18 @@ export async function addContractToGroup( groupLinks: newGroupLinks, }) - if (!group.contractIds.includes(contract.id)) { - return await updateGroup(group, { - contractIds: uniq([...group.contractIds, contract.id]), - }) - .then(() => group) - .catch((err) => { - console.error('error adding contract to group', err) - return err - }) - } + // create new contract document in groupContracts collection + const contractDoc = doc(groupContracts(group.id), contract.id) + await setDoc(contractDoc, { + contractId: contract.id, + createdTime: Date.now(), + }) } export async function removeContractFromGroup( group: Group, - contract: Contract, - userId: string + contract: Contract ) { - if (!canModifyGroupContracts(group, userId)) return - if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) { const newGroupLinks = contract.groupLinks?.filter( (link) => link.slug !== group.slug @@ -188,25 +205,9 @@ export async function removeContractFromGroup( }) } - if (group.contractIds.includes(contract.id)) { - const newContractIds = group.contractIds.filter((id) => id !== contract.id) - return await updateGroup(group, { - contractIds: uniq(newContractIds), - }) - .then(() => group) - .catch((err) => { - console.error('error removing contract from group', err) - return err - }) - } -} - -export function canModifyGroupContracts(group: Group, userId: string) { - return ( - group.creatorId === userId || - group.memberIds.includes(userId) || - group.anyoneCanJoin - ) + // delete the contract document in groupContracts collection + const contractDoc = doc(groupContracts(group.id), contract.id) + await deleteDoc(contractDoc) } export function getGroupLinkToDisplay(contract: Contract) { @@ -222,3 +223,8 @@ export function getGroupLinkToDisplay(contract: Contract) { : sortedGroupLinks?.[0] ?? null return groupToDisplay } + +export async function listMembers(group: Group) { + const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) + return await Promise.all(members.map((m) => m.userId).map(getUser)) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 23a88ec0..b5892ccf 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 { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups' +import { getGroup } 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' @@ -139,7 +139,7 @@ export function NewContract(props: { useEffect(() => { if (groupId) getGroup(groupId).then((group) => { - if (group && canModifyGroupContracts(group, creator.id)) { + if (group) { setSelectedGroup(group) setShowGroupSelector(false) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 9012b585..4626aa77 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -14,13 +14,14 @@ import { getGroupBySlug, groupPath, joinGroup, + listMembers, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' +import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' @@ -157,7 +158,6 @@ export default function GroupPage(props: { const { contractsCount, creator, - members, traderScores, topTraders, creatorScores, @@ -174,6 +174,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() + const members = useMembers(group?.id) ?? props.members useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -183,9 +184,8 @@ export default function GroupPage(props: { if (group === null || !groupSubpages.includes(page) || slugs[2]) { return <Custom404 /> } - const { memberIds } = group const isCreator = user && group && user.id === group.creatorId - const isMember = user && memberIds.includes(user.id) + const isMember = user && members.map((m) => m.id).includes(user.id) const leaderboard = ( <Col> @@ -347,8 +347,7 @@ function GroupOverview(props: { {isCreator ? ( <EditGroupButton className={'ml-1'} group={group} /> ) : ( - user && - group.memberIds.includes(user?.id) && ( + user && ( <Row> <JoinOrLeaveGroupButton group={group} /> </Row> @@ -425,7 +424,7 @@ function GroupMemberSearch(props: { members: User[]; group: Group }) { let { members } = props // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group) + const listenToMembers = useMembers(group.id) if (listenToMembers) { members = listenToMembers } @@ -547,6 +546,7 @@ function AddContractButton(props: { group: Group; user: User }) { const [open, setOpen] = useState(false) const [contracts, setContracts] = useState<Contract[]>([]) const [loading, setLoading] = useState(false) + const groupContractIds = useGroupContractIds(group.id) async function addContractToCurrentGroup(contract: Contract) { if (contracts.map((c) => c.id).includes(contract.id)) { @@ -634,7 +634,9 @@ function AddContractButton(props: { group: Group; user: User }) { hideOrderSelector={true} onContractClick={addContractToCurrentGroup} cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} - additionalFilter={{ excludeContractIds: group.contractIds }} + additionalFilter={{ + excludeContractIds: groupContractIds, + }} highlightOptions={{ contractIds: contracts.map((c) => c.id), highlightClassName: '!bg-indigo-100 border-indigo-100 border-2', @@ -653,7 +655,7 @@ function JoinGroupButton(props: { }) { const { group, user } = props function addUserToGroup() { - if (user && !group.memberIds.includes(user.id)) { + if (user) { toast.promise(joinGroup(group, user.id), { loading: 'Joining group...', success: 'Joined group!', diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 9ef2d8ff..dfb19c69 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -7,7 +7,12 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { useGroups, useMemberGroupIds, useMembers } from 'web/hooks/use-group' +import { + useGroupContractIds, + useGroups, + useMemberGroupIds, + useMemberIds, +} from 'web/hooks/use-group' import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' @@ -18,7 +23,6 @@ import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' -import { UserLink } from 'web/components/user-link' export async function getStaticProps() { let groups = await listAllGroups().catch((_) => []) @@ -73,10 +77,7 @@ export default function Groups(props: { // List groups with the highest question count, then highest member count // TODO use find-active-contracts to sort by? - const matches = sortBy(groups, [ - (group) => -1 * group.contractIds.length, - (group) => -1 * group.memberIds.length, - ]).filter((g) => + const matches = sortBy(groups, []).filter((g) => searchInAny( query, g.name, @@ -87,10 +88,7 @@ export default function Groups(props: { const matchesOrderedByRecentActivity = sortBy(groups, [ (group) => - -1 * - (group.mostRecentChatActivityTime ?? - group.mostRecentContractAddedTime ?? - group.mostRecentActivityTime), + -1 * (group.mostRecentContractAddedTime ?? group.mostRecentActivityTime), ]).filter((g) => searchInAny( query, @@ -124,37 +122,6 @@ export default function Groups(props: { <Tabs currentPageForAnalytics={'groups'} tabs={[ - ...(user && memberGroupIds.length > 0 - ? [ - { - title: 'My Groups', - content: ( - <Col> - <input - type="text" - 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 - .filter((match) => - memberGroupIds.includes(match.id) - ) - .map((group) => ( - <GroupCard - key={group.id} - group={group} - creator={creatorsDict[group.creatorId]} - /> - ))} - </div> - </Col> - ), - }, - ] - : []), { title: 'All', content: ( @@ -178,6 +145,31 @@ export default function Groups(props: { </Col> ), }, + { + title: 'My Groups', + content: ( + <Col> + <input + type="text" + 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 + .filter((match) => memberGroupIds.includes(match.id)) + .map((group) => ( + <GroupCard + key={group.id} + group={group} + creator={creatorsDict[group.creatorId]} + /> + ))} + </div> + </Col> + ), + }, ]} /> </Col> @@ -188,6 +180,7 @@ export default function Groups(props: { export function GroupCard(props: { group: Group; creator: User | undefined }) { const { group, creator } = props + const groupContracts = useGroupContractIds(group.id) return ( <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> <Link href={groupPath(group.slug)}> @@ -205,7 +198,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { <Row className="items-center justify-between gap-2"> <span className="text-xl">{group.name}</span> </Row> - <Row>{group.contractIds.length} questions</Row> + <Row>{groupContracts.length} questions</Row> <Row className="text-sm text-gray-500"> <GroupMembersList group={group} /> </Row> @@ -221,23 +214,11 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { function GroupMembersList(props: { group: Group }) { const { group } = props - const maxMembersToShow = 3 - const members = useMembers(group, maxMembersToShow).filter( - (m) => m.id !== group.creatorId - ) - if (group.memberIds.length === 1) return <div /> + const memberIds = useMemberIds(group.id) + if (memberIds.length === 1) return <div /> return ( <div className="text-neutral flex flex-wrap gap-1"> - <span className={'text-gray-500'}>Other members</span> - {members.slice(0, maxMembersToShow).map((member, i) => ( - <div key={member.id} className={'flex-shrink'}> - <UserLink name={member.name} username={member.username} /> - {members.length > 1 && i !== members.length - 1 && <span>,</span>} - </div> - ))} - {group.memberIds.length > maxMembersToShow && ( - <span> & {group.memberIds.length - maxMembersToShow} more</span> - )} + <span>{memberIds.length} members</span> </div> ) } diff --git a/web/pages/tournaments/index.tsx b/web/pages/tournaments/index.tsx index b1f84473..9bfdfb89 100644 --- a/web/pages/tournaments/index.tsx +++ b/web/pages/tournaments/index.tsx @@ -122,7 +122,7 @@ export async function getStaticProps() { const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]])) const groupMap = keyBy(groups, 'id') - const numPeople = mapValues(groupMap, (g) => g?.memberIds.length) + const numPeople = mapValues(groupMap, (g) => g?.totalMembers) const slugs = mapValues(groupMap, 'slug') return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 } From 9577955d2d13fb01b5029d443a2f354342aa8904 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 18:08:53 -0600 Subject: [PATCH 225/279] Remove null check --- web/pages/groups.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index dfb19c69..76c859c3 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -25,11 +25,11 @@ import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' export async function getStaticProps() { - let groups = await listAllGroups().catch((_) => []) + const groups = await listAllGroups().catch((_) => []) // mqp: temporary fix to make dev deploy while Ian works on migrating groups away // from the document array member and contracts representation - groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) + // groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) const creators = await Promise.all( groups.map((group) => getUser(group.creatorId)) From 57b74a5d09eea66df25b78383c44feb83a80ae87 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 18:12:55 -0600 Subject: [PATCH 226/279] Use cached values --- web/pages/groups.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 76c859c3..92a813aa 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -7,12 +7,7 @@ import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' -import { - useGroupContractIds, - useGroups, - useMemberGroupIds, - useMemberIds, -} from 'web/hooks/use-group' +import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' @@ -180,7 +175,7 @@ export default function Groups(props: { export function GroupCard(props: { group: Group; creator: User | undefined }) { const { group, creator } = props - const groupContracts = useGroupContractIds(group.id) + const { totalContracts } = group return ( <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> <Link href={groupPath(group.slug)}> @@ -198,7 +193,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { <Row className="items-center justify-between gap-2"> <span className="text-xl">{group.name}</span> </Row> - <Row>{groupContracts.length} questions</Row> + <Row>{totalContracts} questions</Row> <Row className="text-sm text-gray-500"> <GroupMembersList group={group} /> </Row> @@ -214,11 +209,11 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { function GroupMembersList(props: { group: Group }) { const { group } = props - const memberIds = useMemberIds(group.id) - if (memberIds.length === 1) return <div /> + const { totalMembers } = group + if (totalMembers === 1) return <div /> return ( <div className="text-neutral flex flex-wrap gap-1"> - <span>{memberIds.length} members</span> + <span>{totalMembers} members</span> </div> ) } From c74d972caf305da97c0c54f31913ce3ca2c2e564 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 19:36:49 -0600 Subject: [PATCH 227/279] Pass user and members via props --- web/components/groups/groups-button.tsx | 21 +++++++++++++-------- web/pages/group/[...slugs]/index.tsx | 7 ++++++- web/pages/groups.tsx | 20 +++++++++++++++++--- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index 810a70bc..f60ed0af 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -72,29 +72,34 @@ function GroupsList(props: { groups: Group[] }) { function GroupItem(props: { group: Group; className?: string }) { const { group, className } = props + const user = useUser() + const memberIds = useMemberIds(group.id) return ( <Row className={clsx('items-center justify-between gap-2 p-2', className)}> <Row className="line-clamp-1 items-center gap-2"> <GroupLinkItem group={group} /> </Row> - <JoinOrLeaveGroupButton group={group} /> + <JoinOrLeaveGroupButton + group={group} + user={user} + isMember={user ? memberIds?.includes(user.id) : false} + /> </Row> ) } export function JoinOrLeaveGroupButton(props: { group: Group + isMember: boolean + user: User | undefined | null small?: boolean className?: string }) { - const { group, small, className } = props - const currentUser = useUser() - const memberIds = useMemberIds(group.id) - const isMember = memberIds?.includes(currentUser?.id ?? '') ?? false + const { group, small, className, isMember, user } = props const smallStyle = 'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500' - if (!currentUser) { + if (!user) { if (!group.anyoneCanJoin) return <div className={clsx(className, 'text-gray-500')}>Closed</div> return ( @@ -107,12 +112,12 @@ export function JoinOrLeaveGroupButton(props: { ) } const onJoinGroup = () => { - joinGroup(group, currentUser.id).catch(() => { + joinGroup(group, user.id).catch(() => { toast.error('Failed to join group') }) } const onLeaveGroup = () => { - leaveGroup(group, currentUser.id).catch(() => { + leaveGroup(group, user.id).catch(() => { toast.error('Failed to leave group') }) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4626aa77..b4046c4c 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -331,6 +331,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` + const isMember = user ? members.map((m) => m.id).includes(user.id) : false return ( <> @@ -349,7 +350,11 @@ function GroupOverview(props: { ) : ( user && ( <Row> - <JoinOrLeaveGroupButton group={group} /> + <JoinOrLeaveGroupButton + group={group} + user={user} + isMember={isMember} + /> </Row> ) )} diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 92a813aa..0afdaba5 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -134,6 +134,8 @@ export default function Groups(props: { key={group.id} group={group} creator={creatorsDict[group.creatorId]} + user={user} + isMember={memberGroupIds.includes(group.id)} /> ))} </div> @@ -159,6 +161,8 @@ export default function Groups(props: { key={group.id} group={group} creator={creatorsDict[group.creatorId]} + user={user} + isMember={memberGroupIds.includes(group.id)} /> ))} </div> @@ -173,8 +177,13 @@ export default function Groups(props: { ) } -export function GroupCard(props: { group: Group; creator: User | undefined }) { - const { group, creator } = props +export function GroupCard(props: { + group: Group + creator: User | undefined + user: User | undefined | null + isMember: boolean +}) { + const { group, creator, user, isMember } = props const { totalContracts } = group return ( <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> @@ -201,7 +210,12 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { <div className="text-sm text-gray-500">{group.about}</div> </Row> <Col className={'mt-2 h-full items-start justify-end'}> - <JoinOrLeaveGroupButton group={group} className={'z-10 w-24'} /> + <JoinOrLeaveGroupButton + group={group} + className={'z-10 w-24'} + user={user} + isMember={isMember} + /> </Col> </Col> ) From 25a0276bf73f17873e2ee7fd7c17a2bd2f5f7772 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 2 Sep 2022 19:52:38 -0600 Subject: [PATCH 228/279] Auth user server-side on groups page --- web/pages/groups.tsx | 89 ++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 0afdaba5..2bac5aed 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -8,7 +8,6 @@ import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' -import { useUser } from 'web/hooks/use-user' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' import { getUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' @@ -18,14 +17,15 @@ import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' +import { GetServerSideProps } from 'next' +import { authenticateOnServer } from 'web/lib/firebase/server-auth' +import { useUser } from 'web/hooks/use-user' -export async function getStaticProps() { +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const creds = await authenticateOnServer(ctx) + const serverUser = creds ? await getUser(creds.uid) : null const groups = await listAllGroups().catch((_) => []) - // mqp: temporary fix to make dev deploy while Ian works on migrating groups away - // from the document array member and contracts representation - // groups = groups.filter((g) => g.contractIds != null && g.memberIds != null) - const creators = await Promise.all( groups.map((group) => getUser(group.creatorId)) ) @@ -33,25 +33,20 @@ export async function getStaticProps() { creators.map((creator) => [creator.id, creator]) ) - return { - props: { - groups: groups, - creatorsDict, - }, - - revalidate: 60, // regenerate after a minute - } + return { props: { serverUser, groups: groups, creatorsDict } } } export default function Groups(props: { + serverUser: User | null groups: Group[] creatorsDict: { [k: string]: User } }) { + //TODO: do we really need the creatorsDict? const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict) - + const { serverUser } = props || {} const groups = useGroups() ?? props.groups - const user = useUser() - const memberGroupIds = useMemberGroupIds(user) || [] + const memberGroupIds = useMemberGroupIds(serverUser) || [] + const user = useUser() ?? serverUser useEffect(() => { // Load User object for creator of new Groups. @@ -117,6 +112,39 @@ export default function Groups(props: { <Tabs currentPageForAnalytics={'groups'} tabs={[ + ...(user + ? [ + { + title: 'My Groups', + content: ( + <Col> + <input + type="text" + 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 + .filter((match) => + memberGroupIds.includes(match.id) + ) + .map((group) => ( + <GroupCard + key={group.id} + group={group} + creator={creatorsDict[group.creatorId]} + user={user} + isMember={memberGroupIds.includes(group.id)} + /> + ))} + </div> + </Col> + ), + }, + ] + : []), { title: 'All', content: ( @@ -142,33 +170,6 @@ export default function Groups(props: { </Col> ), }, - { - title: 'My Groups', - content: ( - <Col> - <input - type="text" - 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 - .filter((match) => memberGroupIds.includes(match.id)) - .map((group) => ( - <GroupCard - key={group.id} - group={group} - creator={creatorsDict[group.creatorId]} - user={user} - isMember={memberGroupIds.includes(group.id)} - /> - ))} - </div> - </Col> - ), - }, ]} /> </Col> From e924061c543c61e9eaf01fa72736b08141bdfe0a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 19:39:07 -0700 Subject: [PATCH 229/279] Don't re-create visibility observer for no reason (#849) * Don't re-create visibility observer for no reason * `IntersectionObserver.unobserve` instead of `.disconnect` --- web/components/visibility-observer.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/web/components/visibility-observer.tsx b/web/components/visibility-observer.tsx index 9af410c7..aea2e41d 100644 --- a/web/components/visibility-observer.tsx +++ b/web/components/visibility-observer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useEvent } from '../hooks/use-event' export function VisibilityObserver(props: { @@ -8,17 +8,18 @@ export function VisibilityObserver(props: { const { className } = props const [elem, setElem] = useState<HTMLElement | null>(null) const onVisibilityUpdated = useEvent(props.onVisibilityUpdated) - - useEffect(() => { - const hasIOSupport = !!window.IntersectionObserver - if (!hasIOSupport || !elem) return - - const observer = new IntersectionObserver(([entry]) => { + const observer = useRef( + new IntersectionObserver(([entry]) => { onVisibilityUpdated(entry.isIntersecting) }, {}) - observer.observe(elem) - return () => observer.disconnect() - }, [elem, onVisibilityUpdated]) + ).current + + useEffect(() => { + if (elem) { + observer.observe(elem) + return () => observer.unobserve(elem) + } + }, [elem, observer]) return <div ref={setElem} className={className}></div> } From 8318621d51c75fc328770414ea07ad98e8bfc084 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 19:39:27 -0700 Subject: [PATCH 230/279] Some changes to make auth better (#846) * Handle the case where a user is surprisingly not in the DB * Only set referral info on user after creation * More reliably cache current user info in local storage * Don't jam username stuff into user listener hook --- web/components/auth-context.tsx | 47 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index 0e9fbd0e..d7c7b717 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -67,6 +67,16 @@ export function AuthProvider(props: { } }, [setAuthUser, serverUser]) + useEffect(() => { + if (authUser != null) { + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser)) + } else { + localStorage.removeItem(CACHED_USER_KEY) + } + }, [authUser]) + useEffect(() => { return onIdTokenChanged( auth, @@ -77,17 +87,13 @@ export function AuthProvider(props: { if (!current.user || !current.privateUser) { const deviceToken = ensureDeviceToken() current = (await createUser({ deviceToken })) as UserAndPrivateUser + setCachedReferralInfoForUser(current.user) } setAuthUser(current) - // Persist to local storage, to reduce login blink next time. - // Note: Cap on localStorage size is ~5mb - localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current)) - setCachedReferralInfoForUser(current.user) } else { // User logged out; reset to null setUserCookie(undefined) setAuthUser(null) - localStorage.removeItem(CACHED_USER_KEY) } }, (e) => { @@ -97,29 +103,32 @@ export function AuthProvider(props: { }, [setAuthUser]) const uid = authUser?.user.id - const username = authUser?.user.username useEffect(() => { - if (uid && username) { + if (uid) { identifyUser(uid) - setUserProperty('username', username) - const userListener = listenForUser(uid, (user) => - setAuthUser((authUser) => { - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - return { ...authUser!, user: user! } - }) - ) + const userListener = listenForUser(uid, (user) => { + setAuthUser((currAuthUser) => + currAuthUser && user ? { ...currAuthUser, user } : null + ) + }) const privateUserListener = listenForPrivateUser(uid, (privateUser) => { - setAuthUser((authUser) => { - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - return { ...authUser!, privateUser: privateUser! } - }) + setAuthUser((currAuthUser) => + currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null + ) }) return () => { userListener() privateUserListener() } } - }, [uid, username, setAuthUser]) + }, [uid, setAuthUser]) + + const username = authUser?.user.username + useEffect(() => { + if (username != null) { + setUserProperty('username', username) + } + }, [username]) return ( <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> From 784c081663784c9357924f33dcf9f10c93941784 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Fri, 2 Sep 2022 19:43:22 -0700 Subject: [PATCH 231/279] Enable source maps in production (#852) --- web/next.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/next.config.js b/web/next.config.js index 6ade8674..e99a3081 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -4,6 +4,7 @@ const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to' /** @type {import('next').NextConfig} */ module.exports = { + productionBrowserSourceMaps: true, staticPageGenerationTimeout: 600, // e.g. stats page reactStrictMode: true, optimizeFonts: false, From bfa88c3406b0a641f37b15c533d37f64c8849121 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Fri, 2 Sep 2022 22:51:33 -0500 Subject: [PATCH 232/279] Turn off react-query notification subscription because it's buggy --- web/hooks/use-notifications.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 60d0e43e..473facd4 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -16,11 +16,7 @@ export type NotificationGroup = { function useNotifications(privateUser: PrivateUser) { const result = useFirestoreQueryData( ['notifications-all', privateUser.id], - getNotificationsQuery(privateUser.id), - { subscribe: true, includeMetadataChanges: true }, - // Temporary workaround for react-query bug: - // https://github.com/invertase/react-query-firebase/issues/25 - { refetchOnMount: 'always' } + getNotificationsQuery(privateUser.id) ) const notifications = useMemo(() => { From 2d88675f42ac7384a3634cf5d2c5dc4634910f04 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Sat, 3 Sep 2022 06:33:33 -0600 Subject: [PATCH 233/279] Move & more out of the loop --- web/components/multi-user-transaction-link.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/web/components/multi-user-transaction-link.tsx b/web/components/multi-user-transaction-link.tsx index 70d273db..3f44349f 100644 --- a/web/components/multi-user-transaction-link.tsx +++ b/web/components/multi-user-transaction-link.tsx @@ -32,20 +32,21 @@ export function MultiUserTransactionLink(props: { setOpen(true) }} > - <Row className={'gap-1'}> - {userInfos.map((userInfo, index) => - index < maxShowCount ? ( - <Row key={userInfo.username + 'shortened'}> + <Row className={'items-center gap-1 sm:gap-2'}> + {userInfos.map( + (userInfo, index) => + index < maxShowCount && ( <Avatar username={userInfo.username} size={'sm'} avatarUrl={userInfo.avatarUrl} noLink={userInfos.length > 1} + key={userInfo.username + 'avatar'} /> - </Row> - ) : ( - <span>& {userInfos.length - maxShowCount} more</span> - ) + ) + )} + {userInfos.length > maxShowCount && ( + <span>& {userInfos.length - maxShowCount} more</span> )} </Row> </Button> From 861fb7abbd5a6a0a4ab09a2465d7e62cee879478 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 3 Sep 2022 05:51:55 -0700 Subject: [PATCH 234/279] Use the magic `auth` prop for groups SSR (#851) --- web/pages/groups.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 2bac5aed..100c8a54 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -9,7 +9,7 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { useGroups, useMemberGroupIds } from 'web/hooks/use-group' import { groupPath, listAllGroups } from 'web/lib/firebase/groups' -import { getUser, User } from 'web/lib/firebase/users' +import { getUser, getUserAndPrivateUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' @@ -23,7 +23,7 @@ import { useUser } from 'web/hooks/use-user' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) - const serverUser = creds ? await getUser(creds.uid) : null + const auth = creds ? await getUserAndPrivateUser(creds.uid) : null const groups = await listAllGroups().catch((_) => []) const creators = await Promise.all( @@ -33,17 +33,17 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { creators.map((creator) => [creator.id, creator]) ) - return { props: { serverUser, groups: groups, creatorsDict } } + return { props: { auth, groups, creatorsDict } } } export default function Groups(props: { - serverUser: User | null + auth: { user: User } | null groups: Group[] creatorsDict: { [k: string]: User } }) { //TODO: do we really need the creatorsDict? const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict) - const { serverUser } = props || {} + const serverUser = props.auth?.user const groups = useGroups() ?? props.groups const memberGroupIds = useMemberGroupIds(serverUser) || [] const user = useUser() ?? serverUser From 272658e5dc3e26185be00f322a1d0b2fa37b389f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Sat, 3 Sep 2022 06:52:51 -0600 Subject: [PATCH 235/279] Use most up-to-date user on groups page --- web/pages/groups.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 100c8a54..3405ef3e 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -45,8 +45,8 @@ export default function Groups(props: { const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict) const serverUser = props.auth?.user const groups = useGroups() ?? props.groups - const memberGroupIds = useMemberGroupIds(serverUser) || [] const user = useUser() ?? serverUser + const memberGroupIds = useMemberGroupIds(user) || [] useEffect(() => { // Load User object for creator of new Groups. From 0938368e3065abcd7fbf8e72a4677b4e3420e6ae Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Sat, 3 Sep 2022 07:29:35 -0600 Subject: [PATCH 236/279] Capitalize yes/no resolution outcomes --- og-image/api/_lib/template.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 26f7677e..2469a636 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -22,13 +22,13 @@ export function getHtml(parsedReq: ParsedRequest) { const hideAvatar = creatorAvatarUrl ? '' : 'hidden' let resolutionColor = 'text-primary' - let resolutionString = 'Yes' + let resolutionString = 'YES' switch (resolution) { case 'YES': break case 'NO': resolutionColor = 'text-red-500' - resolutionString = 'No' + resolutionString = 'NO' break case 'CANCEL': resolutionColor = 'text-yellow-500' From c0383bcf26832fbd2498358f457d69ee3cfaae67 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 3 Sep 2022 09:55:10 -0700 Subject: [PATCH 237/279] 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 <akrolsmir@gmail.com> --- 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 ( <div className={clsx('relative', className)}> 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 = <T>(opts: PaginationOptions<T>) => { 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<Contract[]> { 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<Contract[]> { - 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 ( <Page> @@ -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" /> - <Col className="mx-4 mt-4 gap-20 sm:mx-10 xl:w-[125%]"> - {tourneys.map(({ groupId, ...data }) => ( - <Section - key={groupId} - {...data} - url={groupPath(slugs[groupId])} - ppl={numPeople[groupId] ?? 0} - markets={markets[groupId] ?? []} - /> + <Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]"> + {sections.map(({ tourney, slug, numPeople }) => ( + <div key={slug}> + <SectionHeader + url={groupPath(slug)} + title={tourney.title} + ppl={numPeople} + award={tourney.award} + endTime={tourney.endTime} + /> + <span>{tourney.blurb}</span> + <MarketCarousel slug={slug} /> + </div> ))} - <Section {...Salem} /> + <div> + <SectionHeader + url={Salem.url} + title={Salem.title} + award={Salem.award} + endTime={Salem.endTime} + /> + <span>{Salem.blurb}</span> + <ImageCarousel url={Salem.url} images={Salem.images} /> + </div> </Col> </Page> ) } -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 ( - <div> - <Link href={url}> - <a className="group mb-3 flex flex-wrap justify-between"> - <h2 className="text-xl font-semibold group-hover:underline md:text-3xl"> - {title} - </h2> - <Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6"> - {!!award && <span className="flex items-center">🏆 {award}</span>} - {!!ppl && ( + <Link href={url}> + <a className="group mb-3 flex flex-wrap justify-between"> + <h2 className="text-xl font-semibold group-hover:underline md:text-3xl"> + {title} + </h2> + <Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6"> + {!!award && <span className="flex items-center">🏆 {award}</span>} + {!!ppl && ( + <span className="flex items-center gap-1"> + <UsersIcon className="h-4" /> + {ppl} + </span> + )} + {endTime && ( + <DateTimeTooltip time={endTime} text="Ends"> <span className="flex items-center gap-1"> - <UsersIcon className="h-4" /> - {ppl} + <ClockIcon className="h-4" /> + {dayjs(endTime).format('MMM D')} </span> - )} - {endTime && ( - <DateTimeTooltip time={endTime.valueOf()} text="Ends"> - <span className="flex items-center gap-1"> - <ClockIcon className="h-4" /> - {endTime.format('MMM D')} - </span> - </DateTimeTooltip> - )} - </Row> + </DateTimeTooltip> + )} + </Row> + </a> + </Link> + ) +} + +const ImageCarousel = (props: { images: MarketImage[]; url: string }) => { + const { images, url } = props + return ( + <Carousel className="-mx-4 mt-4 sm:-mx-10"> + <div className="shrink-0 sm:w-6" /> + {images.map(({ marketUrl, image }) => ( + <a key={marketUrl} href={marketUrl} className="hover:brightness-95"> + <NaturalImage src={image} /> </a> - </Link> - <span>{blurb}</span> - <Carousel className="-mx-4 mt-2 sm:-mx-10"> - <div className="shrink-0 sm:w-6" /> - {markets.length ? ( - markets.map((m) => ( - <ContractCard - contract={m} - hideGroupLink - className="mb-2 max-h-[200px] w-96 shrink-0" - questionClass="line-clamp-3" - trackingPostfix=" tournament" - /> - )) - ) : ( - <> - {images?.map(({ marketUrl, image }) => ( - <a href={marketUrl} className="hover:brightness-95"> - <NaturalImage src={image} /> - </a> - ))} - <SiteLink - className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700" - href={url} - > - See more - </SiteLink> - </> - )} - </Carousel> - </div> + ))} + <SiteLink + className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700" + href={url} + > + See more + </SiteLink> + </Carousel> + ) +} + +const MarketCarousel = (props: { slug: string }) => { + const { slug } = props + const q = contractsByGroupSlugQuery(slug) + const { allItems, getNext, isLoading } = usePagination({ q, pageSize: 12 }) + return isLoading ? ( + <LoadingIndicator className="mt-10" /> + ) : ( + <Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}> + <div className="shrink-0 sm:w-6" /> + {allItems().map((m) => ( + <ContractCard + key={m.id} + contract={m} + hideGroupLink + className="mb-2 max-h-[200px] w-96 shrink-0" + questionClass="line-clamp-3" + trackingPostfix=" tournament" + /> + ))} + </Carousel> ) } From 085b9aeb2a7d50f70dd8842def8bcf41d388e450 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 3 Sep 2022 14:55:37 -0500 Subject: [PATCH 238/279] 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 ( - <tbody> - {props.entries.map((entry, i) => ( - <tr key={i}> - <th>{props.entries.length - i}</th> - <TableRowStart entry={entry} /> - <TableRowEnd entry={entry} /> - </tr> - ))} - </tbody> - ) -} - -function TableRowStart(props: { entry: Entry }) { - const { entry } = props - if (entry.yesBid && entry.noBid) { - return ( - <> - <td> - <div className="badge">ANTE</div> - </td> - <td> - ${entry.yesBid} / ${entry.noBid} - </td> - </> - ) - } else if (entry.yesBid) { - return ( - <> - <td> - <div className="badge badge-success">YES</div> - </td> - <td>${entry.yesBid}</td> - </> - ) - } else { - return ( - <> - <td> - <div className="badge badge-error">NO</div> - </td> - <td>${entry.noBid}</td> - </> - ) - } -} - -function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { - const { entry } = props - if (!entry) { - return ( - <> - <td>0</td> - <td>0</td> - {!props.isNew && ( - <> - <td>N/A</td> - <td>N/A</td> - </> - )} - </> - ) - } else if (entry.yesBid && entry.noBid) { - return ( - <> - <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>N/A</td> - {!props.isNew && ( - <> - <td>N/A</td> - <td>N/A</td> - </> - )} - </> - ) - } else if (entry.yesBid) { - return ( - <> - <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${entry.yesWeight.toFixed(0)}</td> - {!props.isNew && ( - <> - <td>${entry.yesPayout.toFixed(0)}</td> - <td>{(entry.yesReturn * 100).toFixed(0)}%</td> - </> - )} - </> - ) - } else { - return ( - <> - <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${entry.noWeight.toFixed(0)}</td> - {!props.isNew && ( - <> - <td>${entry.noPayout.toFixed(0)}</td> - <td>{(entry.noReturn * 100).toFixed(0)}%</td> - </> - )} - </> - ) - } -} - -type Bid = { yesBid: number; noBid: number } - -function NewBidTable(props: { - steps: number - bids: Array<Bid> - setSteps: (steps: number) => void - setBids: (bids: Array<Bid>) => 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 ( - <> - <table className="table-compact my-8 table w-full text-center"> - <thead> - <tr> - <th>Order #</th> - <th>Type</th> - <th>Bet</th> - <th>Prob</th> - <th>Est Payout</th> - <th></th> - </tr> - </thead> - <tbody> - <tr> - <th>{steps + 1}</th> - <td> - <div - className={ - `badge hover:cursor-pointer ` + - (newBidType == 'YES' ? 'badge-success' : 'badge-ghost') - } - onClick={toggleBidType} - > - YES - </div> - <br /> - <div - className={ - `badge hover:cursor-pointer ` + - (newBidType == 'NO' ? 'badge-error' : 'badge-ghost') - } - onClick={toggleBidType} - > - NO - </div> - </td> - <td> - {/* Note: Would love to make this input smaller... */} - <input - type="number" - placeholder="0" - className="input input-bordered max-w-[100px]" - value={newBid.toString()} - onChange={(e) => setNewBid(parseInt(e.target.value) || 0)} - onKeyUp={(e) => { - if (e.key === 'Enter') { - submitBid() - } - }} - onFocus={(e) => e.target.select()} - /> - </td> - - <TableRowEnd entry={nextEntry} isNew /> - - <button - className="btn btn-primary mt-2" - onClick={() => submitBid()} - disabled={newBid <= 0} - > - Submit - </button> - </tr> - </tbody> - </table> - - <button className="btn btn-secondary mb-4" onClick={randomBid}> - Random bet! - </button> - </> - ) -} - -// 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 ( - <Col> - <div className="mx-auto mt-8 grid w-full grid-cols-1 gap-4 p-2 text-center xl:grid-cols-2"> - {/* Left column */} - <div> - <h1 className="mb-8 text-2xl font-bold"> - Dynamic Parimutuel Market Simulator - </h1> - - <NewBidTable {...{ steps, bids, setSteps, setBids }} /> - - {/* History of bids */} - <div className="overflow-x-auto"> - <table className="table w-full text-center"> - <thead> - <tr> - <th>Order #</th> - <th>Type</th> - <th>Bet</th> - <th>Prob</th> - <th>Est Payout</th> - <th>Payout</th> - <th>Return</th> - </tr> - </thead> - - <TableBody entries={reversedEntries} /> - </table> - </div> - </div> - - {/* Right column */} - <Col> - <h1 className="mb-8 text-2xl font-bold"> - Probability of - <div className="badge badge-success w-18 ml-3 h-8 text-2xl"> - YES - </div> - </h1> - <div className="mb-10 h-[500px] w-full"> - <ResponsiveLine - data={data} - yScale={{ min: 0, max: 100, type: 'linear' }} - yFormat={formatPercent} - gridYValues={tickValues} - axisLeft={{ - tickValues, - format: formatPercent, - }} - enableGridX={false} - colors={{ datum: 'color' }} - pointSize={8} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableArea - margin={{ top: 20, right: 10, bottom: 20, left: 40 }} - /> - </div> - {/* Range slider that sets the current step */} - <label>Orders # 1 - {steps}</label> - <input - type="range" - className="range" - min="1" - max={bids.length} - value={steps} - onChange={(e) => setSteps(parseInt(e.target.value))} - /> - </Col> - </div> - </Col> - ) -} - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} From 9060abde8ec41a6cd37faf71c7b9328928b94558 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 3 Sep 2022 15:06:41 -0500 Subject: [PATCH 239/279] 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<CPMM> = {} + 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 <marshall@pol.rs> Date: Sat, 3 Sep 2022 14:07:34 -0700 Subject: [PATCH 240/279] 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 ? ( <LoadingIndicator className="mt-10" /> ) : ( <Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}> <div className="shrink-0 sm:w-6" /> - {allItems().map((m) => ( + {items.map((m) => ( <ContractCard key={m.id} contract={m} From a21466d877c522c4abee775cb8274fcc3411d2d3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sat, 3 Sep 2022 16:20:56 -0500 Subject: [PATCH 241/279] 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 <jahooma@gmail.com> Date: Sun, 4 Sep 2022 14:06:29 -0500 Subject: [PATCH 242/279] 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]]} /> </div> - <div className="mt-2 text-sm text-gray-500"> - {commentsById[topCommentId].userName} made{' '} - {formatMoney(profitById[topCommentId] || 0)}! - </div> <Spacer h={16} /> </> )} @@ -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="💸 Smartest money" className="!mt-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 243/279] 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 244/279] 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 245/279] 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 246/279] 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 247/279] "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 248/279] 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 249/279] 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 250/279] 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 251/279] 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 252/279] 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 253/279] 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 254/279] 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 255/279] 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 256/279] 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 257/279] 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 258/279] 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 259/279] 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 260/279] 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 261/279] 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 262/279] 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 263/279] 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 264/279] 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 265/279] 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 266/279] 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 267/279] 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 268/279] 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 269/279] 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 270/279] 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 271/279] 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 272/279] 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 273/279] 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 274/279] 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 275/279] 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 276/279] 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 277/279] 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 278/279] 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 279/279] 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