From 60f2552139e7d9ac8268130bd34a9cad1a475e9c Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Sun, 9 Oct 2022 16:09:21 -0700 Subject: [PATCH 01/22] copy: Referrals -> Refer a friend --- web/components/nav/sidebar.tsx | 4 ++-- web/pages/referrals.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 23b1115c..c8c4cd80 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) { return buildArray( { name: 'Leaderboards', href: '/leaderboards' }, { name: 'Groups', href: '/groups' }, - { name: 'Referrals', href: '/referrals' }, + { name: 'Refer a friend', href: '/referrals' }, { name: 'Charity', href: '/charity' }, { name: 'Labs', href: '/labs' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, @@ -215,7 +215,7 @@ function getMoreMobileNav() { return buildArray( { name: 'Groups', href: '/groups' }, - { name: 'Referrals', href: '/referrals' }, + { name: 'Refer a friend', href: '/referrals' }, { name: 'Charity', href: '/charity' }, { name: 'Labs', href: '/labs' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, diff --git a/web/pages/referrals.tsx b/web/pages/referrals.tsx index 2e330980..15f75ff4 100644 --- a/web/pages/referrals.tsx +++ b/web/pages/referrals.tsx @@ -10,6 +10,7 @@ import { ENV_CONFIG } from 'common/envs/constants' import { InfoBox } from 'web/components/info-box' import { QRCode } from 'web/components/qr-code' import { REFERRAL_AMOUNT } from 'common/economy' +import { formatMoney } from 'common/util/format' export const getServerSideProps = redirectIfLoggedOut('/') @@ -23,15 +24,15 @@ export default function ReferralsPage() { return ( - + <Title className="!mt-0" text="Refer a friend" /> <img className="mb-6 block -scale-x-100 self-center" src="/logo-flapping-with-money.gif" @@ -40,7 +41,7 @@ export default function ReferralsPage() { /> <div className={'mb-4'}> - Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they + Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)} if they sign up! </div> From 4831c25ce045fde73ecd76e6c09594f5eda46496 Mon Sep 17 00:00:00 2001 From: sipec <sipec@users.noreply.github.com> Date: Sun, 9 Oct 2022 23:10:02 +0000 Subject: [PATCH 02/22] Auto-prettification --- web/pages/referrals.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/pages/referrals.tsx b/web/pages/referrals.tsx index 15f75ff4..46b7b7b0 100644 --- a/web/pages/referrals.tsx +++ b/web/pages/referrals.tsx @@ -25,7 +25,9 @@ export default function ReferralsPage() { <Page> <SEO title="Refer a friend" - description={`Invite new users to Manifold and get ${formatMoney(REFERRAL_AMOUNT)} if they + description={`Invite new users to Manifold and get ${formatMoney( + REFERRAL_AMOUNT + )} if they sign up!`} url="/referrals" /> @@ -41,8 +43,8 @@ export default function ReferralsPage() { /> <div className={'mb-4'}> - Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)} if they - sign up! + Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)}{' '} + if they sign up! </div> <CopyLinkButton From dc51e2cf46b7d5ad870c80fd5aba8e10d2099420 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 9 Oct 2022 19:11:44 -0500 Subject: [PATCH 03/22] Rename `updateMetrics` to `scheduleUpdateMetrics` --- functions/src/index.ts | 2 +- functions/src/update-metrics.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 763fd8bb..a6d120c8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,7 +9,7 @@ export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' -export { updateMetrics } from './update-metrics' +export { scheduleUpdateMetrics } from './update-metrics' export * from './update-stats' export * from './update-loans' export * from './backup-db' diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 4739dcc1..e77ab71f 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -26,7 +26,7 @@ import { getFunctionUrl } from '../../common/api' const firestore = admin.firestore() -export const updateMetrics = functions.pubsub +export const scheduleUpdateMetrics = functions.pubsub .schedule('every 15 minutes') .onRun(async () => { const response = await fetch(getFunctionUrl('updatemetrics'), { From 8d06e4b4d2f664971fcd45d26ccdf2fea2a265ce Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Sun, 9 Oct 2022 19:37:24 -0700 Subject: [PATCH 04/22] refactor text input into one component (#1016) * Add responsive text input component * Add styled expanding textarea component --- web/components/amount-input.tsx | 5 ++-- web/components/answers/answer-item.tsx | 5 ++-- .../answers/create-answer-panel.tsx | 6 ++--- .../answers/multiple-choice-answers.tsx | 6 ++--- .../challenges/create-challenge-modal.tsx | 6 ++--- web/components/contract-search.tsx | 7 +++--- .../contract/contract-description.tsx | 7 +++--- web/components/contract/contract-details.tsx | 9 ++++--- web/components/create-post.tsx | 8 +++--- web/components/expanding-input.tsx | 16 ++++++++++++ web/components/filter-select-users.tsx | 5 ++-- web/components/groups/create-group-button.tsx | 4 +-- web/components/groups/edit-group-button.tsx | 4 +-- web/components/input.tsx | 22 ++++++++++++++++ .../manalinks/create-links-button.tsx | 15 ++++++----- web/components/number-input.tsx | 5 ++-- web/components/probability-input.tsx | 8 +++--- web/components/probability-selector.tsx | 5 ++-- web/pages/charity/index.tsx | 5 ++-- web/pages/contract-search-firestore.tsx | 5 ++-- web/pages/create.tsx | 25 ++++++++----------- web/pages/date-docs/create.tsx | 10 +++----- web/pages/groups.tsx | 9 ++++--- web/pages/home/index.tsx | 5 ++-- web/pages/profile.tsx | 19 ++++++-------- 25 files changed, 128 insertions(+), 93 deletions(-) create mode 100644 web/components/expanding-input.tsx create mode 100644 web/components/input.tsx diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 65a79c20..8cd43369 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -6,6 +6,7 @@ import { Col } from './layout/col' import { ENV_CONFIG } from 'common/envs/constants' import { Row } from './layout/row' import { AddFundsModal } from './add-funds-modal' +import { Input } from './input' export function AmountInput(props: { amount: number | undefined @@ -44,9 +45,9 @@ export function AmountInput(props: { <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2"> {label} </span> - <input + <Input className={clsx( - 'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', + 'pl-9', error && 'input-error', 'w-24 md:w-auto', inputClassName diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index f1ab2f88..323a8b9b 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format' import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { tradingAllowed } from 'web/lib/firebase/contracts' import { Linkify } from '../linkify' +import { Input } from '../input' export function AnswerItem(props: { answer: Answer @@ -74,8 +75,8 @@ export function AnswerItem(props: { <Row className="items-center justify-end gap-4 self-end sm:self-start"> {!wasResolvedTo && (showChoice === 'checkbox' ? ( - <input - className="input input-bordered w-24 justify-self-end text-2xl" + <Input + className="w-24 justify-self-end !text-2xl" type="number" placeholder={`${roundedProb}`} maxLength={9} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 58f55327..4012e587 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx' import React, { useState } from 'react' -import Textarea from 'react-expanding-textarea' import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' @@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' import { lowerCase } from 'lodash' import { Button } from '../button' +import { ExpandingInput } from '../expanding-input' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { <Col className="gap-4 rounded"> <Col className="flex-1 gap-2 px-4 xl:px-0"> <div className="mb-1">Add your answer</div> - <Textarea + <ExpandingInput value={text} onChange={(e) => changeAnswer(e.target.value)} - className="textarea textarea-bordered w-full resize-none" + className="w-full" placeholder="Type your answer..." rows={1} maxLength={MAX_ANSWER_LENGTH} diff --git a/web/components/answers/multiple-choice-answers.tsx b/web/components/answers/multiple-choice-answers.tsx index c2857eb2..bdc61022 100644 --- a/web/components/answers/multiple-choice-answers.tsx +++ b/web/components/answers/multiple-choice-answers.tsx @@ -1,8 +1,8 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' -import Textarea from 'react-expanding-textarea' import { XIcon } from '@heroicons/react/solid' import { Col } from '../layout/col' import { Row } from '../layout/row' +import { ExpandingInput } from '../expanding-input' export function MultipleChoiceAnswers(props: { answers: string[] @@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: { {answers.map((answer, i) => ( <Row className="mb-2 items-center gap-2 align-middle"> {i + 1}.{' '} - <Textarea + <ExpandingInput value={answer} onChange={(e) => setAnswer(i, e.target.value)} - className="textarea textarea-bordered ml-2 w-full resize-none" + className="ml-2 w-full" placeholder="Type your answer..." rows={1} maxLength={MAX_ANSWER_LENGTH} diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index f18fbdad..f8d91a7b 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -20,11 +20,11 @@ import { getProbability } from 'common/calculate' import { createMarket } from 'web/lib/firebase/api' import { removeUndefinedProps } from 'common/util/object' import { FIXED_ANTE } from 'common/economy' -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 { CopyLinkButton } from '../copy-link-button' +import { ExpandingInput } from '../expanding-input' type challengeInfo = { amount: number @@ -153,9 +153,9 @@ function CreateChallengeForm(props: { {contract ? ( <span className="underline">{contract.question}</span> ) : ( - <Textarea + <ExpandingInput placeholder="e.g. Will a Democrat be the next president?" - className="input input-bordered mt-1 w-full resize-none" + className="mt-1 w-full" autoFocus={true} maxLength={MAX_QUESTION_LENGTH} value={challengeInfo.question} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 8d871d65..8365e83b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -41,6 +41,7 @@ import { AdjustmentsIcon } from '@heroicons/react/solid' import { Button } from './button' import { Modal } from './layout/modal' import { Title } from './title' +import { Input } from './input' export const SORTS = [ { label: 'Newest', value: 'newest' }, @@ -438,13 +439,13 @@ function ContractSearchControls(props: { return ( <Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}> <Row className="gap-1 sm:gap-2"> - <input + <Input type="text" value={query} onChange={(e) => updateQuery(e.target.value)} onBlur={trackCallback('search', { query: query })} - placeholder={'Search'} - className="input input-bordered w-full" + placeholder="Search" + className="w-full" autoFocus={autoFocus} /> {!isMobile && !query && ( diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 259e39df..dc94d339 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -1,8 +1,6 @@ import clsx from 'clsx' import dayjs from 'dayjs' import { useState } from 'react' -import Textarea from 'react-expanding-textarea' - import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { exhibitExts } from 'common/util/parse' import { useAdmin } from 'web/hooks/use-admin' @@ -15,6 +13,7 @@ import { Button } from '../button' import { Spacer } from '../layout/spacer' import { Editor, Content as ContentType } from '@tiptap/react' import { insertContent } from '../editor/utils' +import { ExpandingInput } from '../expanding-input' export function ContractDescription(props: { contract: Contract @@ -138,8 +137,8 @@ function EditQuestion(props: { return editing ? ( <div className="mt-4"> - <Textarea - className="textarea textarea-bordered mb-1 h-24 w-full resize-none" + <ExpandingInput + className="mb-1 h-24 w-full" rows={2} value={text} onChange={(e) => setText(e.target.value || '')} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 3b308667..d2734ab5 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -40,6 +40,7 @@ import { BountiedContractBadge, BountiedContractSmallBadge, } from 'web/components/contract/bountied-contract-badge' +import { Input } from '../input' export type ShowTime = 'resolve-date' | 'close-date' @@ -445,17 +446,17 @@ function EditableCloseDate(props: { <Col className="rounded bg-white px-8 pb-8"> <Subtitle text="Edit market close time" /> <Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2"> - <input + <Input type="date" - className="input input-bordered w-full shrink-0 sm:w-fit" + className="w-full shrink-0 sm:w-fit" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)} min={Date.now()} value={closeDate} /> - <input + <Input type="time" - className="input input-bordered w-full shrink-0 sm:w-max" + className="w-full shrink-0 sm:w-max" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseHoursMinutes(e.target.value)} min="00:00" diff --git a/web/components/create-post.tsx b/web/components/create-post.tsx index 6d42051c..f7d9b8bd 100644 --- a/web/components/create-post.tsx +++ b/web/components/create-post.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { Spacer } from 'web/components/layout/spacer' import { Title } from 'web/components/title' -import Textarea from 'react-expanding-textarea' import { TextEditor, useTextEditor } from 'web/components/editor' import { createPost } from 'web/lib/firebase/api' @@ -10,6 +9,7 @@ import Router from 'next/router' import { MAX_POST_TITLE_LENGTH } from 'common/post' import { postPath } from 'web/lib/firebase/posts' import { Group } from 'common/group' +import { ExpandingInput } from './expanding-input' export function CreatePost(props: { group?: Group }) { const [title, setTitle] = useState('') @@ -60,9 +60,8 @@ export function CreatePost(props: { group?: Group }) { Title<span className={'text-red-700'}> *</span> </span> </label> - <Textarea + <ExpandingInput placeholder="e.g. Elon Mania Post" - className="input input-bordered resize-none" autoFocus maxLength={MAX_POST_TITLE_LENGTH} value={title} @@ -74,9 +73,8 @@ export function CreatePost(props: { group?: Group }) { Subtitle<span className={'text-red-700'}> *</span> </span> </label> - <Textarea + <ExpandingInput placeholder="e.g. How Elon Musk is getting everyone's attention" - className="input input-bordered resize-none" autoFocus maxLength={MAX_POST_TITLE_LENGTH} value={subtitle} diff --git a/web/components/expanding-input.tsx b/web/components/expanding-input.tsx new file mode 100644 index 00000000..ff442348 --- /dev/null +++ b/web/components/expanding-input.tsx @@ -0,0 +1,16 @@ +import clsx from 'clsx' +import Textarea from 'react-expanding-textarea' + +/** Expanding `<textarea>` with same style as input.tsx */ +export const ExpandingInput = (props: Parameters<typeof Textarea>[0]) => { + const { className, ...rest } = props + return ( + <Textarea + className={clsx( + 'textarea textarea-bordered resize-none text-[16px] md:text-[14px]', + className + )} + {...rest} + /> + ) +} diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 415a6d57..8ec453d2 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { searchInAny } from 'common/util/parse' import { UserLink } from 'web/components/user-link' +import { Input } from './input' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void @@ -50,13 +51,13 @@ export function FilterSelectUsers(props: { <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> </div> - <input + <Input type="text" name="user name" id="user name" value={query} onChange={(e) => setQuery(e.target.value)} - className="input input-bordered block w-full pl-10 focus:border-gray-300 " + className="block w-full pl-10" placeholder="Austin Chen" /> </div> diff --git a/web/components/groups/create-group-button.tsx b/web/components/groups/create-group-button.tsx index 1df29764..017fa4a6 100644 --- a/web/components/groups/create-group-button.tsx +++ b/web/components/groups/create-group-button.tsx @@ -8,6 +8,7 @@ import { Title } from '../title' import { User } from 'common/user' import { MAX_GROUP_NAME_LENGTH } from 'common/group' import { createGroup } from 'web/lib/firebase/api' +import { Input } from '../input' export function CreateGroupButton(props: { user: User @@ -104,9 +105,8 @@ export function CreateGroupButton(props: { <div className="form-control w-full"> <label className="mb-2 ml-1 mt-0">Group name</label> - <input + <Input placeholder={'Your group name'} - className="input input-bordered resize-none" disabled={isSubmitting} value={name} maxLength={MAX_GROUP_NAME_LENGTH} diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 71c6034e..c427be5c 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' import { useMemberIds } from 'web/hooks/use-group' +import { Input } from '../input' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props @@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) { <span className="mb-1">Group name</span> </label> - <input + <Input placeholder="Your group name" - className="input input-bordered resize-none" disabled={isSubmitting} value={name} onChange={(e) => setName(e.target.value || '')} diff --git a/web/components/input.tsx b/web/components/input.tsx new file mode 100644 index 00000000..f37bfc78 --- /dev/null +++ b/web/components/input.tsx @@ -0,0 +1,22 @@ +import clsx from 'clsx' +import React from 'react' + +/** Text input. Wraps html `<input>` */ +export const Input = (props: JSX.IntrinsicElements['input']) => { + const { className, ...rest } = props + + return ( + <input + className={clsx('input input-bordered text-base md:text-sm', className)} + {...rest} + /> + ) +} + +/* + TODO: replace daisyui style with our own. For reference: + + james: text-lg placeholder:text-gray-400 + inga: placeholder:text-greyscale-4 border-greyscale-2 rounded-md + austin: border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm + */ diff --git a/web/components/manalinks/create-links-button.tsx b/web/components/manalinks/create-links-button.tsx index 449d6c76..8d5bac67 100644 --- a/web/components/manalinks/create-links-button.tsx +++ b/web/components/manalinks/create-links-button.tsx @@ -7,12 +7,13 @@ import { User } from 'common/user' import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' import { createManalink } from 'web/lib/firebase/manalinks' import { Modal } from 'web/components/layout/modal' -import Textarea from 'react-expanding-textarea' import dayjs from 'dayjs' import { Button } from '../button' import { getManalinkUrl } from 'web/pages/links' import { DuplicateIcon } from '@heroicons/react/outline' import { QRCode } from '../qr-code' +import { Input } from '../input' +import { ExpandingInput } from '../expanding-input' export function CreateLinksButton(props: { user: User @@ -120,8 +121,8 @@ function CreateManalinkForm(props: { <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> M$ </span> - <input - className="input input-bordered w-full pl-10" + <Input + className="w-full pl-10" type="number" min="1" value={newManalink.amount} @@ -136,8 +137,7 @@ function CreateManalinkForm(props: { <div className="flex flex-col gap-2 md:flex-row"> <div className="form-control w-full md:w-1/2"> <label className="label">Uses</label> - <input - className="input input-bordered" + <Input type="number" min="1" value={newManalink.maxUses ?? ''} @@ -146,7 +146,7 @@ function CreateManalinkForm(props: { return { ...m, maxUses: parseInt(e.target.value) } }) } - ></input> + /> </div> <div className="form-control w-full md:w-1/2"> <label className="label">Expires in</label> @@ -165,10 +165,9 @@ function CreateManalinkForm(props: { </div> <div className="form-control w-full"> <label className="label">Message</label> - <Textarea + <ExpandingInput placeholder={defaultMessage} maxLength={200} - className="input input-bordered resize-none" autoFocus value={newManalink.message} rows="3" diff --git a/web/components/number-input.tsx b/web/components/number-input.tsx index 0b48df6e..6509a5d7 100644 --- a/web/components/number-input.tsx +++ b/web/components/number-input.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react' import React from 'react' import { Col } from './layout/col' import { Spacer } from './layout/spacer' +import { Input } from './input' export function NumberInput(props: { numberString: string @@ -32,9 +33,9 @@ export function NumberInput(props: { return ( <Col className={className}> <label className="input-group"> - <input + <Input className={clsx( - 'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', + 'max-w-[200px] !text-lg', error && 'input-error', inputClassName )} diff --git a/web/components/probability-input.tsx b/web/components/probability-input.tsx index cc8b9259..9b58dc64 100644 --- a/web/components/probability-input.tsx +++ b/web/components/probability-input.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { getPseudoProbability } from 'common/pseudo-numeric' import { BucketInput } from './bucket-input' +import { Input } from './input' import { Col } from './layout/col' import { Spacer } from './layout/spacer' @@ -30,11 +31,8 @@ export function ProbabilityInput(props: { return ( <Col className={className}> <label className="input-group"> - <input - className={clsx( - 'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', - inputClassName - )} + <Input + className={clsx('max-w-[200px] !text-lg', inputClassName)} type="number" max={99} min={1} diff --git a/web/components/probability-selector.tsx b/web/components/probability-selector.tsx index b13dcfd9..010c7bfa 100644 --- a/web/components/probability-selector.tsx +++ b/web/components/probability-selector.tsx @@ -1,3 +1,4 @@ +import { Input } from './input' import { Row } from './layout/row' export function ProbabilitySelector(props: { @@ -10,10 +11,10 @@ export function ProbabilitySelector(props: { return ( <Row className="items-center gap-2"> <label className="input-group input-group-lg text-lg"> - <input + <Input type="number" value={probabilityInt} - className="input input-bordered input-md w-28 text-lg" + className="input-md w-28 !text-lg" disabled={isSubmitting} min={1} max={99} diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 2f4407d9..4ee7635e 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -24,6 +24,7 @@ import { getUser } from 'web/lib/firebase/users' import { SiteLink } from 'web/components/site-link' import { User } from 'common/user' import { SEO } from 'web/components/SEO' +import { Input } from 'web/components/input' export async function getStaticProps() { let txns = await getAllCharityTxns() @@ -171,11 +172,11 @@ export default function Charity(props: { /> <Spacer h={10} /> - <input + <Input type="text" onChange={(e) => debouncedQuery(e.target.value)} placeholder="Find a charity" - className="input input-bordered mb-6 w-full" + className="mb-6 w-full" /> </Col> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 4d6ada1d..0f81499f 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -9,6 +9,7 @@ import { urlParamStore, } from 'web/hooks/use-persistent-state' import { PAST_BETS } from 'common/user' +import { Input } from 'web/components/input' const MAX_CONTRACTS_RENDERED = 100 @@ -88,12 +89,12 @@ export default function ContractSearchFirestore(props: { <div> {/* Show a search input next to a sort dropdown */} <div className="mt-2 mb-8 flex justify-between gap-2"> - <input + <Input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search markets" - className="input input-bordered w-full" + className="w-full" /> <select className="select select-bordered" diff --git a/web/pages/create.tsx b/web/pages/create.tsx index e23ebf75..7ba99a39 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -2,7 +2,6 @@ import router, { useRouter } from 'next/router' import { useEffect, useState } from 'react' import clsx from 'clsx' import dayjs from 'dayjs' -import Textarea from 'react-expanding-textarea' import { Spacer } from 'web/components/layout/spacer' import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { Contract, contractPath } from 'web/lib/firebase/contracts' @@ -39,6 +38,8 @@ import { SiteLink } from 'web/components/site-link' import { Button } from 'web/components/button' import { AddFundsModal } from 'web/components/add-funds-modal' import ShortToggle from 'web/components/widgets/short-toggle' +import { Input } from 'web/components/input' +import { ExpandingInput } from 'web/components/expanding-input' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { return { props: { auth: await getUserAndPrivateUser(creds.uid) } } @@ -104,9 +105,8 @@ export default function Create(props: { auth: { user: User } }) { </span> </label> - <Textarea + <ExpandingInput placeholder="e.g. Will the Democrats win the 2024 US presidential election?" - className="input input-bordered resize-none" autoFocus maxLength={MAX_QUESTION_LENGTH} value={question} @@ -329,9 +329,9 @@ export function NewContract(props: { </label> <Row className="gap-2"> - <input + <Input type="number" - className="input input-bordered w-32" + className="w-32" placeholder="LOW" onClick={(e) => e.stopPropagation()} onChange={(e) => setMinString(e.target.value)} @@ -340,9 +340,9 @@ export function NewContract(props: { disabled={isSubmitting} value={minString ?? ''} /> - <input + <Input type="number" - className="input input-bordered w-32" + className="w-32" placeholder="HIGH" onClick={(e) => e.stopPropagation()} onChange={(e) => setMaxString(e.target.value)} @@ -374,9 +374,8 @@ export function NewContract(props: { </label> <Row className="gap-2"> - <input + <Input type="number" - className="input input-bordered" placeholder="Initial value" onClick={(e) => e.stopPropagation()} onChange={(e) => setInitialValueString(e.target.value)} @@ -446,19 +445,17 @@ export function NewContract(props: { className={'col-span-4 sm:col-span-2'} /> </Row> - <Row> - <input + <Row className="mt-4 gap-2"> + <Input type={'date'} - className="input input-bordered mt-4" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)} min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} disabled={isSubmitting} value={closeDate} /> - <input + <Input type={'time'} - className="input input-bordered mt-4 ml-2" onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseHoursMinutes(e.target.value)} min={'00:00'} diff --git a/web/pages/date-docs/create.tsx b/web/pages/date-docs/create.tsx index a0fe8922..639ed51c 100644 --- a/web/pages/date-docs/create.tsx +++ b/web/pages/date-docs/create.tsx @@ -1,7 +1,5 @@ import Router from 'next/router' import { useEffect, useState } from 'react' -import Textarea from 'react-expanding-textarea' - import { DateDoc } from 'common/post' import { useTextEditor, TextEditor } from 'web/components/editor' import { Page } from 'web/components/page' @@ -17,6 +15,8 @@ import { MAX_QUESTION_LENGTH } from 'common/contract' import { NoSEO } from 'web/components/NoSEO' import ShortToggle from 'web/components/widgets/short-toggle' import { removeUndefinedProps } from 'common/util/object' +import { Input } from 'web/components/input' +import { ExpandingInput } from 'web/components/expanding-input' export default function CreateDateDocPage() { const user = useUser() @@ -94,9 +94,8 @@ export default function CreateDateDocPage() { <Col className="gap-8"> <Col className="max-w-[160px] justify-start gap-4"> <div className="">Birthday</div> - <input + <Input type={'date'} - className="input input-bordered" onClick={(e) => e.stopPropagation()} onChange={(e) => setBirthday(e.target.value)} max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} @@ -122,8 +121,7 @@ export default function CreateDateDocPage() { </Row> <Col className="gap-2"> - <Textarea - className="input input-bordered resize-none" + <ExpandingInput maxLength={MAX_QUESTION_LENGTH} value={question} onChange={(e) => setQuestion(e.target.value || '')} diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index d5c73913..90781da9 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -20,6 +20,7 @@ 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' +import { Input } from 'web/components/input' export const getServerSideProps: GetServerSideProps = async (ctx) => { const creds = await authenticateOnServer(ctx) @@ -106,12 +107,12 @@ export default function Groups(props: { title: 'All', content: ( <Col> - <input + <Input type="text" onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search groups" value={query} - className="input input-bordered mb-4 w-full" + className="mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> @@ -134,12 +135,12 @@ export default function Groups(props: { title: 'My Groups', content: ( <Col> - <input + <Input type="text" value={query} onChange={(e) => debouncedQuery(e.target.value)} placeholder="Search your groups" - className="input input-bordered mb-4 w-full" + className="mb-4 w-full" /> <div className="flex flex-wrap justify-center gap-4"> diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx index 7b3f79d2..0b0a47c1 100644 --- a/web/pages/home/index.tsx +++ b/web/pages/home/index.tsx @@ -48,6 +48,7 @@ import { } from 'web/hooks/use-contracts' import { ProfitBadge } from 'web/components/profit-badge' import { LoadingIndicator } from 'web/components/loading-indicator' +import { Input } from 'web/components/input' export default function Home() { const user = useUser() @@ -99,10 +100,10 @@ export default function Home() { <Row className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'} > - <input + <Input type="text" placeholder={'Search'} - className="input input-bordered w-full" + className="w-full" onClick={() => Router.push('/search')} /> <CustomizeButton justIcon /> diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index b7607f8b..04878c6c 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -3,8 +3,9 @@ import { PrivateUser, User } from 'common/user' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' import Link from 'next/link' import React, { useState } from 'react' -import Textarea from 'react-expanding-textarea' import { ConfirmationButton } from 'web/components/confirmation-button' +import { ExpandingInput } from 'web/components/expanding-input' +import { Input } from 'web/components/input' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -43,16 +44,15 @@ function EditUserField(props: { <label className="label">{label}</label> {field === 'bio' ? ( - <Textarea - className="textarea textarea-bordered w-full resize-none" + <ExpandingInput + className="w-full" value={value} onChange={(e) => setValue(e.target.value)} onBlur={updateField} /> ) : ( - <input + <Input type="text" - className="input input-bordered" value={value} onChange={(e) => setValue(e.target.value || '')} onBlur={updateField} @@ -152,10 +152,9 @@ export default function ProfilePage(props: { <div> <label className="label">Display name</label> - <input + <Input type="text" placeholder="Display name" - className="input input-bordered" value={name} onChange={(e) => setName(e.target.value || '')} onBlur={updateDisplayName} @@ -164,10 +163,9 @@ export default function ProfilePage(props: { <div> <label className="label">Username</label> - <input + <Input type="text" placeholder="Username" - className="input input-bordered" value={username} onChange={(e) => setUsername(e.target.value || '')} onBlur={updateUsername} @@ -199,10 +197,9 @@ export default function ProfilePage(props: { <div> <label className="label">API key</label> <div className="input-group w-full"> - <input + <Input type="text" placeholder="Click refresh to generate key" - className="input input-bordered w-full" value={apiKey} readOnly /> From a31096395231e28a0af26e9589e69405c3837b94 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 07:01:44 -0600 Subject: [PATCH 05/22] update prefs safely --- .../add-new-notification-preference.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts index d7e7072b..a9d3baef 100644 --- a/functions/src/scripts/add-new-notification-preference.ts +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -1,25 +1,33 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { getAllPrivateUsers } from 'functions/src/utils' +import { filterDefined } from 'common/lib/util/array' +import { getPrivateUser } from '../utils' initAdmin() const firestore = admin.firestore() async function main() { - const privateUsers = await getAllPrivateUsers() + // const privateUsers = await getAllPrivateUsers() + const privateUsers = filterDefined([ + await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'), + ]) await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() - return firestore - .collection('private-users') - .doc(privateUser.id) - .update({ - notificationPreferences: { - ...privateUser.notificationPreferences, - opt_out_all: [], - }, - }) + if (privateUser.notificationPreferences.opt_out_all === undefined) { + console.log('updating opt out all', privateUser.id) + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + opt_out_all: [], + }, + }) + } + return }) ) } From dea65a4ba03a4ef0d756c991f4006ca1cbf2820c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 07:41:41 -0600 Subject: [PATCH 06/22] Better error handling for notification destinations --- common/user-notification-preferences.ts | 63 ++++++++++++++---------- functions/src/emails.ts | 56 +++++++-------------- functions/src/weekly-portfolio-emails.ts | 33 +++++-------- 3 files changed, 68 insertions(+), 84 deletions(-) diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index ae199e77..de01a6cb 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -178,31 +178,44 @@ export const getNotificationDestinationsForUser = ( reason: notification_reason_types | notification_preference ) => { const notificationSettings = privateUser.notificationPreferences - let destinations - let subscriptionType: notification_preference | undefined - if (Object.keys(notificationSettings).includes(reason)) { - subscriptionType = reason as notification_preference - destinations = notificationSettings[subscriptionType] - } else { - const key = reason as notification_reason_types - subscriptionType = notificationReasonToSubscriptionType[key] - destinations = subscriptionType - ? notificationSettings[subscriptionType] - : [] - } - const optOutOfAllSettings = notificationSettings['opt_out_all'] - // Your market closure notifications are high priority, opt-out doesn't affect their delivery - const optedOutOfEmail = - optOutOfAllSettings.includes('email') && - subscriptionType !== 'your_contract_closed' - const optedOutOfBrowser = - optOutOfAllSettings.includes('browser') && - subscriptionType !== 'your_contract_closed' const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - return { - sendToEmail: destinations.includes('email') && !optedOutOfEmail, - sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, - unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, - urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + try { + let destinations + let subscriptionType: notification_preference | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as notification_preference + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' + return { + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, + unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } + } catch (e) { + // Fail safely + console.log( + `couldn't get notification destinations for type ${reason} for user ${privateUser.id}` + ) + return { + sendToEmail: false, + sendToBrowser: false, + unsubscribeUrl: '', + urlToManageThisNotification: '', + } } } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 993fac81..31129b71 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { contractUrl, getUser } from './utils' +import { contractUrl, getUser, log } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' @@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, @@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async ( privateUser: PrivateUser, sendTime: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -279,22 +271,16 @@ export const sendThankYouEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.thank_you_for_purchases.includes( - 'email' - ) - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'thank_you_for_purchases' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Thanks for your Manifold purchase', @@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async ( contractsToSend: Contract[], deliveryTime?: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.trending_markets.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'trending_markets' ) + if (!sendToEmail) return const { name } = user const firstName = name.split(' ')[0] @@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async ( investments: PerContractInvestmentsData[], overallPerformance: OverallPerformanceData ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.profit_loss_updates.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'profit_loss_updates' ) + if (!sendToEmail) return + const { name } = user const firstName = name.split(' ')[0] const templateData: Record<string, string> = { @@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async ( : 'portfolio-update', templateData ) + log('Sent portfolio update email to', privateUser.email) } diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index bcf6da17..215694eb 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { ) ) ) - log('Found', contractsUsersBetOn.length, 'contracts') - let count = 0 await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) // Don't send to a user unless they're over 5 days old - if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) + return await setEmailFlagAsSent(privateUser.id) const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) @@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { (differences) => Math.abs(differences.profit) ).reverse() - log( - 'Found', - investmentValueDifferences.length, - 'investment differences for user', - privateUser.id - ) - const [winningInvestments, losingInvestments] = partition( investmentValueDifferences.filter( (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 @@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { usersToContractsCreated[privateUser.id].length === 0 ) { log( - 'No bets in last week, no market movers, no markets created. Not sending an email.' + `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .` ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - return + return await setEmailFlagAsSent(privateUser.id) } + // Set the flag beforehand just to be safe + await setEmailFlagAsSent(privateUser.id) await sendWeeklyPortfolioUpdateEmail( user, privateUser, topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], performanceData ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - log('Sent weekly portfolio update email to', privateUser.email) - count++ - log('sent out emails to users:', count) }) ) } +async function setEmailFlagAsSent(privateUserId: string) { + await firestore.collection('private-users').doc(privateUserId).update({ + weeklyPortfolioUpdateEmailSent: true, + }) +} + export type PerContractInvestmentsData = { questionTitle: string questionUrl: string From a143a96919400590b0464250f14602b4c56c94e7 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 09:24:37 -0600 Subject: [PATCH 07/22] Fix unable to close contract --- common/util/parse.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/common/util/parse.ts b/common/util/parse.ts index 7e3774c6..3cd53ef2 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -72,7 +72,6 @@ export const exhibitExts = [ Image, Link, - Mention, Mention.extend({ name: 'contract-mention' }), Iframe, TiptapTweet, From b3136ebcace0d9a0212f12e65c792ef1629add9a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 09:48:46 -0600 Subject: [PATCH 08/22] Update update-metrics timeout sends --- functions/src/update-metrics.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index e77ab71f..5127c889 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -25,9 +25,10 @@ import { newEndpointNoAuth } from './api' import { getFunctionUrl } from '../../common/api' const firestore = admin.firestore() - -export const scheduleUpdateMetrics = functions.pubsub - .schedule('every 15 minutes') +const TIMEOUT_SECONDS = 2000 +export const scheduleUpdateMetrics = functions + .runWith({ timeoutSeconds: TIMEOUT_SECONDS }) + .pubsub.schedule('every 15 minutes') .onRun(async () => { const response = await fetch(getFunctionUrl('updatemetrics'), { headers: { @@ -44,7 +45,7 @@ export const scheduleUpdateMetrics = functions.pubsub }) export const updatemetrics = newEndpointNoAuth( - { timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 }, + { timeoutSeconds: TIMEOUT_SECONDS, memory: '8GiB', minInstances: 0 }, async (_req) => { await updateMetricsCore() return { success: true } From 66071e16fa627ed5b7fe3334d221d73c2c52b7f9 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 09:53:42 -0600 Subject: [PATCH 09/22] Try without timeout seconds on pubsub --- functions/src/update-metrics.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 5127c889..19bd5640 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -25,10 +25,8 @@ import { newEndpointNoAuth } from './api' import { getFunctionUrl } from '../../common/api' const firestore = admin.firestore() -const TIMEOUT_SECONDS = 2000 -export const scheduleUpdateMetrics = functions - .runWith({ timeoutSeconds: TIMEOUT_SECONDS }) - .pubsub.schedule('every 15 minutes') +export const scheduleUpdateMetrics = functions.pubsub + .schedule('every 15 minutes') .onRun(async () => { const response = await fetch(getFunctionUrl('updatemetrics'), { headers: { @@ -45,7 +43,7 @@ export const scheduleUpdateMetrics = functions }) export const updatemetrics = newEndpointNoAuth( - { timeoutSeconds: TIMEOUT_SECONDS, memory: '8GiB', minInstances: 0 }, + { timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 }, async (_req) => { await updateMetricsCore() return { success: true } From a4699b79ed36d23e9d956f5544be92d1e1f8ef6a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 10:48:01 -0600 Subject: [PATCH 10/22] If unlisted during creation, fill in creator id --- common/new-contract.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/common/new-contract.ts b/common/new-contract.ts index 9a73e2ea..89afb0c0 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -63,6 +63,7 @@ export function getNewContract( tags: [], lowercaseTags: [], visibility, + unlistedById: visibility === 'unlisted' ? creator.id : undefined, isResolved: false, createdTime: Date.now(), closeTime, From b8ef272784fe8ae0a882b4f02a712a892365eb45 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 10 Oct 2022 13:01:57 -0500 Subject: [PATCH 11/22] withdrawal warning --- web/components/contract/liquidity-bounty-panel.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/components/contract/liquidity-bounty-panel.tsx b/web/components/contract/liquidity-bounty-panel.tsx index 347f41b3..81d3b27c 100644 --- a/web/components/contract/liquidity-bounty-panel.tsx +++ b/web/components/contract/liquidity-bounty-panel.tsx @@ -16,6 +16,8 @@ import { InfoTooltip } from 'web/components/info-tooltip' import { BETTORS, PRESENT_BET } from 'common/user' import { buildArray } from 'common/util/array' import { useAdmin } from 'web/hooks/use-admin' +import { AlertBox } from '../alert-box' +import { Spacer } from '../layout/spacer' export function LiquidityBountyPanel(props: { contract: Contract }) { const { contract } = props @@ -115,7 +117,7 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { <div className="mb-4 text-gray-500"> Contribute your M$ to make this market more accurate.{' '} <InfoTooltip - text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`} + text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}.`} /> </div> @@ -142,6 +144,12 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { )} {isLoading && <div>Processing...</div>} + + <Spacer h={2} /> + <AlertBox + title="Withdrawals ending" + text="Manifold is moving to a new system for handling subsidization. As part of this process, liquidity withdrawals will be disabled shortly. Feel free to withdraw any outstanding liquidity you've added now." + /> </> ) } From f6fd70300574371819bbf7295081ac6c83728f5c Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 10 Oct 2022 13:05:17 -0500 Subject: [PATCH 12/22] Store each user's contract bet metrics (#1017) * Implement most of caching metrics per user per contract * Small group updates refactor * Write contract-metrics subcollection * Fix type error --- common/calculate-metrics.ts | 82 ++++++++++++++++++++++++++-- functions/src/update-metrics.ts | 97 ++++++++++++++++++--------------- 2 files changed, 129 insertions(+), 50 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 9ad44522..0302aa31 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,7 +1,12 @@ -import { last, sortBy, sum, sumBy, uniq } from 'lodash' -import { calculatePayout } from './calculate' +import { Dictionary, groupBy, last, sortBy, sum, sumBy, uniq } from 'lodash' +import { calculatePayout, getContractBetMetrics } from './calculate' import { Bet, LimitBet } from './bet' -import { Contract, CPMMContract, DPMContract } from './contract' +import { + Contract, + CPMMBinaryContract, + CPMMContract, + DPMContract, +} from './contract' import { PortfolioMetrics, User } from './user' import { DAY_MS } from './util/time' import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet' @@ -35,8 +40,7 @@ export const computeInvestmentValueCustomProb = ( const betP = outcome === 'YES' ? p : 1 - p - const payout = betP * shares - const value = payout - (bet.loanAmount ?? 0) + const value = betP * shares if (isNaN(value)) return 0 return value }) @@ -246,3 +250,71 @@ export const calculateNewProfit = ( return newProfit } + +export const calculateMetricsByContract = ( + bets: Bet[], + contractsById: Dictionary<Contract> +) => { + const betsByContract = groupBy(bets, (bet) => bet.contractId) + const unresolvedContracts = Object.keys(betsByContract) + .map((cid) => contractsById[cid]) + .filter((c) => c && !c.isResolved) + + return unresolvedContracts.map((c) => { + const bets = betsByContract[c.id] ?? [] + const current = getContractBetMetrics(c, bets) + + let periodMetrics + if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') { + const periods = ['day', 'week', 'month'] as const + periodMetrics = Object.fromEntries( + periods.map((period) => [ + period, + calculatePeriodProfit(c, bets, period), + ]) + ) + } + + return { + contractId: c.id, + ...current, + from: periodMetrics, + } + }) +} + +const calculatePeriodProfit = ( + contract: CPMMBinaryContract, + bets: Bet[], + period: 'day' | 'week' | 'month' +) => { + const days = period === 'day' ? 1 : period === 'week' ? 7 : 30 + const fromTime = Date.now() - days * DAY_MS + const previousBets = bets.filter((b) => b.createdTime < fromTime) + + const prevProb = contract.prob - contract.probChanges[period] + const prob = contract.resolutionProbability + ? contract.resolutionProbability + : contract.prob + + const previousBetsValue = computeInvestmentValueCustomProb( + previousBets, + contract, + prevProb + ) + const currentBetsValue = computeInvestmentValueCustomProb( + previousBets, + contract, + prob + ) + const profit = currentBetsValue - previousBetsValue + const profitPercent = + previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue) + + return { + profit, + profitPercent, + prevValue: previousBetsValue, + value: currentBetsValue, + } +} diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 19bd5640..39faadbf 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' +import { groupBy, keyBy, last, sortBy } from 'lodash' import fetch from 'node-fetch' import { getValues, log, logMemory, writeAsync } from './utils' @@ -15,6 +15,7 @@ import { calculateNewPortfolioMetrics, calculateNewProfit, calculateProbChanges, + calculateMetricsByContract, computeElasticity, computeVolume, } from '../../common/calculate-metrics' @@ -23,6 +24,7 @@ import { Group } from '../../common/group' import { batchedWaitAll } from '../../common/util/promise' import { newEndpointNoAuth } from './api' import { getFunctionUrl } from '../../common/api' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() export const scheduleUpdateMetrics = functions.pubsub @@ -159,6 +161,12 @@ export async function updateMetricsCore() { lastPortfolio.investmentValue !== newPortfolio.investmentValue const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + + const metricsByContract = calculateMetricsByContract( + currentBets, + contractsById + ) + const contractRatios = userContracts .map((contract) => { if ( @@ -189,6 +197,7 @@ export async function updateMetricsCore() { newProfit, didPortfolioChange, newFractionResolvedCorrectly, + metricsByContract, } }) @@ -204,63 +213,61 @@ export async function updateMetricsCore() { const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const userUpdates = userMetrics.map( - ({ - user, - newCreatorVolume, - newPortfolio, - newProfit, - didPortfolioChange, - newFractionResolvedCorrectly, - }) => { + ({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { - fieldUpdates: { - doc: firestore.collection('users').doc(user.id), - fields: { - creatorVolumeCached: newCreatorVolume, - profitCached: newProfit, - nextLoanCached, - fractionResolvedCorrectly: newFractionResolvedCorrectly, - }, - }, - - subcollectionUpdates: { - doc: firestore - .collection('users') - .doc(user.id) - .collection('portfolioHistory') - .doc(), - fields: didPortfolioChange ? newPortfolio : {}, + doc: firestore.collection('users').doc(user.id), + fields: { + creatorVolumeCached: newCreatorVolume, + profitCached: newProfit, + nextLoanCached, + fractionResolvedCorrectly: newFractionResolvedCorrectly, }, } } ) - await writeAsync( - firestore, - userUpdates.map((u) => u.fieldUpdates) + await writeAsync(firestore, userUpdates) + + const portfolioHistoryUpdates = filterDefined( + userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => { + return didPortfolioChange + ? { + doc: firestore + .collection('users') + .doc(user.id) + .collection('portfolioHistory') + .doc(), + fields: newPortfolio, + } + : null + }) ) - await writeAsync( - firestore, - userUpdates - .filter((u) => !isEmpty(u.subcollectionUpdates.fields)) - .map((u) => u.subcollectionUpdates), - 'set' + await writeAsync(firestore, portfolioHistoryUpdates, 'set') + + const contractMetricsUpdates = userMetrics.flatMap( + ({ user, metricsByContract }) => { + const collection = firestore + .collection('users') + .doc(user.id) + .collection('contract-metrics') + return metricsByContract.map((metrics) => ({ + doc: collection.doc(metrics.contractId), + fields: metrics, + })) + } ) + + await writeAsync(firestore, contractMetricsUpdates, 'set') + log(`Updated metrics for ${users.length} users.`) try { const groupUpdates = groups.map((group, index) => { const groupContractIds = contractsByGroup[index] as GroupContractDoc[] - const groupContracts = groupContractIds - .map((e) => contractsById[e.contractId]) - .filter((e) => e !== undefined) as Contract[] - const bets = groupContracts.map((e) => { - if (e != null && e.id in betsByContract) { - return betsByContract[e.id] ?? [] - } else { - return [] - } - }) + const groupContracts = filterDefined( + groupContractIds.map((e) => contractsById[e.contractId]) + ) + const bets = groupContracts.map((e) => betsByContract[e.id] ?? []) const creatorScores = scoreCreators(groupContracts) const traderScores = scoreTraders(groupContracts, bets) From 84f79ffe7cbd1939703551cc4e4ebcf43910f34e Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 10 Oct 2022 13:34:02 -0500 Subject: [PATCH 13/22] Remove undefined props --- common/calculate-metrics.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 0302aa31..9cd1a57a 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -11,6 +11,7 @@ import { PortfolioMetrics, User } from './user' import { DAY_MS } from './util/time' import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet' import { getCpmmProbability } from './calculate-cpmm' +import { removeUndefinedProps } from './util/object' const computeInvestmentValue = ( bets: Bet[], @@ -275,11 +276,11 @@ export const calculateMetricsByContract = ( ) } - return { + return removeUndefinedProps({ contractId: c.id, ...current, from: periodMetrics, - } + }) }) } From 5d561acdf804187e8905d2d745e09aa80cc1e7d6 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 10 Oct 2022 14:23:16 -0500 Subject: [PATCH 14/22] Fix NaN --- common/calculate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/calculate.ts b/common/calculate.ts index 44dc9113..47fee8c6 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -215,7 +215,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { } const profit = payout + saleValue + redeemed - totalInvested - const profitPercent = (profit / totalInvested) * 100 + const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100 const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets) const hasShares = Object.values(totalShares).some( From cdc64c647525ae909d7e6b5794cd72e493e82147 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 10 Oct 2022 14:55:33 -0500 Subject: [PATCH 15/22] Correctly configure env var for firebase functions --- functions/.env.dev | 3 +++ functions/{.env => .env.prod} | 0 functions/package.json | 2 +- functions/src/update-metrics.ts | 4 +++- 4 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 functions/.env.dev rename functions/{.env => .env.prod} (100%) diff --git a/functions/.env.dev b/functions/.env.dev new file mode 100644 index 00000000..b5aae225 --- /dev/null +++ b/functions/.env.dev @@ -0,0 +1,3 @@ +# This sets which EnvConfig is deployed to Firebase Cloud Functions + +NEXT_PUBLIC_FIREBASE_ENV=DEV diff --git a/functions/.env b/functions/.env.prod similarity index 100% rename from functions/.env rename to functions/.env.prod diff --git a/functions/package.json b/functions/package.json index 0397c5db..cd2a9ec5 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist", "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 39faadbf..5dfb1eeb 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -30,7 +30,9 @@ const firestore = admin.firestore() export const scheduleUpdateMetrics = functions.pubsub .schedule('every 15 minutes') .onRun(async () => { - const response = await fetch(getFunctionUrl('updatemetrics'), { + const url = getFunctionUrl('updatemetrics') + console.log('Scheduling update metrics', url) + const response = await fetch(url, { headers: { 'Content-Type': 'application/json', }, From f26ba1c4a292a1383fc2ca9742d6e39171161507 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 14:32:29 -0600 Subject: [PATCH 16/22] Award badges for market creation, betting streaks, proven correct (#891) * Award badges for market creation, betting streaks, proven correct * Styling * Add minimum unique bettors for proven correct * Add name, refactor * Add notifications for badge awards * Correct styling * Need at least 3 unique bettors for market maker badge * Lint * Switch to badges_awarded * Don't include n/a resolutions in market creator badge * Add badges by rarities to profile * Show badges on profile, soon on market page * Add achievements to new user * Backfill all users badges --- common/badge.ts | 123 ++++++++++ common/notification.ts | 7 +- common/scoring.ts | 56 ++++- common/user-notification-preferences.ts | 3 +- common/user.ts | 18 +- functions/src/create-notification.ts | 49 +++- functions/src/create-user.ts | 1 + functions/src/on-create-bet.ts | 45 +++- functions/src/on-create-contract.ts | 52 +++- functions/src/on-update-contract.ts | 75 +++++- .../add-new-notification-preference.ts | 13 +- functions/src/scripts/backfill-badges.ts | 101 ++++++++ functions/src/utils.ts | 6 + web/components/badge-display.tsx | 62 +++++ web/components/contract/contract-details.tsx | 19 +- .../contract/contract-leaderboard.tsx | 59 ++--- web/components/notification-settings.tsx | 1 + web/components/profile/badges-modal.tsx | 223 ++++++++++++++++++ .../profile/betting-streak-modal.tsx | 2 +- web/components/user-page.tsx | 44 +--- web/pages/[username]/[contractSlug].tsx | 6 +- web/pages/notifications.tsx | 126 ++++++++-- web/public/award.svg | 28 +++ 23 files changed, 990 insertions(+), 129 deletions(-) create mode 100644 common/badge.ts create mode 100644 functions/src/scripts/backfill-badges.ts create mode 100644 web/components/badge-display.tsx create mode 100644 web/components/profile/badges-modal.tsx create mode 100644 web/public/award.svg diff --git a/common/badge.ts b/common/badge.ts new file mode 100644 index 00000000..c20b1f03 --- /dev/null +++ b/common/badge.ts @@ -0,0 +1,123 @@ +import { User } from './user' + +export type Badge = { + type: BadgeTypes + createdTime: number + data: { [key: string]: any } + name: 'Proven Correct' | 'Streaker' | 'Market Creator' +} + +export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR' + +export type ProvenCorrectBadgeData = { + type: 'PROVEN_CORRECT' + data: { + contractSlug: string + contractCreatorUsername: string + contractTitle: string + commentId: string + betAmount: number + } +} + +export type MarketCreatorBadgeData = { + type: 'MARKET_CREATOR' + data: { + totalContractsCreated: number + } +} + +export type StreakerBadgeData = { + type: 'STREAKER' + data: { + totalBettingStreak: number + } +} + +export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData +export type StreakerBadge = Badge & StreakerBadgeData +export type MarketCreatorBadge = Badge & MarketCreatorBadgeData + +export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5 +export const provenCorrectRarityThresholds = [1, 1000, 10000] +const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => { + const { betAmount } = badge.data + const thresholdArray = provenCorrectRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (betAmount >= thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const streakerBadgeRarityThresholds = [1, 50, 250] +const calculateStreakerBadgeRarity = (badge: StreakerBadge) => { + const { totalBettingStreak } = badge.data + const thresholdArray = streakerBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalBettingStreak == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const marketCreatorBadgeRarityThresholds = [1, 75, 300] +const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => { + const { totalContractsCreated } = badge.data + const thresholdArray = marketCreatorBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalContractsCreated == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export type rarities = 'bronze' | 'silver' | 'gold' + +const rarityRanks: { [key: number]: rarities } = { + 1: 'bronze', + 2: 'silver', + 3: 'gold', +} + +export const calculateBadgeRarity = (badge: Badge) => { + switch (badge.type) { + case 'PROVEN_CORRECT': + return rarityRanks[ + calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge) + ] + case 'MARKET_CREATOR': + return rarityRanks[ + calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge) + ] + case 'STREAKER': + return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)] + default: + return rarityRanks[0] + } +} + +export const getBadgesByRarity = (user: User | null | undefined) => { + const rarities: { [key in rarities]: number } = { + bronze: 0, + silver: 0, + gold: 0, + } + if (!user) return rarities + Object.values(user.achievements).map((value) => { + value.badges.map((badge) => { + rarities[calculateBadgeRarity(badge)] = + (rarities[calculateBadgeRarity(badge)] ?? 0) + 1 + }) + }) + return rarities +} diff --git a/common/notification.ts b/common/notification.ts index b75e3d4a..436393a5 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -4,7 +4,7 @@ export type Notification = { id: string userId: string reasonText?: string - reason?: notification_reason_types + reason?: notification_reason_types | notification_preference createdTime: number viewTime?: number isSeen: boolean @@ -46,6 +46,7 @@ export type notification_source_types = | 'loan' | 'like' | 'tip_and_like' + | 'badge' export type notification_source_update_types = | 'created' @@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { simple: `Only on markets you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`, }, + badges_awarded: { + simple: 'New badges awarded', + detailed: 'New badges you have earned', + }, opt_out_all: { simple: 'Opt out of all notifications (excludes when your markets close)', detailed: diff --git a/common/scoring.ts b/common/scoring.ts index 4ef46534..a8f62631 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -1,8 +1,9 @@ -import { groupBy, sumBy, mapValues } from 'lodash' +import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash' import { Bet } from './bet' -import { getContractBetMetrics } from './calculate' +import { getContractBetMetrics, resolvedPayout } from './calculate' import { Contract } from './contract' +import { ContractComment } from './comment' export function scoreCreators(contracts: Contract[]) { const creatorScore = mapValues( @@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) { } export function scoreUsersByContract(contract: Contract, bets: Bet[]) { - const betsByUser = groupBy(bets, bet => bet.userId) - return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit) + const betsByUser = groupBy(bets, (bet) => bet.userId) + return mapValues( + betsByUser, + (bets) => getContractBetMetrics(contract, bets).profit + ) } export function addUserScores( @@ -43,3 +47,47 @@ export function addUserScores( dest[userId] += score } } + +export function scoreCommentorsAndBettors( + contract: Contract, + bets: Bet[], + comments: ContractComment[] +) { + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record<string, number> = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = betsById[topBetId]?.userName + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + const topCommentBetId = commentsById[topCommentId]?.betId + + return { + topCommentId, + topBetId, + topBettor, + profitById, + commentsById, + betsById, + topCommentBetId, + } +} diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index de01a6cb..6b5a448d 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -53,7 +53,7 @@ export type notification_preferences = { profit_loss_updates: notification_destination_types[] onboarding_flow: notification_destination_types[] thank_you_for_purchases: notification_destination_types[] - + badges_awarded: notification_destination_types[] opt_out_all: notification_destination_types[] // When adding a new notification preference, use add-new-notification-preference.ts to existing users } @@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = ( onboarding_flow: constructPref(false, false), opt_out_all: [], + badges_awarded: constructPref(true, false), } return defaults } diff --git a/common/user.ts b/common/user.ts index 233fe4cc..f00dfc89 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,5 +1,6 @@ import { notification_preferences } from './user-notification-preferences' -import { ENV_CONFIG } from 'common/envs/constants' +import { ENV_CONFIG } from './envs/constants' +import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge' export type User = { id: string @@ -51,6 +52,18 @@ export type User = { hasSeenContractFollowModal?: boolean freeMarketsCreated?: number isBannedFromPosting?: boolean + + achievements: { + provenCorrect?: { + badges: ProvenCorrectBadge[] + } + marketCreator?: { + badges: MarketCreatorBadge[] + } + streaker?: { + badges: StreakerBadge[] + } + } } export type PrivateUser = { @@ -81,7 +94,8 @@ export type PortfolioMetrics = { userId: string } -export const MANIFOLD_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_USER_NAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' // TODO: remove. Hardcoding the strings would be better. diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 9bd73d05..a0134634 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -6,7 +6,12 @@ import { Notification, notification_reason_types, } from '../../common/notification' -import { User } from '../../common/user' +import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USER_NAME, + MANIFOLD_USER_USERNAME, + User, +} from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' @@ -30,6 +35,7 @@ import { import { filterDefined } from '../../common/util/array' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { ContractFollow } from '../../common/follow' +import { Badge } from 'common/badge' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -1087,6 +1093,43 @@ export const createBountyNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) - - // maybe TODO: send email notification to comment creator +} + +export const createBadgeAwardedNotification = async ( + user: User, + badge: Badge +) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'badges_awarded' + ) + if (!sendToBrowser) return + + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc() + const notification: Notification = { + id: notificationRef.id, + userId: user.id, + reason: 'badges_awarded', + createdTime: Date.now(), + isSeen: false, + sourceId: badge.type, + sourceType: 'badge', + sourceUpdateType: 'created', + sourceUserName: MANIFOLD_USER_NAME, + sourceUserUsername: MANIFOLD_USER_USERNAME, + sourceUserAvatarUrl: MANIFOLD_AVATAR_URL, + sourceText: `You earned a new ${badge.name} badge!`, + sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`, + sourceTitle: badge.name, + data: { + badge, + }, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index c3b7ba1d..d22b8a2e 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { followedCategories: DEFAULT_CATEGORIES, shouldShowWelcome: true, fractionResolvedCorrectly: 1, + achievements: {}, } await firestore.collection('users').doc(auth.uid).create(user) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index b2451c62..7496db03 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -12,6 +12,7 @@ import { revalidateStaticProps, } from './utils' import { + createBadgeAwardedNotification, createBetFillNotification, createBettingStreakBonusNotification, createUniqueBettorBonusNotification, @@ -33,6 +34,10 @@ import { APIError } from '../../common/api' import { User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' +import { + StreakerBadge, + streakerBadgeRarityThresholds, +} from '../../common/badge' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -143,7 +148,7 @@ const updateBettingStreak = async ( log('message:', result.message) return } - if (result.txn) + if (result.txn) { await createBettingStreakBonusNotification( user, result.txn.id, @@ -153,6 +158,8 @@ const updateBettingStreak = async ( newBettingStreak, eventId ) + await handleBettingStreakBadgeAward(user, newBettingStreak) + } } const updateUniqueBettorsAndGiveCreatorBonus = async ( @@ -296,3 +303,39 @@ const notifyFills = async ( const currentDateBettingStreakResetTime = () => { return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) } + +async function handleBettingStreakBadgeAward( + user: User, + newBettingStreak: number +) { + const alreadyHasBadgeForFirstStreak = + user.achievements?.streaker?.badges.some( + (badge) => badge.data.totalBettingStreak === 1 + ) + + if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return + + if (newBettingStreak in streakerBadgeRarityThresholds) { + const badge = { + type: 'STREAKER', + name: 'Streaker', + data: { + totalBettingStreak: newBettingStreak, + }, + createdTime: Date.now(), + } as StreakerBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + streaker: { + badges: [...(user.achievements?.streaker?.badges ?? []), badge], + }, + }, + }) + await createBadgeAwardedNotification(user, badge) + } +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index b613142b..13a84575 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,11 +1,20 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createNewContractNotification } from './create-notification' +import { getUser, getValues } from './utils' +import { + createBadgeAwardedNotification, + createNewContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' import { addUserToContractFollowers } from './follow-market' +import { User } from '../../common/user' +import * as admin from 'firebase-admin' +import { + MarketCreatorBadge, + marketCreatorBadgeRarityThresholds, +} from '../../common/badge' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -28,4 +37,43 @@ export const onCreateContract = functions richTextToString(desc), mentioned ) + await handleMarketCreatorBadgeAward(contractCreator) }) + +const firestore = admin.firestore() + +async function handleMarketCreatorBadgeAward(contractCreator: User) { + // get all contracts by user and calculate size of array + const contracts = await getValues<Contract>( + firestore + .collection(`contracts`) + .where('creatorId', '==', contractCreator.id) + .where('resolution', '!=', 'CANCEL') + ) + if (contracts.length in marketCreatorBadgeRarityThresholds) { + const badge = { + type: 'MARKET_CREATOR', + name: 'Market Creator', + data: { + totalContractsCreated: contracts.length, + }, + createdTime: Date.now(), + } as MarketCreatorBadge + // update user + await firestore + .collection('users') + .doc(contractCreator.id) + .update({ + achievements: { + ...contractCreator.achievements, + marketCreator: { + badges: [ + ...(contractCreator.achievements?.marketCreator?.badges ?? []), + badge, + ], + }, + }, + }) + await createBadgeAwardedNotification(contractCreator, badge) + } +} diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 1e3418fa..f0aa0252 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,9 +1,19 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { getUser, getValues } from './utils' +import { + createBadgeAwardedNotification, + createCommentOrAnswerOrUpdatedContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' -import { GroupContractDoc } from '../../common/group' +import { Bet } from '../../common/bet' import * as admin from 'firebase-admin' +import { ContractComment } from '../../common/comment' +import { scoreCommentorsAndBettors } from '../../common/scoring' +import { + MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE, + ProvenCorrectBadge, +} from '../../common/badge' +import { GroupContractDoc } from '../../common/group' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') @@ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore if (!previousContract.isResolved && contract.isResolved) { // No need to notify users of resolution, that's handled in resolve-market - return + return await handleResolvedContract(contract) } else if (previousContract.groupSlugs !== contract.groupSlugs) { await handleContractGroupUpdated(previousContract, contract) } else if ( @@ -26,6 +36,63 @@ export const onUpdateContract = functions.firestore } }) +async function handleResolvedContract(contract: Contract) { + if ( + (contract.uniqueBettorCount ?? 0) < + MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE + ) + return + + // get all bets on this contract + const bets = await getValues<Bet>( + firestore.collection(`contracts/${contract.id}/bets`) + ) + + // get comments on this contract + const comments = await getValues<ContractComment>( + firestore.collection(`contracts/${contract.id}/comments`) + ) + + const { topCommentId, profitById, commentsById, betsById, topCommentBetId } = + scoreCommentorsAndBettors(contract, bets, comments) + if (topCommentBetId && profitById[topCommentBetId] > 0) { + // award proven correct badge to user + const comment = commentsById[topCommentId] + const bet = betsById[topCommentBetId] + + const user = await getUser(comment.userId) + if (!user) return + const newProvenCorrectBadge = { + createdTime: Date.now(), + type: 'PROVEN_CORRECT', + name: 'Proven Correct', + data: { + contractSlug: contract.slug, + contractCreatorUsername: contract.creatorUsername, + commentId: comment.id, + betAmount: bet.amount, + contractTitle: contract.question, + }, + } as ProvenCorrectBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + provenCorrect: { + badges: [ + ...(user.achievements?.provenCorrect?.badges ?? []), + newProvenCorrectBadge, + ], + }, + }, + }) + await createBadgeAwardedNotification(user, newProvenCorrectBadge) + } +} + async function handleUpdatedCloseTime( previousContract: Contract, contract: Contract, diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts index a9d3baef..f72692f7 100644 --- a/functions/src/scripts/add-new-notification-preference.ts +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -1,29 +1,24 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { filterDefined } from 'common/lib/util/array' -import { getPrivateUser } from '../utils' +import { getAllPrivateUsers } from 'functions/src/utils' initAdmin() const firestore = admin.firestore() async function main() { - // const privateUsers = await getAllPrivateUsers() - const privateUsers = filterDefined([ - await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'), - ]) + const privateUsers = await getAllPrivateUsers() await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() - if (privateUser.notificationPreferences.opt_out_all === undefined) { - console.log('updating opt out all', privateUser.id) + if (privateUser.notificationPreferences.badges_awarded === undefined) { return firestore .collection('private-users') .doc(privateUser.id) .update({ notificationPreferences: { ...privateUser.notificationPreferences, - opt_out_all: [], + badges_awarded: ['browser'], }, }) } diff --git a/functions/src/scripts/backfill-badges.ts b/functions/src/scripts/backfill-badges.ts new file mode 100644 index 00000000..648467cf --- /dev/null +++ b/functions/src/scripts/backfill-badges.ts @@ -0,0 +1,101 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllUsers, getValues } from '../utils' +import { Contract } from 'common/contract' +import { + MarketCreatorBadge, + marketCreatorBadgeRarityThresholds, + StreakerBadge, + streakerBadgeRarityThresholds, +} from 'common/badge' +import { User } from 'common/user' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const users = await getAllUsers() + // const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian + // const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian + await Promise.all( + users.map(async (user) => { + // console.log('Added achievements to user', user.id) + if (!user.id) return + if (user.achievements === undefined) { + await firestore.collection('users').doc(user.id).update({ + achievements: {}, + }) + user.achievements = {} + } + user.achievements = await awardMarketCreatorBadges(user) + user.achievements = await awardBettingStreakBadges(user) + // going to ignore backfilling the proven correct badges for now + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) + +async function awardMarketCreatorBadges(user: User) { + // Award market maker badges + const contracts = await getValues<Contract>( + firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('resolution', '!=', 'CANCEL') + ) + + const achievements = { + ...user.achievements, + marketCreator: { + badges: [...(user.achievements.marketCreator?.badges ?? [])], + }, + } + for (const threshold of marketCreatorBadgeRarityThresholds) { + if (contracts.length >= threshold) { + const badge = { + type: 'MARKET_CREATOR', + name: 'Market Creator', + data: { + totalContractsCreated: threshold, + }, + createdTime: Date.now(), + } as MarketCreatorBadge + achievements.marketCreator.badges.push(badge) + } + } + // update user + await firestore.collection('users').doc(user.id).update({ + achievements, + }) + return achievements +} + +async function awardBettingStreakBadges(user: User) { + const streak = user.currentBettingStreak ?? 0 + const achievements = { + ...user.achievements, + streaker: { + badges: [...(user.achievements?.streaker?.badges ?? [])], + }, + } + for (const threshold of streakerBadgeRarityThresholds) { + if (streak >= threshold) { + const badge = { + type: 'STREAKER', + name: 'Streaker', + data: { + totalBettingStreak: threshold, + }, + createdTime: Date.now(), + } as StreakerBadge + achievements.streaker.badges.push(badge) + } + } + // update user + await firestore.collection('users').doc(user.id).update({ + achievements, + }) + return achievements +} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 91f4b293..e0cd269a 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => { return users.docs.map((doc) => doc.data() as PrivateUser) } +export const getAllUsers = async () => { + const firestore = admin.firestore() + const users = await firestore.collection('users').get() + return users.docs.map((doc) => doc.data() as User) +} + export const getUserByUsername = async (username: string) => { const firestore = admin.firestore() const snap = await firestore diff --git a/web/components/badge-display.tsx b/web/components/badge-display.tsx new file mode 100644 index 00000000..983956cd --- /dev/null +++ b/web/components/badge-display.tsx @@ -0,0 +1,62 @@ +import { User } from 'common/user' +import { NextRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { getBadgesByRarity } from 'common/badge' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { BadgesModal } from 'web/components/profile/badges-modal' + +export const goldClassName = 'text-amber-400' +export const silverClassName = 'text-gray-500' +export const bronzeClassName = 'text-amber-900' + +export function BadgeDisplay(props: { + user: User | undefined | null + router?: NextRouter +}) { + const { user, router } = props + const [showBadgesModal, setShowBadgesModal] = useState(false) + + useEffect(() => { + if (!router) return + const showBadgesModal = router.query['show'] === 'badges' + setShowBadgesModal(showBadgesModal) + }, [router]) + // get number of badges of each rarity type + const badgesByRarity = getBadgesByRarity(user) + const badgesByRarityItems = Object.entries(badgesByRarity).map( + ([rarity, numBadges]) => { + return ( + <Row + key={rarity} + className={clsx( + 'items-center gap-2', + rarity === 'bronze' + ? bronzeClassName + : rarity === 'silver' + ? silverClassName + : goldClassName + )} + > + <span className={clsx('-m-0.5 text-lg')}>•</span> + <span className="text-xs">{numBadges}</span> + </Row> + ) + } + ) + return ( + <Row + className={'cursor-pointer gap-2'} + onClick={() => setShowBadgesModal(true)} + > + {badgesByRarityItems} + {user && ( + <BadgesModal + isOpen={showBadgesModal} + setOpen={setShowBadgesModal} + user={user} + /> + )} + </Row> + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index d2734ab5..ed7374b1 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -154,8 +154,8 @@ export function MarketSubheader(props: { const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract const { resolvedDate } = contractMetrics(contract) const user = useUser() - const correctResolutionPercentage = - useUserById(creatorId)?.fractionResolvedCorrectly + const creator = useUserById(creatorId) + const correctResolutionPercentage = creator?.fractionResolvedCorrectly const isCreator = user?.id === creatorId const isMobile = useIsMobile() return ( @@ -178,12 +178,15 @@ export function MarketSubheader(props: { {disabled ? ( creatorName ) : ( - <UserLink - className="my-auto whitespace-nowrap" - name={creatorName} - username={creatorUsername} - short={isMobile} - /> + <Row className={'gap-2'}> + <UserLink + className="my-auto whitespace-nowrap" + name={creatorName} + username={creatorUsername} + short={isMobile} + /> + {/*<BadgeDisplay user={creator} />*/} + </Row> )} {correctResolutionPercentage != null && correctResolutionPercentage < BAD_CREATOR_THRESHOLD && ( diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index f984e3b6..b4669156 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -2,15 +2,17 @@ import { Bet } from 'common/bet' import { resolvedPayout } from 'common/calculate' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' -import { memo } from 'react' -import { useComments } from 'web/hooks/use-comments' + +import { groupBy, mapValues, sumBy } from 'lodash' import { FeedBet } from '../feed/feed-bets' import { FeedComment } from '../feed/feed-comments' import { Spacer } from '../layout/spacer' import { Leaderboard } from '../leaderboard' import { Title } from '../title' import { BETTORS } from 'common/user' +import { scoreCommentorsAndBettors } from 'common/scoring' +import { ContractComment } from 'common/comment' +import { memo } from 'react' export const ContractLeaderboard = memo(function ContractLeaderboard(props: { contract: Contract @@ -50,47 +52,38 @@ export const ContractLeaderboard = memo(function ContractLeaderboard(props: { ) : null }) -export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) { - const { contract, bets } = props - // todo: this stuff should be calced in DB at resolve time - const comments = useComments(contract.id) - const betsById = keyBy(bets, 'id') - - // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit - // Otherwise, we record the profit at resolution time - const profitById: Record<string, number> = {} - for (const bet of bets) { - if (bet.sale) { - const originalBet = betsById[bet.sale.betId] - const profit = bet.sale.amount - originalBet.amount - profitById[bet.id] = profit - profitById[originalBet.id] = profit - } else { - profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount - } - } - - // Now find the betId with the highest profit - const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id - const topBettor = betsById[topBetId]?.userName - - // And also the comment with the highest profit - const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0] - +export function ContractTopTrades(props: { + contract: Contract + bets: Bet[] + comments: ContractComment[] +}) { + const { contract, bets, comments } = props + const { + topBetId, + topBettor, + profitById, + betsById, + topCommentId, + commentsById, + topCommentBetId, + } = scoreCommentorsAndBettors(contract, bets, comments) return ( <div className="mt-12 max-w-sm"> - {topComment && profitById[topComment.id] > 0 && ( + {topCommentBetId && profitById[topCommentBetId] > 0 && ( <> <Title text="💬 Proven correct" className="!mt-0" /> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> - <FeedComment contract={contract} comment={topComment} /> + <FeedComment + contract={contract} + comment={commentsById[topCommentId]} + /> </div> <Spacer h={16} /> </> )} {/* If they're the same, only show the comment; otherwise show both */} - {topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && ( + {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( <> <Title text="💸 Best bet" className="!mt-0" /> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index ad9adbdf..166653e2 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -131,6 +131,7 @@ export function NotificationSettings(props: { 'betting_streaks', 'referral_bonuses', 'unique_bettors_on_your_contract', + 'badges_awarded', ], } const otherBalances: SectionData = { diff --git a/web/components/profile/badges-modal.tsx b/web/components/profile/badges-modal.tsx new file mode 100644 index 00000000..96e0fa9d --- /dev/null +++ b/web/components/profile/badges-modal.tsx @@ -0,0 +1,223 @@ +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { PAST_BETS, User } from 'common/user' +import clsx from 'clsx' +import { + Badge, + calculateBadgeRarity, + MarketCreatorBadge, + ProvenCorrectBadge, + rarities, + StreakerBadge, +} from 'common/badge' +import { groupBy } from 'lodash' +import { Row } from 'web/components/layout/row' +import { SiteLink } from 'web/components/site-link' +import { contractPathWithoutContract } from 'web/lib/firebase/contracts' +import { Tooltip } from 'web/components/tooltip' +import { + bronzeClassName, + goldClassName, + silverClassName, +} from 'web/components/badge-display' + +export function BadgesModal(props: { + isOpen: boolean + setOpen: (open: boolean) => void + user: User +}) { + const { isOpen, setOpen, user } = props + const { provenCorrect, marketCreator, streaker } = user.achievements ?? {} + const badges = [ + ...(provenCorrect?.badges ?? []), + ...(streaker?.badges ?? []), + ...(marketCreator?.badges ?? []), + ] + + // group badges by their rarities + const badgesByRarity = groupBy(badges, (badge) => calculateBadgeRarity(badge)) + + return ( + <Modal open={isOpen} setOpen={setOpen}> + <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> + <span className={clsx('text-8xl')}>🏅</span> + <span className="text-xl">{user.name + "'s"} badges</span> + + <Row className={'flex-wrap gap-2'}> + <Col + className={clsx( + 'min-w-full gap-2 rounded-md border-2 border-amber-900 border-opacity-40 p-2 text-center' + )} + > + <span className={clsx(' ', bronzeClassName)}>Bronze</span> + <Row className={'flex-wrap justify-center gap-4'}> + {badgesByRarity['bronze'] ? ( + badgesByRarity['bronze'].map((badge, i) => ( + <BadgeToItem badge={badge} key={i} rarity={'bronze'} /> + )) + ) : ( + <span className={'text-gray-500'}>None yet</span> + )} + </Row> + </Col> + <Col + className={clsx( + 'min-w-full gap-2 rounded-md border-2 border-gray-500 border-opacity-40 p-2 text-center ' + )} + > + <span className={clsx(' ', silverClassName)}>Silver</span> + <Row className={'flex-wrap justify-center gap-4'}> + {badgesByRarity['silver'] ? ( + badgesByRarity['silver'].map((badge, i) => ( + <BadgeToItem badge={badge} key={i} rarity={'silver'} /> + )) + ) : ( + <span className={'text-gray-500'}>None yet</span> + )} + </Row> + </Col> + <Col + className={clsx( + 'min-w-full gap-2 rounded-md border-2 border-amber-400 p-2 text-center ' + )} + > + <span className={clsx('', goldClassName)}>Gold</span> + <Row className={'flex-wrap justify-center gap-4'}> + {badgesByRarity['gold'] ? ( + badgesByRarity['gold'].map((badge, i) => ( + <BadgeToItem badge={badge} key={i} rarity={'gold'} /> + )) + ) : ( + <span className={'text-gray-500'}>None yet</span> + )} + </Row> + </Col> + </Row> + </Col> + </Modal> + ) +} + +function BadgeToItem(props: { badge: Badge; rarity: rarities }) { + const { badge, rarity } = props + if (badge.type === 'PROVEN_CORRECT') + return ( + <ProvenCorrectBadgeItem + badge={badge as ProvenCorrectBadge} + rarity={rarity} + /> + ) + else if (badge.type === 'STREAKER') + return <StreakerBadgeItem badge={badge as StreakerBadge} rarity={rarity} /> + else if (badge.type === 'MARKET_CREATOR') + return ( + <MarketCreatorBadgeItem + badge={badge as MarketCreatorBadge} + rarity={rarity} + /> + ) + else return null +} + +function ProvenCorrectBadgeItem(props: { + badge: ProvenCorrectBadge + rarity: rarities +}) { + const { badge, rarity } = props + const { betAmount, contractSlug, contractCreatorUsername } = badge.data + return ( + <SiteLink + href={contractPathWithoutContract(contractCreatorUsername, contractSlug)} + > + <Col className={'text-center'}> + <Medal rarity={rarity} /> + <Tooltip + text={`Make a comment attached to a winning bet worth ${betAmount}`} + > + <span + className={ + rarity === 'gold' + ? goldClassName + : rarity === 'silver' + ? silverClassName + : bronzeClassName + } + > + Proven Correct + </span> + </Tooltip> + </Col> + </SiteLink> + ) +} +function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) { + const { badge, rarity } = props + const { totalBettingStreak } = badge.data + return ( + <Col className={'cursor-default text-center'}> + <Medal rarity={rarity} /> + <Tooltip + text={`Make ${PAST_BETS} ${totalBettingStreak} day${ + totalBettingStreak > 1 ? 's' : '' + } in a row`} + > + <span + className={ + rarity === 'gold' + ? goldClassName + : rarity === 'silver' + ? silverClassName + : bronzeClassName + } + > + Prediction Streak + </span> + </Tooltip> + </Col> + ) +} +function MarketCreatorBadgeItem(props: { + badge: MarketCreatorBadge + rarity: rarities +}) { + const { badge, rarity } = props + const { totalContractsCreated } = badge.data + return ( + <Col className={'cursor-default text-center'}> + <Medal rarity={rarity} /> + <Tooltip + text={`Make ${totalContractsCreated} market${ + totalContractsCreated > 1 ? 's' : '' + }`} + > + <span + className={ + rarity === 'gold' + ? goldClassName + : rarity === 'silver' + ? silverClassName + : bronzeClassName + } + > + Market Creator + </span> + </Tooltip> + </Col> + ) +} +function Medal(props: { rarity: rarities }) { + const { rarity } = props + return ( + <span + className={ + rarity === 'gold' + ? goldClassName + : rarity === 'silver' + ? silverClassName + : bronzeClassName + } + > + {rarity === 'gold' ? '🥇' : rarity === 'silver' ? '🥈' : '🥉'} + </span> + ) +} diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 4d1d63be..306a839c 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -13,7 +13,7 @@ import clsx from 'clsx' export function BettingStreakModal(props: { isOpen: boolean setOpen: (open: boolean) => void - currentUser?: User | null + currentUser: User | null | undefined }) { const { isOpen, setOpen, currentUser } = props const missingStreak = currentUser && !hasCompletedStreakToday(currentUser) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index dea7036d..f0ad0569 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -31,14 +31,12 @@ import { UserFollowButton } from './follow-button' import { GroupsButton } from 'web/components/groups/groups-button' import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { formatMoney } from 'common/util/format' -import { - BettingStreakModal, - hasCompletedStreakToday, -} from 'web/components/profile/betting-streak-modal' + import { LoansModal } from './profile/loans-modal' import { copyToClipboard } from 'web/lib/util/copy' import { track } from 'web/lib/service/analytics' import { DOMAIN } from 'common/envs/constants' +import { BadgeDisplay } from 'web/components/badge-display' export function UserPage(props: { user: User }) { const { user } = props @@ -79,6 +77,7 @@ export function UserPage(props: { user: User }) { {showConfetti && ( <FullscreenConfetti recycle={false} numberOfPieces={300} /> )} + <Col className="relative"> <Row className="relative px-4 pt-4"> <Avatar @@ -101,9 +100,10 @@ export function UserPage(props: { user: User }) { <span className="break-anywhere text-lg font-bold sm:text-2xl"> {user.name} </span> - <span className="sm:text-md text-greyscale-4 text-sm"> - @{user.username} - </span> + <Row className="sm:text-md -mt-1 items-center gap-x-3 text-sm "> + <span className={' text-greyscale-4'}>@{user.username}</span> + <BadgeDisplay user={user} router={router} /> + </Row> </Col> {isCurrentUser && ( <ProfilePrivateStats @@ -278,14 +278,10 @@ export function ProfilePrivateStats(props: { user: User router: NextRouter }) { - const { currentUser, profit, user, router } = props - const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) + const { profit, user, router } = props const [showLoansModal, setShowLoansModal] = useState(false) useEffect(() => { - const showBettingStreak = router.query['show'] === 'betting-streak' - setShowBettingStreakModal(showBettingStreak) - const showLoansModel = router.query['show'] === 'loans' setShowLoansModal(showLoansModel) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -301,23 +297,6 @@ export function ProfilePrivateStats(props: { </span> <span className="mx-auto text-xs sm:text-sm">profit</span> </Col> - <Col - className={clsx('text-,d cursor-pointer sm:text-lg ')} - onClick={() => setShowBettingStreakModal(true)} - > - <span - className={clsx( - !hasCompletedStreakToday(user) - ? 'opacity-50 grayscale' - : 'grayscale-0' - )} - > - 🔥 {user.currentBettingStreak ?? 0} - </span> - <span className="text-greyscale-4 mx-auto text-xs sm:text-sm"> - streak - </span> - </Col> <Col className={ 'text-greyscale-4 text-md flex-shrink-0 cursor-pointer sm:text-lg' @@ -330,13 +309,6 @@ export function ProfilePrivateStats(props: { <span className="mx-auto text-xs sm:text-sm">next loan</span> </Col> </Row> - {BettingStreakModal && ( - <BettingStreakModal - isOpen={showBettingStreakModal} - setOpen={setShowBettingStreakModal} - currentUser={currentUser} - /> - )} {showLoansModal && ( <LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} /> )} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 1de472c5..70026e5e 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -270,7 +270,11 @@ export function ContractPageContent( <> <div className="grid grid-cols-1 sm:grid-cols-2"> <ContractLeaderboard contract={contract} bets={bets} /> - <ContractTopTrades contract={contract} bets={bets} /> + <ContractTopTrades + contract={contract} + bets={bets} + comments={comments} + /> </div> <Spacer h={12} /> </> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 34218911..f7e4bc84 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -13,11 +13,7 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' -import { - MANIFOLD_AVATAR_URL, - MANIFOLD_USERNAME, - PrivateUser, -} from 'common/user' +import { MANIFOLD_AVATAR_URL, PAST_BETS, PrivateUser } from 'common/user' import clsx from 'clsx' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' @@ -739,6 +735,24 @@ function NotificationItem(props: { justSummary={justSummary} /> ) + } else if (sourceType === 'badge') { + return ( + <BadgeNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) + } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { + return ( + <MarketClosedNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) } // TODO Add new notification components here @@ -812,9 +826,16 @@ function NotificationFrame(props: { subtitle: string children: React.ReactNode isChildOfGroup?: boolean + showUserName?: boolean }) { - const { notification, isChildOfGroup, highlighted, subtitle, children } = - props + const { + notification, + isChildOfGroup, + highlighted, + subtitle, + children, + showUserName, + } = props const { sourceType, sourceUserName, @@ -825,7 +846,7 @@ function NotificationFrame(props: { sourceUserUsername, sourceText, } = notification - const questionNeedsResolution = sourceUpdateType == 'closed' + const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 return ( @@ -855,16 +876,10 @@ function NotificationFrame(props: { /> <Row className={'items-center text-gray-500 sm:justify-start'}> <Avatar - avatarUrl={ - questionNeedsResolution - ? MANIFOLD_AVATAR_URL - : sourceUserAvatarUrl - } + avatarUrl={sourceUserAvatarUrl} size={'sm'} className={'z-10 mr-2'} - username={ - questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername - } + username={sourceUserUsername} /> <div className={'flex w-full flex-row pl-1 sm:pl-0'}> <div @@ -873,12 +888,14 @@ function NotificationFrame(props: { } > <div> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'relative mr-1 flex-shrink-0'} - short={isMobile} - /> + {showUserName && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'relative mr-1 flex-shrink-0'} + short={isMobile} + /> + )} {subtitle} {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> @@ -967,6 +984,65 @@ function BetFillNotification(props: { ) } +function MarketClosedNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted } = props + notification.sourceUserAvatarUrl = MANIFOLD_AVATAR_URL + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={'Please resolve'} + > + <Row> + <span> + {`Your market has closed. Please resolve it to pay out ${PAST_BETS}.`} + </span> + </Row> + </NotificationFrame> + ) +} + +function BadgeNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText } = notification + const subtitle = 'You earned a new badge!' + notification.sourceUserAvatarUrl = '/award.svg' + if (justSummary) { + return ( + <NotificationSummaryFrame notification={notification} subtitle={subtitle}> + <Row className={'line-clamp-1'}> + <span>{sourceText} 🎉</span> + </Row> + </NotificationSummaryFrame> + ) + } + + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={subtitle} + showUserName={false} + > + <Row> + <span>{sourceText} 🎉</span> + </Row> + </NotificationFrame> + ) +} + function ContractResolvedNotification(props: { notification: Notification highlighted: boolean @@ -1137,6 +1213,11 @@ function getSourceUrl(notification: Notification) { sourceId ?? '', sourceType )}` + else if (sourceSlug) + return `/${sourceSlug}#${getSourceIdForLinkComponent( + sourceId ?? '', + sourceType + )}` } function getSourceIdForLinkComponent( @@ -1236,7 +1317,6 @@ function getReasonForShowingNotification( reasonText = justSummary ? 'asked the question' : 'asked' else if (sourceUpdateType === 'resolved') reasonText = justSummary ? `resolved the question` : `resolved` - else if (sourceUpdateType === 'closed') reasonText = `Please resolve` else reasonText = justSummary ? 'updated the question' : `updated` break case 'answer': diff --git a/web/public/award.svg b/web/public/award.svg new file mode 100644 index 00000000..3140c5b2 --- /dev/null +++ b/web/public/award.svg @@ -0,0 +1,28 @@ +<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg"> + <g id="color"> + <polyline fill="#92d3f5" stroke="#92d3f5" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="54.9988,4.0221 43,16.0208 36,16.0208 30.9584,10.9792 37.9207,4.0169 54.9988,4.0169"/> + <polyline fill="#ea5a47" stroke="#ea5a47" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="23.9831,4.0039 36,16.0208 29,16.0208 16.9675,3.9883 23.9831,3.9883"/> + <polyline fill="#fcea2b" stroke="none" points="28,22.4271 28,17 44,17 44,22.4271"/> + <circle cx="36" cy="45.0208" r="23" fill="#fcea2b" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <polygon fill="#f1b31c" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/> + </g> + <g id="hair"/> + <g id="skin"/> + <g id="skin-shadow"/> + <g id="line"> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/> + <line x1="29" x2="29" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="43" x2="43" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="29" x2="43" y1="16.0208" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="25.9896" x2="16.9675" y1="13.0104" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="31.9896" x2="23.9831" y1="12.0104" y2="4.0039" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="34" x2="37.9207" y1="8" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="46" x2="54.9988" y1="13" y2="4.0221" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="16.9675" x2="23.9831" y1="3.9883" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="37.9207" x2="54.9988" y1="4.0169" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/> + <polygon fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/> + </g> +</svg> From 867cdf249638e05562ca2fcdd3165433247b7693 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 14:34:06 -0600 Subject: [PATCH 17/22] Only backfill unfilled users' badges --- functions/src/scripts/backfill-badges.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/functions/src/scripts/backfill-badges.ts b/functions/src/scripts/backfill-badges.ts index 648467cf..145f064c 100644 --- a/functions/src/scripts/backfill-badges.ts +++ b/functions/src/scripts/backfill-badges.ts @@ -20,17 +20,18 @@ async function main() { // const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian await Promise.all( users.map(async (user) => { - // console.log('Added achievements to user', user.id) if (!user.id) return + // Only backfill users without achievements if (user.achievements === undefined) { await firestore.collection('users').doc(user.id).update({ achievements: {}, }) user.achievements = {} + user.achievements = await awardMarketCreatorBadges(user) + user.achievements = await awardBettingStreakBadges(user) + console.log('Added achievements to user', user.id) + // going to ignore backfilling the proven correct badges for now } - user.achievements = await awardMarketCreatorBadges(user) - user.achievements = await awardBettingStreakBadges(user) - // going to ignore backfilling the proven correct badges for now }) ) } From 17d0fb7da690ebcdede24468449c2adbc876a3e5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 14:41:24 -0600 Subject: [PATCH 18/22] Change badge award notif setting group --- functions/src/on-create-bet.ts | 2 +- web/components/notification-settings.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 7496db03..fc473b6d 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -312,7 +312,7 @@ async function handleBettingStreakBadgeAward( user.achievements?.streaker?.badges.some( (badge) => badge.data.totalBettingStreak === 1 ) - + // TODO: check if already awarded 50th streak as well if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return if (newBettingStreak in streakerBadgeRarityThresholds) { diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 166653e2..58a606f2 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -131,7 +131,6 @@ export function NotificationSettings(props: { 'betting_streaks', 'referral_bonuses', 'unique_bettors_on_your_contract', - 'badges_awarded', ], } const otherBalances: SectionData = { @@ -140,6 +139,7 @@ export function NotificationSettings(props: { 'loan_income', 'limit_order_fills', 'tips_on_your_comments', + 'badges_awarded', ], } const userInteractions: SectionData = { From fb0a09664e88f190e382478d5012642e57846ad6 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 10 Oct 2022 13:51:27 -0700 Subject: [PATCH 19/22] delete bannerUrl from user type --- common/user.ts | 1 - firestore.rules | 2 +- web/pages/api/v0/_types.ts | 3 --- web/pages/profile.tsx | 2 +- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/common/user.ts b/common/user.ts index f00dfc89..f89223d2 100644 --- a/common/user.ts +++ b/common/user.ts @@ -12,7 +12,6 @@ export type User = { // For their user page bio?: string - bannerUrl?: string website?: string twitterHandle?: string discordHandle?: string diff --git a/firestore.rules b/firestore.rules index 50f93e1f..9ab575cd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -27,7 +27,7 @@ service cloud.firestore { allow read; allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); + .hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); // User referral rules allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index ccaa217d..788b5ec7 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -192,7 +192,6 @@ export type LiteUser = { avatarUrl?: string bio?: string - bannerUrl?: string website?: string twitterHandle?: string discordHandle?: string @@ -223,7 +222,6 @@ export function toLiteUser(user: User): LiteUser { username, avatarUrl, bio, - bannerUrl, website, twitterHandle, discordHandle, @@ -241,7 +239,6 @@ export function toLiteUser(user: User): LiteUser { url: `https://${ENV_CONFIG.domain}/${username}`, avatarUrl, bio, - bannerUrl, website, twitterHandle, discordHandle, diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 04878c6c..c3d4335c 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -28,7 +28,7 @@ export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { function EditUserField(props: { user: User - field: 'bio' | 'website' | 'bannerUrl' | 'twitterHandle' | 'discordHandle' + field: 'bio' | 'website' | 'twitterHandle' | 'discordHandle' label: string }) { const { user, field, label } = props From f03e5d7af0ab33c5a9d3f9b376c64b2a4de6946d Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 10 Oct 2022 15:51:51 -0500 Subject: [PATCH 20/22] Refactor portfolio query (#1018) * Fetch less data for portfolio query * Rename var --- common/calculate-metrics.ts | 36 +++++-------------- functions/src/update-metrics.ts | 62 ++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 9cd1a57a..47fccd86 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,4 +1,4 @@ -import { Dictionary, groupBy, last, sortBy, sum, sumBy, uniq } from 'lodash' +import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash' import { calculatePayout, getContractBetMetrics } from './calculate' import { Bet, LimitBet } from './bet' import { @@ -199,14 +199,9 @@ export const calculateNewPortfolioMetrics = ( } const calculateProfitForPeriod = ( - startTime: number, - descendingPortfolio: PortfolioMetrics[], + startingPortfolio: PortfolioMetrics | undefined, currentProfit: number ) => { - const startingPortfolio = descendingPortfolio.find( - (p) => p.timestamp < startTime - ) - if (startingPortfolio === undefined) { return currentProfit } @@ -221,31 +216,18 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { } export const calculateNewProfit = ( - portfolioHistory: PortfolioMetrics[], + portfolioHistory: Record< + 'current' | 'day' | 'week' | 'month', + PortfolioMetrics | undefined + >, newPortfolio: PortfolioMetrics ) => { const allTimeProfit = calculatePortfolioProfit(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 - ), + daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit), + weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit), + monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit), allTime: allTimeProfit, } diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 5dfb1eeb..106ed773 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, keyBy, last, sortBy } from 'lodash' +import { groupBy, keyBy, sortBy } from 'lodash' import fetch from 'node-fetch' import { getValues, log, logMemory, writeAsync } from './utils' @@ -62,11 +62,7 @@ export async function updateMetricsCore() { const contracts = await getValues<Contract>(firestore.collection('contracts')) console.log('Loading portfolio history') - const allPortfolioHistories = await getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ) + const userPortfolioHistory = await loadPortfolioHistory(users) console.log('Loading groups') const groups = await getValues<Group>(firestore.collection('groups')) @@ -143,11 +139,10 @@ export async function updateMetricsCore() { ) const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const betsByUser = groupBy(bets, (bet) => bet.userId) - const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId) const userMetrics = users.map((user) => { const currentBets = betsByUser[user.id] ?? [] - const portfolioHistory = portfolioHistoryByUser[user.id] ?? [] + const portfolioHistory = userPortfolioHistory[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? [] const newCreatorVolume = calculateCreatorVolume(userContracts) const newPortfolio = calculateNewPortfolioMetrics( @@ -155,12 +150,12 @@ export async function updateMetricsCore() { contractsById, currentBets ) - const lastPortfolio = last(portfolioHistory) + const currPortfolio = portfolioHistory.current const didPortfolioChange = - lastPortfolio === undefined || - lastPortfolio.balance !== newPortfolio.balance || - lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || - lastPortfolio.investmentValue !== newPortfolio.investmentValue + currPortfolio === undefined || + currPortfolio.balance !== newPortfolio.balance || + currPortfolio.totalDeposits !== newPortfolio.totalDeposits || + currPortfolio.investmentValue !== newPortfolio.investmentValue const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) @@ -303,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => { type GroupContractDoc = { contractId: string; createdTime: number } const BAD_RESOLUTION_THRESHOLD = 0.1 + +const loadPortfolioHistory = async (users: User[]) => { + const now = Date.now() + const userPortfolioHistory = await batchedWaitAll( + users.map((user) => async () => { + const query = firestore + .collection('users') + .doc(user.id) + .collection('portfolioHistory') + .orderBy('timestamp', 'desc') + .limit(1) + + const portfolioMetrics = await Promise.all([ + getValues<PortfolioMetrics>(query), + getValues<PortfolioMetrics>( + query.where('timestamp', '<', now - DAY_MS) + ), + getValues<PortfolioMetrics>( + query.where('timestamp', '<', now - 7 * DAY_MS) + ), + getValues<PortfolioMetrics>( + query.where('timestamp', '<', now - 30 * DAY_MS) + ), + ]) + const [current, day, week, month] = portfolioMetrics.map( + (p) => p[0] as PortfolioMetrics | undefined + ) + + return { + userId: user.id, + current, + day, + week, + month, + } + }), + 100 + ) + + return keyBy(userPortfolioHistory, (p) => p.userId) +} From 4f5c93be96d603fc07858dd1b56d6206a9587a08 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 15:01:18 -0600 Subject: [PATCH 21/22] Badge notifications ui changes --- web/components/badge-display.tsx | 12 ++++++------ web/components/user-page.tsx | 6 +++--- web/pages/notifications.tsx | 15 ++++++++++----- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/web/components/badge-display.tsx b/web/components/badge-display.tsx index 983956cd..df101c7d 100644 --- a/web/components/badge-display.tsx +++ b/web/components/badge-display.tsx @@ -1,10 +1,10 @@ import { User } from 'common/user' -import { NextRouter } from 'next/router' import { useEffect, useState } from 'react' import { getBadgesByRarity } from 'common/badge' import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { BadgesModal } from 'web/components/profile/badges-modal' +import { ParsedUrlQuery } from 'querystring' export const goldClassName = 'text-amber-400' export const silverClassName = 'text-gray-500' @@ -12,16 +12,16 @@ export const bronzeClassName = 'text-amber-900' export function BadgeDisplay(props: { user: User | undefined | null - router?: NextRouter + query: ParsedUrlQuery }) { - const { user, router } = props + const { user, query } = props const [showBadgesModal, setShowBadgesModal] = useState(false) useEffect(() => { - if (!router) return - const showBadgesModal = router.query['show'] === 'badges' + const showBadgesModal = query['show'] == 'badges' setShowBadgesModal(showBadgesModal) - }, [router]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // get number of badges of each rarity type const badgesByRarity = getBadgesByRarity(user) const badgesByRarityItems = Object.entries(badgesByRarity).map( diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index f0ad0569..3f2176dc 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -50,8 +50,8 @@ export function UserPage(props: { user: User }) { setShowConfetti(claimedMana) const query = { ...router.query } if (query.claimedMana || query.show) { - delete query['claimed-mana'] - delete query['show'] + const queriesToDelete = ['claimed-mana', 'show', 'badge'] + queriesToDelete.forEach((key) => delete query[key]) router.replace( { pathname: router.pathname, @@ -102,7 +102,7 @@ export function UserPage(props: { user: User }) { </span> <Row className="sm:text-md -mt-1 items-center gap-x-3 text-sm "> <span className={' text-greyscale-4'}>@{user.username}</span> - <BadgeDisplay user={user} router={router} /> + <BadgeDisplay user={user} query={router.query} /> </Row> </Col> {isCurrentUser && ( diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index f7e4bc84..dd622a72 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -826,7 +826,8 @@ function NotificationFrame(props: { subtitle: string children: React.ReactNode isChildOfGroup?: boolean - showUserName?: boolean + hideUserName?: boolean + hideLinkToGroupOrQuestion?: boolean }) { const { notification, @@ -834,7 +835,8 @@ function NotificationFrame(props: { highlighted, subtitle, children, - showUserName, + hideUserName, + hideLinkToGroupOrQuestion, } = props const { sourceType, @@ -888,7 +890,7 @@ function NotificationFrame(props: { } > <div> - {showUserName && ( + {!hideUserName && ( <UserLink name={sourceUserName || ''} username={sourceUserUsername || ''} @@ -900,7 +902,9 @@ function NotificationFrame(props: { {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> ) : ( - <QuestionOrGroupLink notification={notification} /> + !hideLinkToGroupOrQuestion && ( + <QuestionOrGroupLink notification={notification} /> + ) )} </div> </div> @@ -1034,7 +1038,8 @@ function BadgeNotification(props: { isChildOfGroup={isChildOfGroup} highlighted={highlighted} subtitle={subtitle} - showUserName={false} + hideUserName={true} + hideLinkToGroupOrQuestion={true} > <Row> <span>{sourceText} 🎉</span> From c46c384d1d108c1c7bdf317b236a45167fccaccb Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 10 Oct 2022 15:38:27 -0600 Subject: [PATCH 22/22] Add more bot tags, better creator name scaling --- web/components/contract/contract-details.tsx | 1 - web/components/user-link.tsx | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index ed7374b1..33c8d6aa 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -183,7 +183,6 @@ export function MarketSubheader(props: { className="my-auto whitespace-nowrap" name={creatorName} username={creatorUsername} - short={isMobile} /> {/*<BadgeDisplay user={creator} />*/} </Row> diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx index d7f660ae..18a866cc 100644 --- a/web/components/user-link.tsx +++ b/web/components/user-link.tsx @@ -1,5 +1,6 @@ import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' +import { useWindowSize } from 'web/hooks/use-window-size' export function shortenName(name: string) { const firstName = name.split(' ')[0] @@ -24,10 +25,12 @@ export function UserLink(props: { }) { const { name, username, className, short, noLink } = props const shortName = short ? shortenName(name) : name + const { width } = useWindowSize() return ( <SiteLink href={`/${username}`} className={clsx( + (width ?? 0) < 450 ? ' max-w-[120px]' : 'max-w-[200px]', 'z-10 truncate', className, noLink ? 'pointer-events-none' : '' @@ -39,7 +42,13 @@ export function UserLink(props: { ) } -const BOT_USERNAMES = ['v', 'ArbitrageBot'] +const BOT_USERNAMES = [ + 'v', + 'ArbitrageBot', + 'MarketManagerBot', + 'Botlab', + 'JuniorBot', +] function BotBadge() { return (