From 876abef0409841f136f8aaecc16dded4dbcd443f Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 30 Aug 2022 10:02:51 -0600 Subject: [PATCH 01/82] 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 Date: Tue, 30 Aug 2022 10:31:35 -0700 Subject: [PATCH 02/82] 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 Date: Tue, 30 Aug 2022 16:18:39 -0500 Subject: [PATCH 03/82] 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(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 ( +
+ + {children} + + {!atFront && ( +
+ +
+ )} + {!atBack && ( +
+ +
+ )} +
+ ) +} 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} /> - + {renderContracts ? ( + renderContracts(renderedContracts) + ) : ( + + )} ) } 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 ( - + @@ -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 ( - - <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 04/82] 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 05/82] 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 06/82] 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 07/82] 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 08/82] 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 09/82] 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 10/82] 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 11/82] 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 12/82] 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 13/82] 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 14/82] 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 15/82] 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 16/82] 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 17/82] 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 18/82] 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 19/82] 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 20/82] 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 21/82] 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 22/82] 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 23/82] 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 24/82] 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 25/82] 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 26/82] 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 27/82] 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 28/82] 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 29/82] 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 30/82] 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 31/82] 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 32/82] 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 33/82] 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 34/82] 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 35/82] 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 36/82] 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 37/82] 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 38/82] 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 39/82] 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 40/82] 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 41/82] 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 42/82] 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 43/82] 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 44/82] 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 45/82] 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 46/82] 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 47/82] 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 48/82] 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 49/82] 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 50/82] 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 51/82] 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 52/82] 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 53/82] 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 54/82] 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 55/82] 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 56/82] 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 57/82] 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 58/82] 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 59/82] 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 60/82] 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 61/82] 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 62/82] 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 63/82] 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 64/82] 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 65/82] 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 66/82] 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 67/82] 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 68/82] 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 69/82] 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 70/82] 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 71/82] 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 72/82] 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 73/82] 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 74/82] 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 75/82] 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 76/82] 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 77/82] 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 78/82] 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 79/82] 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 80/82] 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 81/82] 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 82/82] 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'