From cb68530e2a8af2c50836ab3a04b26bd1797ea2d1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 1 Jul 2022 12:26:45 -0400 Subject: [PATCH 01/39] Use client side contract search for emulator --- web/components/contract-search.tsx | 2 +- web/components/outcome-label.tsx | 2 +- web/pages/contract-search-firestore.tsx | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index fac02d74..9a4da597 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -122,7 +122,7 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` - if (IS_PRIVATE_MANIFOLD) { + if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( if (resolution === 'MKT') return - const chosen = contract.answers.find((answer) => answer.id === resolution) + const chosen = contract.answers?.find((answer) => answer.id === resolution) if (!chosen) return return ( diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index c9a7a666..8cd80f7a 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -9,6 +9,8 @@ import { useInitialQueryAndSort, } from 'web/hooks/use-sort-and-query-params' +const MAX_CONTRACTS_RENDERED = 100 + export default function ContractSearchFirestore(props: { querySortOptions?: { defaultSort: Sort @@ -80,6 +82,8 @@ export default function ContractSearchFirestore(props: { } } + matches = matches.slice(0, MAX_CONTRACTS_RENDERED) + const showTime = ['close-date', 'closed'].includes(sort) ? 'close-date' : sort === 'resolve-date' From b9931e65dad9fe8d0d5de921e785a858a8a286b8 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 1 Jul 2022 16:37:30 -0600 Subject: [PATCH 02/39] Allow adding anyone's contract to a group --- firestore.rules | 11 ++- web/components/contract-search.tsx | 49 ++++++++--- web/components/contract/contract-details.tsx | 36 ++++++-- web/components/groups/edit-group-button.tsx | 3 +- web/components/groups/groups-button.tsx | 4 +- web/components/layout/modal.tsx | 11 ++- web/components/layout/tabs.tsx | 14 +-- web/components/nav/sidebar.tsx | 2 +- web/components/user-page.tsx | 2 +- web/hooks/use-group.ts | 4 +- web/lib/firebase/groups.ts | 18 +++- web/pages/create.tsx | 6 +- web/pages/group/[...slugs]/index.tsx | 93 +++++++------------- web/pages/links.tsx | 2 +- web/pages/notifications.tsx | 2 +- 15 files changed, 150 insertions(+), 107 deletions(-) diff --git a/firestore.rules b/firestore.rules index 50df415a..4645343d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,11 +21,16 @@ service cloud.firestore { allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - // only one referral allowed per user allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - && !("referredByUserId" in resource.data); + .hasOnly(['referredByUserId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && (resource.data.id != request.resource.data.referredByUserId) + // user can't refer someone who referred them quid pro quo + && get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id; + } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 9a4da597..2c7f5b62 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -9,7 +9,7 @@ import { useSortBy, } from 'react-instantsearch-hooks-web' -import { Contract } from '../../common/contract' +import { Contract } from 'common/contract' import { Sort, useInitialQueryAndSort, @@ -58,15 +58,24 @@ export function ContractSearch(props: { additionalFilter?: { creatorId?: string tag?: string + excludeContractIds?: string[] } showCategorySelector: boolean onContractClick?: (contract: Contract) => void + showPlaceHolder?: boolean + hideOrderSelector?: boolean + overrideGridClassName?: string + hideQuickBet?: boolean }) { const { querySortOptions, additionalFilter, showCategorySelector, onContractClick, + overrideGridClassName, + hideOrderSelector, + showPlaceHolder, + hideQuickBet, } = props const user = useUser() @@ -136,6 +145,7 @@ export function ContractSearch(props: { Resolved - + {!hideOrderSelector && ( + + )} )} @@ -199,8 +214,17 @@ export function ContractSearchInner(props: { shouldLoadFromStorage?: boolean } onContractClick?: (contract: Contract) => void + overrideGridClassName?: string + hideQuickBet?: boolean + excludeContractIds?: string[] }) { - const { querySortOptions, onContractClick } = props + const { + querySortOptions, + onContractClick, + overrideGridClassName, + hideQuickBet, + excludeContractIds, + } = props const { initialQuery } = useInitialQueryAndSort(querySortOptions) const { query, setQuery, setSort } = useUpdateQueryAndSort({ @@ -239,7 +263,7 @@ export function ContractSearchInner(props: { }, []) const { showMore, hits, isLastPage } = useInfiniteHits() - const contracts = hits as any as Contract[] + let contracts = hits as any as Contract[] if (isInitialLoad && contracts.length === 0) return <> @@ -249,6 +273,9 @@ export function ContractSearchInner(props: { ? 'resolve-date' : undefined + if (excludeContractIds) + contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) + return ( ) } diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 3512efa2..f908918e 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -130,9 +130,32 @@ export function ContractDetails(props: { const { contract, bets, isCreator, disabled } = props const { closeTime, creatorName, creatorUsername, creatorId } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) - // Find a group that this contract id is in - const groups = useGroupsWithContract(contract.id) + + const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => { + return g2.createdTime - g1.createdTime + }) const user = useUser() + + const groupsUserIsMemberOf = groups + ? groups.filter((g) => g.memberIds.includes(contract.creatorId)) + : [] + const groupsUserIsCreatorOf = groups + ? groups.filter((g) => g.creatorId === contract.creatorId) + : [] + + // Priorities for which group the contract belongs to: + // In order of created most recently + // Group that the contract owner created + // Group the contract owner is a member of + // Any group the contract is in + const groupToDisplay = + groupsUserIsCreatorOf.length > 0 + ? groupsUserIsCreatorOf[0] + : groupsUserIsMemberOf.length > 0 + ? groupsUserIsMemberOf[0] + : groups + ? groups[0] + : undefined return ( @@ -153,14 +176,15 @@ export function ContractDetails(props: { )} {!disabled && } - {/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/} - {groups && groups.length > 0 && ( + {groupToDisplay ? ( - + - {groups[0].name} + {groupToDisplay.name} + ) : ( +
)} {(!!closeTime || !!resolvedDate) && ( diff --git a/web/components/groups/edit-group-button.tsx b/web/components/groups/edit-group-button.tsx index 6ad7237a..834af5ec 100644 --- a/web/components/groups/edit-group-button.tsx +++ b/web/components/groups/edit-group-button.tsx @@ -9,6 +9,7 @@ import { useRouter } from 'next/router' import { Modal } from 'web/components/layout/modal' import { FilterSelectUsers } from 'web/components/filter-select-users' import { User } from 'common/user' +import { uniq } from 'lodash' export function EditGroupButton(props: { group: Group; className?: string }) { const { group, className } = props @@ -35,7 +36,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) { await updateGroup(group, { name, about, - memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)], + memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]), }) setIsSubmitting(false) diff --git a/web/components/groups/groups-button.tsx b/web/components/groups/groups-button.tsx index e6ee217d..b81155d1 100644 --- a/web/components/groups/groups-button.tsx +++ b/web/components/groups/groups-button.tsx @@ -9,7 +9,7 @@ import { TextButton } from 'web/components/text-button' import { Group } from 'common/group' import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' -import { joinGroup, leaveGroup } from 'web/lib/firebase/groups' +import { addUserToGroup, leaveGroup } from 'web/lib/firebase/groups' import { firebaseLogin } from 'web/lib/firebase/users' import { GroupLink } from 'web/pages/groups' @@ -93,7 +93,7 @@ export function JoinOrLeaveGroupButton(props: { : false const onJoinGroup = () => { if (!currentUser) return - joinGroup(group, currentUser.id) + addUserToGroup(group, currentUser.id) } const onLeaveGroup = () => { if (!currentUser) return diff --git a/web/components/layout/modal.tsx b/web/components/layout/modal.tsx index d61a38dd..7a320f24 100644 --- a/web/components/layout/modal.tsx +++ b/web/components/layout/modal.tsx @@ -1,13 +1,15 @@ import { Fragment, ReactNode } from 'react' import { Dialog, Transition } from '@headlessui/react' +import clsx from 'clsx' // From https://tailwindui.com/components/application-ui/overlays/modals export function Modal(props: { children: ReactNode open: boolean setOpen: (open: boolean) => void + className?: string }) { - const { children, open, setOpen } = props + const { children, open, setOpen, className } = props return ( @@ -45,7 +47,12 @@ export function Modal(props: { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > -
+
{children}
diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 69e8cfab..796f5dae 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -14,17 +14,17 @@ type Tab = { export function Tabs(props: { tabs: Tab[] defaultIndex?: number - className?: string + labelClassName?: string onClick?: (tabTitle: string, index: number) => void }) { - const { tabs, defaultIndex, className, onClick } = props + const { tabs, defaultIndex, labelClassName, onClick } = props const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0) const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case return ( - + {activeTab?.content} + ) } diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 402f5e12..8c3ceb02 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -254,7 +254,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
{memberItems.map((item) => ( diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index ac9fe8fd..ccacca04 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -258,7 +258,7 @@ export function UserPage(props: { {usersContracts !== 'loading' && commentsByContract != 'loading' ? ( user - ) + return await Promise.all(group.memberIds.map(getUser)) } export const useGroupsWithContract = (contractId: string | undefined) => { diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 506849ad..04a5bd44 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -102,10 +102,13 @@ export async function addUserToGroupViaSlug(groupSlug: string, userId: string) { console.error(`Group not found: ${groupSlug}`) return } - return await joinGroup(group, userId) + return await addUserToGroup(group, userId) } -export async function joinGroup(group: Group, userId: string): Promise { +export async function addUserToGroup( + group: Group, + userId: string +): Promise { const { memberIds } = group if (memberIds.includes(userId)) { return group @@ -125,3 +128,14 @@ export async function leaveGroup(group: Group, userId: string): Promise { await updateGroup(newGroup, { memberIds: uniq(newMemberIds) }) return newGroup } + +export async function addContractToGroup(group: Group, contractId: string) { + return await updateGroup(group, { + contractIds: uniq([...group.contractIds, contractId]), + }) + .then(() => group) + .catch((err) => { + console.error('error adding contract to group', err) + return err + }) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ebbb6f65..7d645b04 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -19,7 +19,7 @@ import { import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { getGroup, updateGroup } from 'web/lib/firebase/groups' +import { addContractToGroup, getGroup } from 'web/lib/firebase/groups' import { Group } from 'common/group' import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' @@ -186,9 +186,7 @@ export function NewContract(props: { isFree: false, }) if (result && selectedGroup) { - await updateGroup(selectedGroup, { - contractIds: [...selectedGroup.contractIds, result.id], - }) + await addContractToGroup(selectedGroup, result.id) } await router.push(contractPath(result as Contract)) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 3a3db14d..8a8bc4c1 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -4,12 +4,14 @@ import { Group } from 'common/group' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { listAllBets } from 'web/lib/firebase/bets' -import { Contract, listenForUserContracts } from 'web/lib/firebase/contracts' +import { Contract } from 'web/lib/firebase/contracts' import { groupPath, getGroupBySlug, getGroupContracts, updateGroup, + addContractToGroup, + addUserToGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' @@ -39,7 +41,6 @@ import React, { useEffect, useState } from 'react' import { GroupChat } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' -import { PlusIcon } from '@heroicons/react/outline' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' @@ -48,6 +49,7 @@ import ShortToggle from 'web/components/widgets/short-toggle' import { ShareIconButton } from 'web/components/share-icon-button' import { REFERRAL_AMOUNT } from 'common/user' import { SiteLink } from 'web/components/site-link' +import { ContractSearch } from 'web/components/contract-search' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -509,75 +511,46 @@ function GroupLeaderboards(props: { } function AddContractButton(props: { group: Group; user: User }) { - const { group, user } = props + const { group } = props const [open, setOpen] = useState(false) - const [contracts, setContracts] = useState(undefined) - const [query, setQuery] = useState('') - useEffect(() => { - return listenForUserContracts(user.id, (contracts) => { - setContracts(contracts.filter((c) => !group.contractIds.includes(c.id))) - }) - }, [group.contractIds, user.id]) - - async function addContractToGroup(contract: Contract) { - await updateGroup(group, { - ...group, - contractIds: [...group.contractIds, contract.id], - }) + async function addContractToCurrentGroup(contract: Contract) { + await addContractToGroup(group, contract.id) setOpen(false) } - // TODO use find-active-contracts to sort by? - const matches = sortBy(contracts, [ - (contract) => -1 * contract.createdTime, - ]).filter( - (c) => - checkAgainstQuery(query, c.question) || - checkAgainstQuery(query, c.description) || - checkAgainstQuery(query, c.tags.flat().join(' ')) - ) - const debouncedQuery = debounce(setQuery, 50) return ( <> - - + +
Add a question to your group
- debouncedQuery(e.target.value)} - placeholder="Search your questions" - className="input input-bordered mb-4 w-full" - /> -
- {contracts ? ( - {}} - hasMore={false} - onContractClick={(contract) => { - addContractToGroup(contract) - }} - overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'} - hideQuickBet={true} - /> - ) : ( - - )} +
+
@@ -591,17 +564,11 @@ function JoinGroupButton(props: { const { group, user } = props function joinGroup() { if (user && !group.memberIds.includes(user.id)) { - toast.promise( - updateGroup(group, { - ...group, - memberIds: [...group.memberIds, user.id], - }), - { - loading: 'Joining group...', - success: 'Joined group!', - error: "Couldn't join group", - } - ) + toast.promise(addUserToGroup(group, user.id), { + loading: 'Joining group...', + success: 'Joined group!', + error: "Couldn't join group", + }) } } return ( diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 08c99460..12cde274 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -64,7 +64,7 @@ export default function LinkPage() { <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9b0216b6..f3512c56 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -86,7 +86,7 @@ export default function Notifications() { <div className={'p-2 sm:p-4'}> <Title text={'Notifications'} className={'hidden md:block'} /> <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { From 2dce3e15a138bccc38063021003716127fcffa34 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Fri, 1 Jul 2022 17:03:26 -0600 Subject: [PATCH 03/39] Correct margin on tabs --- web/components/layout/tabs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index 796f5dae..ac1c0fe3 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -23,8 +23,8 @@ export function Tabs(props: { return ( <> - <div className="border-b border-gray-200"> - <nav className="-mb-px mb-4 flex space-x-8" aria-label="Tabs"> + <div className="mb-4 border-b border-gray-200"> + <nav className="-mb-px flex space-x-8" aria-label="Tabs"> {tabs.map((tab, i) => ( <Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> <a From cc52bff05e4202fa5f0e1962cf5bec266226f476 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Fri, 1 Jul 2022 16:45:05 -0700 Subject: [PATCH 04/39] fix functions/README formatting --- functions/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/functions/README.md b/functions/README.md index 031cc4fa..8013fb20 100644 --- a/functions/README.md +++ b/functions/README.md @@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started ### For local development 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI -1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 0. `$ brew install java` - 1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` +1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): + + 1. `$ brew install java` + 2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` 2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options) 4. `$ mkdir firestore_export` to create a folder to store the exported database From 1a6afaf44fabdef277bb2837d7658554094469f3 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Sat, 2 Jul 2022 14:37:59 -0500 Subject: [PATCH 05/39] Pseudo numeric market (#609) * create pseudo-numeric contracts * graph and bet panel for pseudo numeric * pseudo numeric market layout, quick betting * Estimated value * sell panel * fix graph * pseudo numeric resolution * bets tab * redemption for pseudo numeric markets * create log scale market, validation * log scale * create: initial value can't be min or max * don't allow log scale for ranges with negative values (b/c of problem with graph library) * prettier delenda est * graph: handle min value of zero * bet labeling * validation * prettier * pseudo numeric embeds * update disclaimer * validation * validation --- common/calculate.ts | 31 ++-- common/contract.ts | 22 ++- common/new-bet.ts | 3 +- common/new-contract.ts | 24 ++- common/payouts.ts | 15 +- common/pseudo-numeric.ts | 45 ++++++ functions/src/create-contract.ts | 30 +++- functions/src/emails.ts | 18 ++- functions/src/place-bet.ts | 5 +- functions/src/redeem-shares.ts | 6 +- functions/src/resolve-market.ts | 22 ++- web/components/bet-panel.tsx | 54 +++++-- web/components/bet-row.tsx | 5 +- web/components/bets-list.tsx | 39 ++++- web/components/contract/contract-card.tsx | 58 +++++++- web/components/contract/contract-overview.tsx | 18 ++- .../contract/contract-prob-graph.tsx | 51 +++++-- web/components/contract/quick-bet.tsx | 82 +++++++---- web/components/feed/feed-bets.tsx | 10 +- web/components/numeric-resolution-panel.tsx | 27 +++- web/components/outcome-label.tsx | 25 +++- web/components/sell-button.tsx | 12 +- web/components/sell-modal.tsx | 4 +- web/components/sell-row.tsx | 4 +- web/components/yes-no-selector.tsx | 6 +- web/pages/[username]/[contractSlug].tsx | 9 +- web/pages/create.tsx | 139 +++++++++++++----- web/pages/embed/[username]/[contractSlug].tsx | 15 +- 28 files changed, 623 insertions(+), 156 deletions(-) create mode 100644 common/pseudo-numeric.ts diff --git a/common/calculate.ts b/common/calculate.ts index a0574c10..482a0ccf 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -18,15 +18,24 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Contract, BinaryContract, FreeResponseContract } from './contract' +import { + Contract, + BinaryContract, + FreeResponseContract, + PseudoNumericContract, +} from './contract' -export function getProbability(contract: BinaryContract) { +export function getProbability( + contract: BinaryContract | PseudoNumericContract +) { return contract.mechanism === 'cpmm-1' ? getCpmmProbability(contract.pool, contract.p) : getDpmProbability(contract.totalShares) } -export function getInitialProbability(contract: BinaryContract) { +export function getInitialProbability( + contract: BinaryContract | PseudoNumericContract +) { if (contract.initialProbability) return contract.initialProbability if (contract.mechanism === 'dpm-2' || (contract as any).totalShares) @@ -65,7 +74,9 @@ export function calculateShares( } export function calculateSaleAmount(contract: Contract, bet: Bet) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -87,7 +98,9 @@ export function getProbabilityAfterSale( } export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) { const outcome = contract.resolution if (!outcome) throw new Error('Contract not resolved') - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 - const hasShares = Object.values(totalShares).some( - (shares) => shares > 0 - ) + const hasShares = Object.values(totalShares).some((shares) => shares > 0) return { invested: Math.max(0, currentInvested), diff --git a/common/contract.ts b/common/contract.ts index dc91a20e..3a90d01f 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -2,9 +2,10 @@ import { Answer } from './answer' import { Fees } from './fees' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | FreeResponse | Numeric +export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric export type AnyContractType = | (CPMM & Binary) + | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) @@ -33,7 +34,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market resolution?: string - resolutionProbability?: number, + resolutionProbability?: number closeEmailsSent?: number @@ -44,7 +45,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = { collectedFees: Fees } & T -export type BinaryContract = Contract & Binary +export type BinaryContract = Contract & Binary +export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse export type DPMContract = Contract & DPM @@ -75,6 +77,18 @@ export type Binary = { resolution?: resolution } +export type PseudoNumeric = { + outcomeType: 'PSEUDO_NUMERIC' + min: number + max: number + isLogScale: boolean + resolutionValue?: number + + // same as binary market; map everything to probability + initialProbability: number + resolutionProbability?: number +} + export type FreeResponse = { outcomeType: 'FREE_RESPONSE' answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. @@ -94,7 +108,7 @@ export type Numeric = { export type outcomeType = AnyOutcomeType['outcomeType'] export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const -export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const +export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/new-bet.ts b/common/new-bet.ts index ba799624..236c0908 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -14,6 +14,7 @@ import { DPMBinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from './contract' import { noFees } from './fees' import { addObjects } from './util/object' @@ -32,7 +33,7 @@ export type BetInfo = { export const getNewBinaryCpmmBetInfo = ( outcome: 'YES' | 'NO', amount: number, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, loanAmount: number ) => { const { shares, newPool, newP, fees } = calculateCpmmPurchase( diff --git a/common/new-contract.ts b/common/new-contract.ts index 0b7d294a..6c89c8c4 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -7,6 +7,7 @@ import { FreeResponse, Numeric, outcomeType, + PseudoNumeric, } from './contract' import { User } from './user' import { parseTags } from './util/parse' @@ -27,7 +28,8 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + isLogScale: boolean ) { const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` @@ -37,6 +39,8 @@ export function getNewContract( const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) + : outcomeType === 'PSEUDO_NUMERIC' + ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) : outcomeType === 'NUMERIC' ? getNumericProps(ante, bucketCount, min, max) : getFreeAnswerProps(ante) @@ -111,6 +115,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { return system } +const getPseudoNumericCpmmProps = ( + initialProb: number, + ante: number, + min: number, + max: number, + isLogScale: boolean +) => { + const system: CPMM & PseudoNumeric = { + ...getBinaryCpmmProps(initialProb, ante), + outcomeType: 'PSEUDO_NUMERIC', + min, + max, + isLogScale, + } + + return system +} + const getFreeAnswerProps = (ante: number) => { const system: DPM & FreeResponse = { mechanism: 'dpm-2', diff --git a/common/payouts.ts b/common/payouts.ts index f2c8d271..1469cf4e 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,7 +1,12 @@ import { sumBy, groupBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' -import { Contract, CPMMBinaryContract, DPMContract } from './contract' +import { + Contract, + CPMMBinaryContract, + DPMContract, + PseudoNumericContract, +} from './contract' import { Fees } from './fees' import { LiquidityProvision } from './liquidity-provision' import { @@ -56,7 +61,11 @@ export const getPayouts = ( }, resolutionProbability?: number ): PayoutInfo => { - if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { + if ( + contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') + ) { return getFixedPayouts( outcome, contract, @@ -76,7 +85,7 @@ export const getPayouts = ( export const getFixedPayouts = ( outcome: string | undefined, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, bets: Bet[], liquidities: LiquidityProvision[], resolutionProbability?: number diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts new file mode 100644 index 00000000..9a322e35 --- /dev/null +++ b/common/pseudo-numeric.ts @@ -0,0 +1,45 @@ +import { BinaryContract, PseudoNumericContract } from './contract' +import { formatLargeNumber, formatPercent } from './util/format' + +export function formatNumericProbability( + p: number, + contract: PseudoNumericContract +) { + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) +} + +export const getMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return p + + const { min, max, isLogScale } = contract + + if (isLogScale) { + const logValue = p * Math.log10(max - min) + return 10 ** logValue + min + } + + return p * (max - min) + min + } + +export const getFormattedMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return formatPercent(p) + + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) + } + +export const getPseudoProbability = ( + value: number, + min: number, + max: number, + isLogScale = false +) => { + if (isLogScale) { + return Math.log10(value - min) / Math.log10(max - min) + } + + return (value - min) / (max - min) +} diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index c9468fdc..0d78ab5c 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -28,6 +28,7 @@ import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' import { Group, MAX_ID_LENGTH } from '../../common/group' +import { getPseudoProbability } from '../../common/pseudo-numeric' const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), @@ -45,19 +46,31 @@ const binarySchema = z.object({ initialProb: z.number().min(1).max(99), }) +const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER) + const numericSchema = z.object({ - min: z.number(), - max: z.number(), + min: finite(), + max: finite(), + initialValue: finite(), + isLogScale: z.boolean().optional(), }) export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) - let min, max, initialProb - if (outcomeType === 'NUMERIC') { - ;({ min, max } = validate(numericSchema, req.body)) - if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') + let min, max, initialProb, isLogScale + + if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { + let initialValue + ;({ min, max, initialValue, isLogScale } = validate( + numericSchema, + req.body + )) + if (max - min <= 0.01 || initialValue < min || initialValue > max) + throw new APIError(400, 'Invalid range.') + + initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 } if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) @@ -121,7 +134,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0 + max ?? 0, + isLogScale ?? false ) if (ante) await chargeUser(user.id, ante, true) @@ -130,7 +144,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { const providerId = user.id - if (outcomeType === 'BINARY') { + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore .collection(`contracts/${contract.id}/liquidity`) .doc() diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 1ba8ca96..40e8900c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -6,8 +6,13 @@ import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' -import { formatMoney, formatPercent } from '../../common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from '../../common/util/format' import { getValueFromBucket } from '../../common/calculate-dpm' +import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' @@ -101,6 +106,17 @@ const toDisplayResolution = ( return display || resolution } + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + const { resolutionValue } = contract + + return resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? getProbability(contract), + contract + ) + } + if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'CANCEL') return 'N/A' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 06d27668..b6c7d267 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -70,7 +70,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { const { outcome } = validate(binarySchema, req.body) return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) - } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { + } else if ( + (outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && + mechanism == 'cpmm-1' + ) { const { outcome } = validate(binarySchema, req.body) return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index bdd3ab94..67922a65 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -16,7 +16,11 @@ export const redeemShares = async (userId: string, contractId: string) => { return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') + const { mechanism, outcomeType } = contract + if ( + !(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') || + mechanism !== 'cpmm-1' + ) return { status: 'success' } const betsSnap = await transaction.get( diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index ee78dfec..f8976cb3 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -27,7 +27,7 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(RESOLUTIONS), - probabilityInt: z.number().gte(0).lt(100).optional(), + probabilityInt: z.number().gte(0).lte(100).optional(), }) const freeResponseSchema = z.union([ @@ -39,7 +39,7 @@ const freeResponseSchema = z.union([ resolutions: z.array( z.object({ answer: z.number().int().nonnegative(), - pct: z.number().gte(0).lt(100), + pct: z.number().gte(0).lte(100), }) ), }), @@ -53,7 +53,19 @@ const numericSchema = z.object({ value: z.number().optional(), }) +const pseudoNumericSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + value: z.number(), + probabilityInt: z.number().gte(0).lte(100), + }), +]) + const opts = { secrets: ['MAILGUN_KEY'] } + export const resolvemarket = newEndpoint(opts, async (req, auth) => { const { contractId } = validate(bodySchema, req.body) const userId = auth.uid @@ -221,12 +233,18 @@ const sendResolutionEmails = async ( function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract + if (outcomeType === 'NUMERIC') { return { ...validate(numericSchema, body), resolutions: undefined, probabilityInt: undefined, } + } else if (outcomeType === 'PSEUDO_NUMERIC') { + return { + ...validate(pseudoNumericSchema, body), + resolutions: undefined, + } } else if (outcomeType === 'FREE_RESPONSE') { const freeResponseParams = validate(freeResponseSchema, body) const { outcome } = freeResponseParams diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 73055872..f76117b9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' -import { BinaryContract, CPMMBinaryContract } from 'common/contract' +import { + BinaryContract, + CPMMBinaryContract, + PseudoNumericContract, +} from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' @@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' import { calculatePayoutAfterCorrectBet, calculateShares, @@ -35,6 +39,7 @@ import { getCpmmProbability, getCpmmLiquidityFee, } from 'common/calculate-cpmm' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveShares } from './use-save-shares' import { SignUpPrompt } from './sign-up-prompt' @@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device' import { track } from 'web/lib/service/analytics' export function BetPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string }) { const { contract, className } = props @@ -81,7 +86,7 @@ export function BetPanel(props: { } export function BetPanelSwitcher(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string title?: string // Set if BetPanel is on a feed modal selected?: 'YES' | 'NO' @@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: { }) { const { contract, className, title, selected, onBetSuccess } = props - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) @@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: { <Row className="items-center justify-between gap-2"> <div> You have {formatWithCommas(floorShares)}{' '} - <BinaryOutcomeLabel outcome={sharesOutcome} /> shares + {isPseudoNumeric ? ( + <PseudoNumericOutcomeLabel outcome={sharesOutcome} /> + ) : ( + <BinaryOutcomeLabel outcome={sharesOutcome} /> + )}{' '} + shares </div> {tradeType === 'BUY' && ( @@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: { } function BuyPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { const { contract, user, selected, onBuySuccess } = props + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState<number | undefined>(undefined) @@ -302,6 +314,9 @@ function BuyPanel(props: { : 0) )} ${betChoice ?? 'YES'} shares` : undefined + + const format = getFormattedMappedValue(contract) + return ( <> <YesNoSelector @@ -309,6 +324,7 @@ function BuyPanel(props: { btnClassName="flex-1" selected={betChoice} onSelect={(choice) => onBetChoice(choice)} + isPseudoNumeric={isPseudoNumeric} /> <div className="my-3 text-left text-sm text-gray-500">Amount</div> <BuyAmountInput @@ -323,11 +339,13 @@ function BuyPanel(props: { <Col className="mt-3 w-full gap-3"> <Row className="items-center justify-between text-sm"> - <div className="text-gray-500">Probability</div> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> <div> - {formatPercent(initialProb)} + {format(initialProb)} <span className="mx-2">→</span> - {formatPercent(resultProb)} + {format(resultProb)} </div> </Row> @@ -340,6 +358,8 @@ function BuyPanel(props: { <br /> payout if{' '} <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> </> + ) : isPseudoNumeric ? ( + 'Max payout' ) : ( <> Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} /> @@ -389,7 +409,7 @@ function BuyPanel(props: { } export function SellPanel(props: { - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' @@ -488,6 +508,10 @@ export function SellPanel(props: { } } + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const format = getFormattedMappedValue(contract) + return ( <> <AmountInput @@ -511,11 +535,13 @@ export function SellPanel(props: { <span className="text-neutral">{formatMoney(saleValue)}</span> </Row> <Row className="items-center justify-between"> - <div className="text-gray-500">Probability</div> + <div className="text-gray-500"> + {isPseudoNumeric ? 'Estimated value' : 'Probability'} + </div> <div> - {formatPercent(initialProb)} + {format(initialProb)} <span className="mx-2">→</span> - {formatPercent(resultProb)} + {format(resultProb)} </div> </Row> </Col> diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 9621f7a9..ae5e0b00 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' import { BetPanelSwitcher } from './bet-panel' import { YesNoSelector } from './yes-no-selector' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' @@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string btnClassName?: string betPanelClassName?: string @@ -32,6 +32,7 @@ export default function BetRow(props: { return ( <> <YesNoSelector + isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'} className={clsx('justify-end', className)} btnClassName={clsx('btn-sm w-24', btnClassName)} onSelect={(choice) => { diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index f41f89b6..b8fb7d31 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { + formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -40,6 +41,7 @@ import { import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' +import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { SellSharesModal } from './sell-modal' @@ -366,6 +368,7 @@ export function BetsSummary(props: { const { contract, isYourBets, className } = props const { resolution, closeTime, outcomeType, mechanism } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isCpmm = mechanism === 'cpmm-1' const isClosed = closeTime && Date.now() > closeTime @@ -427,6 +430,25 @@ export function BetsSummary(props: { </div> </Col> </> + ) : isPseudoNumeric ? ( + <> + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Payout if {'>='} {formatLargeNumber(contract.max)} + </div> + <div className="whitespace-nowrap"> + {formatMoney(yesWinnings)} + </div> + </Col> + <Col> + <div className="whitespace-nowrap text-sm text-gray-500"> + Payout if {'<='} {formatLargeNumber(contract.min)} + </div> + <div className="whitespace-nowrap"> + {formatMoney(noWinnings)} + </div> + </Col> + </> ) : ( <Col> <div className="whitespace-nowrap text-sm text-gray-500"> @@ -507,13 +529,15 @@ export function ContractBetsTable(props: { const { isResolved, mechanism, outcomeType } = contract const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( <div className={clsx('overflow-x-auto', className)}> {amountRedeemed > 0 && ( <> <div className="pl-2 text-sm text-gray-500"> - {amountRedeemed} YES shares and {amountRedeemed} NO shares + {amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '} + {amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares automatically redeemed for {formatMoney(amountRedeemed)}. </div> <Spacer h={4} /> @@ -541,7 +565,7 @@ export function ContractBetsTable(props: { )} {!isCPMM && !isResolved && <th>Payout if chosen</th>} <th>Shares</th> - <th>Probability</th> + {!isPseudoNumeric && <th>Probability</th>} <th>Date</th> </tr> </thead> @@ -585,6 +609,7 @@ function BetRow(props: { const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const saleAmount = saleBet?.sale?.amount @@ -628,14 +653,18 @@ function BetRow(props: { truncate="short" /> )} + {isPseudoNumeric && + ' than ' + formatNumericProbability(bet.probAfter, contract)} </td> <td>{formatMoney(Math.abs(amount))}</td> {!isCPMM && !isNumeric && <td>{saleDisplay}</td>} {!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>} <td>{formatWithCommas(Math.abs(shares))}</td> - <td> - {formatPercent(probBefore)} → {formatPercent(probAfter)} - </td> + {!isPseudoNumeric && ( + <td> + {formatPercent(probBefore)} → {formatPercent(probAfter)} + </td> + )} <td>{dayjs(createdTime).format('MMM D, h:mma')}</td> </tr> ) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 87239465..c6cda43c 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -9,6 +9,7 @@ import { BinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from 'common/contract' import { AnswerLabel, @@ -16,7 +17,11 @@ import { CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' -import { getOutcomeProbability, getTopAnswer } from 'common/calculate' +import { + getOutcomeProbability, + getProbability, + getTopAnswer, +} from 'common/calculate' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { QuickBet, ProbBar, getColor } from './quick-bet' @@ -24,6 +29,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract' import { useUser } from 'web/hooks/use-user' import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' export function ContractCard(props: { contract: Contract @@ -131,6 +137,13 @@ export function ContractCard(props: { /> )} + {outcomeType === 'PSEUDO_NUMERIC' && ( + <PseudoNumericResolutionOrExpectation + className="items-center" + contract={contract} + /> + )} + {outcomeType === 'NUMERIC' && ( <NumericResolutionOrExpectation className="items-center" @@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: { {resolution === 'CANCEL' ? ( <CancelLabel /> ) : ( - <div className="text-blue-400">{resolutionValue}</div> + <div className="text-blue-400"> + {formatLargeNumber(resolutionValue)} + </div> )} </> ) : ( @@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: { </Col> ) } + +export function PseudoNumericResolutionOrExpectation(props: { + contract: PseudoNumericContract + className?: string +}) { + const { contract, className } = props + const { resolution, resolutionValue, resolutionProbability } = contract + const textColor = `text-blue-400` + + return ( + <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> + {resolution ? ( + <> + <div className={clsx('text-base text-gray-500')}>Resolved</div> + + {resolution === 'CANCEL' ? ( + <CancelLabel /> + ) : ( + <div className="text-blue-400"> + {resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? 0, + contract + )} + </div> + )} + </> + ) : ( + <> + <div className={clsx('text-3xl', textColor)}> + {formatNumericProbability(getProbability(contract), contract)} + </div> + <div className={clsx('text-base', textColor)}>expected</div> + </> + )} + </Col> + ) +} diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a68f37be..897bef04 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -11,6 +11,7 @@ import { FreeResponseResolutionOrChance, BinaryResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' import BetRow from '../bet-row' @@ -32,6 +33,7 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( <Col className={clsx('mb-6', className)}> @@ -49,6 +51,13 @@ export const ContractOverview = (props: { /> )} + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation + contract={contract} + className="hidden items-end xl:flex" + /> + )} + {outcomeType === 'NUMERIC' && ( <NumericResolutionOrExpectation contract={contract} @@ -61,6 +70,11 @@ export const ContractOverview = (props: { <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> + {tradingAllowed(contract) && <BetRow contract={contract} />} + </Row> + ) : isPseudoNumeric ? ( + <Row className="items-center justify-between gap-4 xl:hidden"> + <PseudoNumericResolutionOrExpectation contract={contract} /> {tradingAllowed(contract) && <BetRow contract={contract} />} </Row> ) : ( @@ -86,7 +100,9 @@ export const ContractOverview = (props: { /> </Col> <Spacer h={4} /> - {isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '} + {(isBinary || isPseudoNumeric) && ( + <ContractProbGraph contract={contract} bets={bets} /> + )}{' '} {outcomeType === 'FREE_RESPONSE' && ( <AnswersGraph contract={contract} bets={bets} /> )} diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 7386d748..a9d26e2e 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -5,16 +5,20 @@ import dayjs from 'dayjs' import { memo } from 'react' import { Bet } from 'common/bet' import { getInitialProbability } from 'common/calculate' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { useWindowSize } from 'web/hooks/use-window-size' +import { getMappedValue } from 'common/pseudo-numeric' +import { formatLargeNumber } from 'common/util/format' export const ContractProbGraph = memo(function ContractProbGraph(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract bets: Bet[] height?: number }) { const { contract, height } = props - const { resolutionTime, closeTime } = contract + const { resolutionTime, closeTime, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) @@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { contract.createdTime, ...bets.map((bet) => bet.createdTime), ].map((time) => new Date(time)) - const probs = [startProb, ...bets.map((bet) => bet.probAfter)] + + const f = getMappedValue(contract) + + const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) const isClosed = !!closeTime && Date.now() > closeTime const latestTime = dayjs( @@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { times.push(latestTime.toDate()) probs.push(probs[probs.length - 1]) - const yTickValues = [0, 25, 50, 75, 100] + const quartiles = [0, 25, 50, 75, 100] + + const yTickValues = isBinary + ? quartiles + : quartiles.map((x) => x / 100).map(f) const { width } = useWindowSize() @@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const totalPoints = width ? (width > 800 ? 300 : 50) : 1 const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints + const points: { x: Date; y: number }[] = [] + const s = isBinary ? 100 : 1 + const c = isLogScale && contract.min === 0 ? 1 : 0 + for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: probs[i] * 100 } + points[points.length] = { x: times[i], y: s * probs[i] + c } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep ) @@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { x: dayjs(times[i]) .add(thisTimeStep * n, 'ms') .toDate(), - y: probs[i] * 100, + y: s * probs[i] + c, } } } } - const data = [{ id: 'Yes', data: points, color: '#11b981' }] + const data = [ + { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, + ] const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) + const formatter = isBinary + ? formatPercent + : (x: DatumValue) => formatLargeNumber(+x.valueOf()) + return ( <div className="w-full overflow-visible" @@ -87,12 +108,20 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { > <ResponsiveLine data={data} - yScale={{ min: 0, max: 100, type: 'linear' }} - yFormat={formatPercent} + yScale={ + isBinary + ? { min: 0, max: 100, type: 'linear' } + : { + min: contract.min + c, + max: contract.max + c, + type: contract.isLogScale ? 'log' : 'linear', + } + } + yFormat={formatter} gridYValues={yTickValues} axisLeft={{ tickValues: yTickValues, - format: formatPercent, + format: formatter, }} xScale={{ type: 'time', diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 9ee8b165..adbcc456 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import { getOutcomeProbability, getOutcomeProbabilityAfterBet, + getProbability, getTopAnswer, } from 'common/calculate' import { getExpectedValue } from 'common/calculate-dpm' @@ -25,18 +26,18 @@ import { useSaveShares } from '../use-save-shares' import { sellShares } from 'web/lib/firebase/api-call' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { track } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' const BET_SIZE = 10 export function QuickBet(props: { contract: Contract; user: User }) { const { contract, user } = props - const isCpmm = contract.mechanism === 'cpmm-1' + const { mechanism, outcomeType } = contract + const isCpmm = mechanism === 'cpmm-1' const userBets = useUserContractBets(user.id, contract.id) const topAnswer = - contract.outcomeType === 'FREE_RESPONSE' - ? getTopAnswer(contract) - : undefined + outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined // TODO: yes/no from useSaveShares doesn't work on numeric contracts const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( @@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) { topAnswer?.number.toString() || undefined ) const hasUpShares = - yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC') + yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC') const hasDownShares = - noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC' + noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC' const [upHover, setUpHover] = useState(false) const [downHover, setDownHover] = useState(false) @@ -130,25 +131,6 @@ export function QuickBet(props: { contract: Contract; user: User }) { }) } - function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { - if (contract.outcomeType === 'BINARY') { - return direction === 'UP' ? 'YES' : 'NO' - } - if (contract.outcomeType === 'FREE_RESPONSE') { - // TODO: Implement shorting of free response answers - if (direction === 'DOWN') { - throw new Error("Can't bet against free response answers") - } - return getTopAnswer(contract)?.id - } - if (contract.outcomeType === 'NUMERIC') { - // TODO: Ideally an 'UP' bet would be a uniform bet between [current, max] - throw new Error("Can't quick bet on numeric markets") - } - } - - const textColor = `text-${getColor(contract)}` - return ( <Col className={clsx( @@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) { <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', - upHover ? textColor : 'text-gray-400' + upHover ? 'text-green-500' : 'text-gray-400' )} /> ) : ( <TriangleFillIcon className={clsx( 'mx-auto h-5 w-5', - upHover ? textColor : 'text-gray-200' + upHover ? 'text-green-500' : 'text-gray-200' )} /> )} @@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { <QuickOutcomeView contract={contract} previewProb={previewProb} /> {/* Down bet triangle */} - {contract.outcomeType !== 'BINARY' ? ( + {outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? ( <div> <div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div> <TriangleDownFillIcon @@ -254,6 +236,25 @@ export function ProbBar(props: { contract: Contract; previewProb?: number }) { ) } +function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { + const { outcomeType } = contract + + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { + return direction === 'UP' ? 'YES' : 'NO' + } + if (outcomeType === 'FREE_RESPONSE') { + // TODO: Implement shorting of free response answers + if (direction === 'DOWN') { + throw new Error("Can't bet against free response answers") + } + return getTopAnswer(contract)?.id + } + if (outcomeType === 'NUMERIC') { + // TODO: Ideally an 'UP' bet would be a uniform bet between [current, max] + throw new Error("Can't quick bet on numeric markets") + } +} + function QuickOutcomeView(props: { contract: Contract previewProb?: number @@ -261,9 +262,16 @@ function QuickOutcomeView(props: { }) { const { contract, previewProb, caption } = props const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + // If there's a preview prob, display that instead of the current prob const override = - previewProb === undefined ? undefined : formatPercent(previewProb) + previewProb === undefined + ? undefined + : isPseudoNumeric + ? formatNumericProbability(previewProb, contract) + : formatPercent(previewProb) + const textColor = `text-${getColor(contract)}` let display: string | undefined @@ -271,6 +279,9 @@ function QuickOutcomeView(props: { case 'BINARY': display = getBinaryProbPercent(contract) break + case 'PSEUDO_NUMERIC': + display = formatNumericProbability(getProbability(contract), contract) + break case 'NUMERIC': display = formatLargeNumber(getExpectedValue(contract)) break @@ -295,11 +306,15 @@ function QuickOutcomeView(props: { // Return a number from 0 to 1 for this contract // Resolved contracts are set to 1, for coloring purposes (even if NO) function getProb(contract: Contract) { - const { outcomeType, resolution } = contract - return resolution + const { outcomeType, resolution, resolutionProbability } = contract + return resolutionProbability + ? resolutionProbability + : resolution ? 1 : outcomeType === 'BINARY' ? getBinaryProb(contract) + : outcomeType === 'PSEUDO_NUMERIC' + ? getProbability(contract) : outcomeType === 'FREE_RESPONSE' ? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '') : outcomeType === 'NUMERIC' @@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) { export function getColor(contract: Contract) { // TODO: Try injecting a gradient here // return 'primary' - const { resolution } = contract + const { resolution, outcomeType } = contract + if (resolution) { return ( OUTCOME_TO_COLOR[resolution as resolution] ?? @@ -325,6 +341,8 @@ export function getColor(contract: Contract) { ) } + if (outcomeType === 'PSEUDO_NUMERIC') return 'blue-400' + if ((contract.closeTime ?? Infinity) < Date.now()) { return 'gray-400' } diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index ae22b4b8..2ffdae8e 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -7,13 +7,14 @@ import { Row } from 'web/components/layout/row' import { Avatar, EmptyAvatar } from 'web/components/avatar' import clsx from 'clsx' import { UsersIcon } from '@heroicons/react/solid' -import { formatMoney } from 'common/util/format' +import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React, { Fragment } from 'react' import { uniqBy, partition, sumBy, groupBy } from 'lodash' import { JoinSpans } from 'web/components/join-spans' import { UserLink } from '../user-page' +import { formatNumericProbability } from 'common/pseudo-numeric' export function FeedBet(props: { contract: Contract @@ -75,6 +76,8 @@ export function BetStatusText(props: { hideOutcome?: boolean }) { const { bet, contract, bettor, isSelf, hideOutcome } = props + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const { amount, outcome, createdTime } = bet const bought = amount >= 0 ? 'bought' : 'sold' @@ -97,7 +100,10 @@ export function BetStatusText(props: { value={(bet as any).value} contract={contract} truncate="short" - /> + />{' '} + {isPseudoNumeric + ? ' than ' + formatNumericProbability(bet.probAfter, contract) + : ' at ' + formatPercent(bet.probAfter)} </> )} <RelativeTimestamp time={createdTime} /> diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index ebac68e5..cf111281 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users' import { NumberCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' +import { NumericContract, PseudoNumericContract } from 'common/contract' import { APIError, resolveMarket } from 'web/lib/firebase/api-call' -import { NumericContract } from 'common/contract' import { BucketInput } from './bucket-input' +import { getPseudoProbability } from 'common/pseudo-numeric' export function NumericResolutionPanel(props: { creator: User - contract: NumericContract + contract: NumericContract | PseudoNumericContract className?: string }) { useEffect(() => { @@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: { }, []) const { contract, className } = props + const { min, max, outcomeType } = contract const [outcomeMode, setOutcomeMode] = useState< 'NUMBER' | 'CANCEL' | undefined @@ -32,15 +34,32 @@ export function NumericResolutionPanel(props: { const [error, setError] = useState<string | undefined>(undefined) const resolve = async () => { - const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL' + const finalOutcome = + outcomeMode === 'CANCEL' + ? 'CANCEL' + : outcomeType === 'PSEUDO_NUMERIC' + ? 'MKT' + : 'NUMBER' if (outcomeMode === undefined || finalOutcome === undefined) return setIsSubmitting(true) + const boundedValue = Math.max(Math.min(max, value ?? 0), min) + + const probabilityInt = + 100 * + getPseudoProbability( + boundedValue, + min, + max, + outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale + ) + try { const result = await resolveMarket({ outcome: finalOutcome, value, + probabilityInt, contractId: contract.id, }) console.log('resolved', outcome, 'result:', result) @@ -77,7 +96,7 @@ export function NumericResolutionPanel(props: { {outcomeMode === 'NUMBER' && ( <BucketInput - contract={contract} + contract={contract as any} isSubmitting={isSubmitting} onBucketChange={(v, o) => (setValue(v), setOutcome(o))} /> diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 054ebfd2..a94618e4 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -19,11 +19,15 @@ export function OutcomeLabel(props: { value?: number }) { const { outcome, contract, truncate, value } = props + const { outcomeType } = contract - if (contract.outcomeType === 'BINARY') + if (outcomeType === 'PSEUDO_NUMERIC') + return <PseudoNumericOutcomeLabel outcome={outcome as any} /> + + if (outcomeType === 'BINARY') return <BinaryOutcomeLabel outcome={outcome as any} /> - if (contract.outcomeType === 'NUMERIC') + if (outcomeType === 'NUMERIC') return ( <span className="text-blue-500"> {value ?? getValueFromBucket(outcome, contract)} @@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) { return <CancelLabel /> } +export function PseudoNumericOutcomeLabel(props: { outcome: resolution }) { + const { outcome } = props + + if (outcome === 'YES') return <HigherLabel /> + if (outcome === 'NO') return <LowerLabel /> + if (outcome === 'MKT') return <ProbLabel /> + return <CancelLabel /> +} + export function BinaryContractOutcomeLabel(props: { contract: BinaryContract resolution: resolution @@ -98,6 +111,14 @@ export function YesLabel() { return <span className="text-primary">YES</span> } +export function HigherLabel() { + return <span className="text-primary">HIGHER</span> +} + +export function LowerLabel() { + return <span className="text-red-400">LOWER</span> +} + export function NoLabel() { return <span className="text-red-400">NO</span> } diff --git a/web/components/sell-button.tsx b/web/components/sell-button.tsx index 2b3734a5..51c88442 100644 --- a/web/components/sell-button.tsx +++ b/web/components/sell-button.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useState } from 'react' @@ -7,7 +7,7 @@ import clsx from 'clsx' import { SellSharesModal } from './sell-modal' export function SellButton(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined sharesOutcome: 'YES' | 'NO' | undefined shares: number @@ -16,7 +16,8 @@ export function SellButton(props: { const { contract, user, sharesOutcome, shares, panelClassName } = props const userBets = useUserContractBets(user?.id, contract.id) const [showSellModal, setShowSellModal] = useState(false) - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' if (sharesOutcome && user && mechanism === 'cpmm-1') { return ( @@ -32,7 +33,10 @@ export function SellButton(props: { )} onClick={() => setShowSellModal(true)} > - {'Sell ' + sharesOutcome} + Sell{' '} + {isPseudoNumeric + ? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome] + : sharesOutcome} </button> <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> {'(' + Math.floor(shares) + ' shares)'} diff --git a/web/components/sell-modal.tsx b/web/components/sell-modal.tsx index f5a1af67..63cf79b2 100644 --- a/web/components/sell-modal.tsx +++ b/web/components/sell-modal.tsx @@ -1,4 +1,4 @@ -import { CPMMBinaryContract } from 'common/contract' +import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { Bet } from 'common/bet' import { User } from 'common/user' import { Modal } from './layout/modal' @@ -11,7 +11,7 @@ import clsx from 'clsx' export function SellSharesModal(props: { className?: string - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx index 4fe2536f..a8cb2851 100644 --- a/web/components/sell-row.tsx +++ b/web/components/sell-row.tsx @@ -1,4 +1,4 @@ -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { User } from 'common/user' import { useState } from 'react' import { Col } from './layout/col' @@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares' import { SellSharesModal } from './sell-modal' export function SellRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined className?: string }) { diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index d040eba9..cac7bf74 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -12,6 +12,7 @@ export function YesNoSelector(props: { btnClassName?: string replaceYesButton?: React.ReactNode replaceNoButton?: React.ReactNode + isPseudoNumeric?: boolean }) { const { selected, @@ -20,6 +21,7 @@ export function YesNoSelector(props: { btnClassName, replaceNoButton, replaceYesButton, + isPseudoNumeric, } = props const commonClassNames = @@ -41,7 +43,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('YES')} > - Bet YES + {isPseudoNumeric ? 'HIGHER' : 'Bet YES'} </button> )} {replaceNoButton ? ( @@ -58,7 +60,7 @@ export function YesNoSelector(props: { )} onClick={() => onSelect('NO')} > - Bet NO + {isPseudoNumeric ? 'LOWER' : 'Bet NO'} </button> )} </Row> diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 413de725..2576c2e3 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -144,10 +144,12 @@ export function ContractPageContent( const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isNumeric = outcomeType === 'NUMERIC' const allowTrade = tradingAllowed(contract) const allowResolve = !isResolved && isCreator && !!user - const hasSidePanel = (isBinary || isNumeric) && (allowTrade || allowResolve) + const hasSidePanel = + (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) const ogCardProps = getOpenGraphProps(contract) @@ -170,7 +172,7 @@ export function ContractPageContent( <BetPanel className="hidden xl:flex" contract={contract} /> ))} {allowResolve && - (isNumeric ? ( + (isNumeric || isPseudoNumeric ? ( <NumericResolutionPanel creator={user} contract={contract} /> ) : ( <ResolutionPanel creator={user} contract={contract} /> @@ -210,10 +212,11 @@ export function ContractPageContent( )} <ContractOverview contract={contract} bets={bets} /> + {isNumeric && ( <AlertBox title="Warning" - text="Numeric markets were introduced as an experimental feature and are now deprecated." + text="Distributional numeric markets were introduced as an experimental feature and are now deprecated." /> )} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 7d645b04..c7b8f02e 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -85,8 +85,12 @@ export function NewContract(props: { const { creator, question, groupId } = props const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') const [initialProb] = useState(50) + const [minString, setMinString] = useState('') const [maxString, setMaxString] = useState('') + const [isLogScale, setIsLogScale] = useState(false) + const [initialValueString, setInitialValueString] = useState('') + const [description, setDescription] = useState('') // const [tagText, setTagText] = useState<string>(tag ?? '') // const tags = parseWordsAsTags(tagText) @@ -129,6 +133,18 @@ export function NewContract(props: { const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined + const initialValue = initialValueString + ? parseFloat(initialValueString) + : undefined + + const adjustIsLog = () => { + if (min === undefined || max === undefined) return + const lengthDiff = Math.log10(max - min) + if (lengthDiff > 2) { + setIsLogScale(true) + } + } + // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') @@ -145,13 +161,16 @@ export function NewContract(props: { // closeTime must be in the future closeTime && closeTime > Date.now() && - (outcomeType !== 'NUMERIC' || + (outcomeType !== 'PSEUDO_NUMERIC' || (min !== undefined && max !== undefined && + initialValue !== undefined && isFinite(min) && isFinite(max) && min < max && - max - min > 0.01)) + max - min > 0.01 && + min < initialValue && + initialValue < max)) function setCloseDateInDays(days: number) { const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') @@ -175,6 +194,8 @@ export function NewContract(props: { closeTime, min, max, + initialValue, + isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, tags: category ? [category] : undefined, }) @@ -220,6 +241,7 @@ export function NewContract(props: { choicesMap={{ 'Yes / No': 'BINARY', 'Free response': 'FREE_RESPONSE', + Numeric: 'PSEUDO_NUMERIC', }} isSubmitting={isSubmitting} className={'col-span-4'} @@ -232,38 +254,89 @@ export function NewContract(props: { <Spacer h={6} /> - {outcomeType === 'NUMERIC' && ( - <div className="form-control items-start"> - <label className="label gap-2"> - <span className="mb-1">Range</span> - <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> - </label> + {outcomeType === 'PSEUDO_NUMERIC' && ( + <> + <div className="form-control mb-2 items-start"> + <label className="label gap-2"> + <span className="mb-1">Range</span> + <InfoTooltip text="The minimum and maximum numbers across the numeric range." /> + </label> - <Row className="gap-2"> - <input - type="number" - className="input input-bordered" - placeholder="MIN" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setMinString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={minString ?? ''} - /> - <input - type="number" - className="input input-bordered" - placeholder="MAX" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setMaxString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={maxString} - /> - </Row> - </div> + <Row className="gap-2"> + <input + type="number" + className="input input-bordered" + placeholder="MIN" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setMinString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={minString ?? ''} + /> + <input + type="number" + className="input input-bordered" + placeholder="MAX" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setMaxString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={maxString} + /> + </Row> + + {!(min !== undefined && min < 0) && ( + <Row className="mt-1 ml-2 mb-2 items-center"> + <span className="mr-2 text-sm">Log scale</span>{' '} + <input + type="checkbox" + checked={isLogScale} + onChange={() => setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> + </Row> + )} + + {min !== undefined && max !== undefined && min >= max && ( + <div className="mt-2 mb-2 text-sm text-red-500"> + The maximum value must be greater than the minimum. + </div> + )} + </div> + <div className="form-control mb-2 items-start"> + <label className="label gap-2"> + <span className="mb-1">Initial value</span> + <InfoTooltip text="The starting value for this market. Should be in between min and max values." /> + </label> + + <Row className="gap-2"> + <input + type="number" + className="input input-bordered" + placeholder="Initial value" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setInitialValueString(e.target.value)} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={initialValueString ?? ''} + /> + </Row> + + {initialValue !== undefined && + min !== undefined && + max !== undefined && + min < max && + (initialValue <= min || initialValue >= max) && ( + <div className="mt-2 mb-2 text-sm text-red-500"> + Initial value must be in between {min} and {max}.{' '} + </div> + )} + </div> + </> )} <div className="form-control max-w-[265px] items-start"> diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 98bf37b2..93439be7 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -7,6 +7,7 @@ import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { ContractDetails } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' @@ -79,6 +80,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { question, outcomeType } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const href = `https://${DOMAIN}${contractPath(contract)}` @@ -110,13 +112,18 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( <Row className="items-center gap-4"> - {/* this fails typechecking, but it doesn't explode because we will - never */} - <BetRow contract={contract as any} betPanelClassName="scale-75" /> + <BetRow contract={contract} betPanelClassName="scale-75" /> <BinaryResolutionOrChance contract={contract} /> </Row> )} + {isPseudoNumeric && ( + <Row className="items-center gap-4"> + <BetRow contract={contract} betPanelClassName="scale-75" /> + <PseudoNumericResolutionOrExpectation contract={contract} /> + </Row> + )} + {outcomeType === 'FREE_RESPONSE' && ( <FreeResponseResolutionOrChance contract={contract} @@ -133,7 +140,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { </div> <div className="mx-1" style={{ paddingBottom }}> - {isBinary && ( + {(isBinary || isPseudoNumeric) && ( <ContractProbGraph contract={contract} bets={bets} From 218b18254cf33577369a3b2dbb2ee75145789ae2 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sat, 2 Jul 2022 15:46:32 -0400 Subject: [PATCH 06/39] add liquidity: support pseudo numeric markets --- functions/src/add-liquidity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index 34d3f7c6..eca0a056 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall( const contract = contractSnap.data() as Contract if ( contract.mechanism !== 'cpmm-1' || - contract.outcomeType !== 'BINARY' + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') ) return { status: 'error', message: 'Invalid contract' } From 18b87581916ace525dc256a176b12b0d9c96b886 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 13:26:42 -0700 Subject: [PATCH 07/39] Remove code for obsolete feed updater backend jobs (#607) * Remove code for obsolete feed updater backend jobs * Kill two more obsolete guys --- functions/package.json | 1 - functions/src/call-cloud-function.ts | 17 -- functions/src/fetch.ts | 9 - functions/src/keep-awake.ts | 25 --- functions/src/scripts/update-feed.ts | 53 ------ functions/src/update-feed.ts | 220 ------------------------ functions/src/update-recommendations.ts | 70 -------- yarn.lock | 29 +--- 8 files changed, 1 insertion(+), 423 deletions(-) delete mode 100644 functions/src/call-cloud-function.ts delete mode 100644 functions/src/fetch.ts delete mode 100644 functions/src/keep-awake.ts delete mode 100644 functions/src/scripts/update-feed.ts delete mode 100644 functions/src/update-feed.ts delete mode 100644 functions/src/update-recommendations.ts diff --git a/functions/package.json b/functions/package.json index eb6c7151..ed12b4e7 100644 --- a/functions/package.json +++ b/functions/package.json @@ -23,7 +23,6 @@ "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", - "fetch": "1.1.0", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/functions/src/call-cloud-function.ts b/functions/src/call-cloud-function.ts deleted file mode 100644 index 35191343..00000000 --- a/functions/src/call-cloud-function.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as admin from 'firebase-admin' - -import fetch from './fetch' - -export const callCloudFunction = (functionName: string, data: unknown = {}) => { - const projectId = admin.instanceId().app.options.projectId - - const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}` - - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data }), - }).then((response) => response.json()) -} diff --git a/functions/src/fetch.ts b/functions/src/fetch.ts deleted file mode 100644 index 1b54dc6c..00000000 --- a/functions/src/fetch.ts +++ /dev/null @@ -1,9 +0,0 @@ -let fetchRequest: typeof fetch - -try { - fetchRequest = fetch -} catch { - fetchRequest = require('node-fetch') -} - -export default fetchRequest diff --git a/functions/src/keep-awake.ts b/functions/src/keep-awake.ts deleted file mode 100644 index 00799e65..00000000 --- a/functions/src/keep-awake.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as functions from 'firebase-functions' - -import { callCloudFunction } from './call-cloud-function' - -export const keepAwake = functions.pubsub - .schedule('every 1 minutes') - .onRun(async () => { - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - - await sleep(30) - - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - }) - -const sleep = (seconds: number) => { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)) -} diff --git a/functions/src/scripts/update-feed.ts b/functions/src/scripts/update-feed.ts deleted file mode 100644 index c5cba142..00000000 --- a/functions/src/scripts/update-feed.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as admin from 'firebase-admin' - -import { initAdmin } from './script-init' -initAdmin() - -import { getValues } from '../utils' -import { User } from '../../../common/user' -import { batchedWaitAll } from '../../../common/util/promise' -import { Contract } from '../../../common/contract' -import { updateWordScores } from '../update-recommendations' -import { computeFeed } from '../update-feed' -import { getFeedContracts, getTaggedContracts } from '../get-feed-data' -import { CATEGORY_LIST } from '../../../common/categories' - -const firestore = admin.firestore() - -async function updateFeed() { - console.log('Updating feed') - - const contracts = await getValues<Contract>(firestore.collection('contracts')) - const feedContracts = await getFeedContracts() - const users = await getValues<User>( - firestore.collection('users').where('username', '==', 'JamesGrugett') - ) - - await batchedWaitAll( - users.map((user) => async () => { - console.log('Updating recs for', user.username) - await updateWordScores(user, contracts) - console.log('Updating feed for', user.username) - await computeFeed(user, feedContracts) - }) - ) - - console.log('Updating feed categories!') - - await batchedWaitAll( - users.map((user) => async () => { - for (const category of CATEGORY_LIST) { - const contracts = await getTaggedContracts(category) - const feed = await computeFeed(user, contracts) - await firestore - .collection(`private-users/${user.id}/cache`) - .doc(`feed-${category}`) - .set({ feed }) - } - }) - ) -} - -if (require.main === module) { - updateFeed().then(() => process.exit()) -} diff --git a/functions/src/update-feed.ts b/functions/src/update-feed.ts deleted file mode 100644 index f19fda92..00000000 --- a/functions/src/update-feed.ts +++ /dev/null @@ -1,220 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { logInterpolation } from '../../common/util/math' -import { DAY_MS } from '../../common/util/time' -import { - getProbability, - getOutcomeProbability, - getTopAnswer, -} from '../../common/calculate' -import { User } from '../../common/user' -import { - getContractScore, - MAX_FEED_CONTRACTS, -} from '../../common/recommended-contracts' -import { callCloudFunction } from './call-cloud-function' -import { - getFeedContracts, - getRecentBetsAndComments, - getTaggedContracts, -} from './get-feed-data' -import { CATEGORY_LIST } from '../../common/categories' - -const firestore = admin.firestore() - -const BATCH_SIZE = 30 -const MAX_BATCHES = 50 - -const getUserBatches = async () => { - const users = shuffle(await getValues<User>(firestore.collection('users'))) - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += BATCH_SIZE) { - userBatches.push(users.slice(i, i + BATCH_SIZE)) - } - - console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length) - - return userBatches.slice(0, MAX_BATCHES) -} - -export const updateFeed = functions.pubsub - .schedule('every 60 minutes') - .onRun(async () => { - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map((users) => - callCloudFunction('updateFeedBatch', { users }) - ) - ) - - console.log('updating category feed') - - await Promise.all( - CATEGORY_LIST.map((category) => - callCloudFunction('updateCategoryFeed', { - category, - }) - ) - ) - }) - -export const updateFeedBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - const contracts = await getFeedContracts() - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc('feed').set({ feed }) - ) - ) - } -) - -export const updateCategoryFeed = functions.https.onCall( - async (data: { category: string }) => { - const { category } = data - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map(async (users) => { - await callCloudFunction('updateCategoryFeedBatch', { - users, - category, - }) - }) - ) - } -) - -export const updateCategoryFeedBatch = functions.https.onCall( - async (data: { users: User[]; category: string }) => { - const { users, category } = data - const contracts = await getTaggedContracts(category) - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed }) - ) - ) - } -) - -const getNewFeeds = async (users: User[], contracts: Contract[]) => { - const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts))) - const contractIds = uniq(flatten(feeds).map((c) => c.id)) - const data = await Promise.all(contractIds.map(getRecentBetsAndComments)) - const dataByContractId = zipObject(contractIds, data) - return feeds.map((feed) => - feed.map((contract) => { - return { contract, ...dataByContractId[contract.id] } - }) - ) -} - -const getUserCacheCollection = (user: User) => - firestore.collection(`private-users/${user.id}/cache`) - -export const computeFeed = async (user: User, contracts: Contract[]) => { - const userCacheCollection = getUserCacheCollection(user) - - const [wordScores, lastViewedTime] = await Promise.all([ - getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')), - getValue<{ [contractId: string]: number }>( - userCacheCollection.doc('lastViewTime') - ), - ]).then((dicts) => dicts.map((dict) => dict ?? {})) - - const scoredContracts = contracts.map((contract) => { - const score = scoreContract( - contract, - wordScores, - lastViewedTime[contract.id] - ) - return [contract, score] as [Contract, number] - }) - - const sortedContracts = sortBy( - scoredContracts, - ([_, score]) => score - ).reverse() - - // console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) - - return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c) -} - -function scoreContract( - contract: Contract, - wordScores: { [word: string]: number }, - viewTime: number | undefined -) { - const recommendationScore = getContractScore(contract, wordScores) - const activityScore = getActivityScore(contract, viewTime) - // const lastViewedScore = getLastViewedScore(viewTime) - return recommendationScore * activityScore -} - -function getActivityScore(contract: Contract, viewTime: number | undefined) { - const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract - const hasNewComments = - lastCommentTime && (!viewTime || lastCommentTime > viewTime) - const newCommentScore = hasNewComments ? 1 : 0.5 - - const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime) - const commentDaysAgo = timeSinceLastComment / DAY_MS - const commentTimeScore = - 0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo)) - - const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime) - const betDaysAgo = timeSinceLastBet / DAY_MS - const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo)) - - let prob = 0.5 - if (outcomeType === 'BINARY') { - prob = getProbability(contract) - } else if (outcomeType === 'FREE_RESPONSE') { - const topAnswer = getTopAnswer(contract) - if (topAnswer) - prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) - } - const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 - const probScore = 0.5 + frac * 0.5 - - const { volume24Hours, volume7Days } = contract - const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1) - const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume) - - const score = - newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore - - // Map score to [0.5, 1] since no recent activty is not a deal breaker. - const mappedScore = 0.5 + 0.5 * score - const newMappedScore = 0.7 + 0.3 * score - - const isNew = Date.now() < contract.createdTime + DAY_MS - return isNew ? newMappedScore : mappedScore -} - -// function getLastViewedScore(viewTime: number | undefined) { -// if (viewTime === undefined) { -// return 1 -// } - -// const daysAgo = (Date.now() - viewTime) / DAY_MS - -// if (daysAgo < 0.5) { -// const frac = logInterpolation(0, 0.5, daysAgo) -// return 0.5 + 0.25 * frac -// } - -// const frac = logInterpolation(0.5, 14, daysAgo) -// return 0.75 + 0.25 * frac -// } diff --git a/functions/src/update-recommendations.ts b/functions/src/update-recommendations.ts deleted file mode 100644 index bc82291c..00000000 --- a/functions/src/update-recommendations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { Bet } from '../../common/bet' -import { User } from '../../common/user' -import { ClickEvent } from '../../common/tracking' -import { getWordScores } from '../../common/recommended-contracts' -import { batchedWaitAll } from '../../common/util/promise' -import { callCloudFunction } from './call-cloud-function' - -const firestore = admin.firestore() - -export const updateRecommendations = functions.pubsub - .schedule('every 24 hours') - .onRun(async () => { - const users = await getValues<User>(firestore.collection('users')) - - const batchSize = 100 - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += batchSize) { - userBatches.push(users.slice(i, i + batchSize)) - } - - await Promise.all( - userBatches.map((batch) => - callCloudFunction('updateRecommendationsBatch', { users: batch }) - ) - ) - }) - -export const updateRecommendationsBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - - const contracts = await getValues<Contract>( - firestore.collection('contracts') - ) - - await batchedWaitAll( - users.map((user) => () => updateWordScores(user, contracts)) - ) - } -) - -export const updateWordScores = async (user: User, contracts: Contract[]) => { - const [bets, viewCounts, clicks] = await Promise.all([ - getValues<Bet>( - firestore.collectionGroup('bets').where('userId', '==', user.id) - ), - - getValue<{ [contractId: string]: number }>( - firestore.doc(`private-users/${user.id}/cache/viewCounts`) - ), - - getValues<ClickEvent>( - firestore - .collection(`private-users/${user.id}/events`) - .where('type', '==', 'click') - ), - ]) - - const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets) - - const cachedCollection = firestore.collection( - `private-users/${user.id}/cache` - ) - await cachedCollection.doc('wordScores').set(wordScores) -} diff --git a/yarn.lock b/yarn.lock index 15cd3c51..c07d548f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3875,13 +3875,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -biskviit@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/biskviit/-/biskviit-1.0.1.tgz#037a0cd4b71b9e331fd90a1122de17dc49e420a7" - integrity sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w== - dependencies: - psl "^1.1.7" - bluebird@^3.7.1: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -5237,13 +5230,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" - integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= - dependencies: - iconv-lite "~0.4.13" - end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -5817,14 +5803,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -fetch@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fetch/-/fetch-1.1.0.tgz#0a8279f06be37f9f0ebb567560a30a480da59a2e" - integrity sha1-CoJ58Gvjf58Ou1Z1YKMKSA2lmi4= - dependencies: - biskviit "1.0.1" - encoding "0.1.12" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -6782,7 +6760,7 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@0.4.24, iconv-lite@~0.4.13: +iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -9151,11 +9129,6 @@ pseudomap@^1.0.1: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.7: - version "1.8.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" - integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== - pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" From 90d7f55c6d7b80cb6395276b157f93fd4451e99c Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 13:27:06 -0700 Subject: [PATCH 08/39] Fix backup DB job to actually backup most things, refactor (#605) * Make backup manually invokable and thereby testable * Add a shitload of missing stuff to our backups * Also backup follows as per James --- functions/src/backup-db.ts | 91 +++++++++++++++++----------- functions/src/scripts/backup-db.ts | 16 +++++ functions/src/scripts/script-init.ts | 19 +++--- 3 files changed, 81 insertions(+), 45 deletions(-) create mode 100644 functions/src/scripts/backup-db.ts diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index 5174f595..227c89e4 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -18,46 +18,63 @@ import * as functions from 'firebase-functions' import * as firestore from '@google-cloud/firestore' -const client = new firestore.v1.FirestoreAdminClient() +import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client' -const bucket = 'gs://manifold-firestore-backup' +export const backupDbCore = async ( + client: FirestoreAdminClient, + project: string, + bucket: string +) => { + const name = client.databasePath(project, '(default)') + const outputUriPrefix = `gs://${bucket}` + // Leave collectionIds empty to export all collections + // or set to a list of collection IDs to export, + // collectionIds: ['users', 'posts'] + // NOTE: Subcollections are not backed up by default + const collectionIds = [ + 'contracts', + 'groups', + 'private-users', + 'stripe-transactions', + 'transactions', + 'users', + 'bets', + 'comments', + 'follows', + 'followers', + 'answers', + 'txns', + 'manalinks', + 'liquidity', + 'stats', + 'cache', + 'latency', + 'views', + 'notifications', + 'portfolioHistory', + 'folds', + ] + return await client.exportDocuments({ name, outputUriPrefix, collectionIds }) +} export const backupDb = functions.pubsub .schedule('every 24 hours') - .onRun((_context) => { - const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT - if (projectId == null) { - throw new Error('No project ID environment variable set.') + .onRun(async (_context) => { + try { + const client = new firestore.v1.FirestoreAdminClient() + const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT + if (project == null) { + throw new Error('No project ID environment variable set.') + } + const responses = await backupDbCore( + client, + project, + 'manifold-firestore-backup' + ) + const response = responses[0] + console.log(`Operation Name: ${response['name']}`) + } catch (err) { + console.error(err) + throw new Error('Export operation failed') } - const databaseName = client.databasePath(projectId, '(default)') - - return client - .exportDocuments({ - name: databaseName, - outputUriPrefix: bucket, - // Leave collectionIds empty to export all collections - // or set to a list of collection IDs to export, - // collectionIds: ['users', 'posts'] - // NOTE: Subcollections are not backed up by default - collectionIds: [ - 'contracts', - 'groups', - 'private-users', - 'stripe-transactions', - 'users', - 'bets', - 'comments', - 'followers', - 'answers', - 'txns', - ], - }) - .then((responses) => { - const response = responses[0] - console.log(`Operation Name: ${response['name']}`) - }) - .catch((err) => { - console.error(err) - throw new Error('Export operation failed') - }) }) diff --git a/functions/src/scripts/backup-db.ts b/functions/src/scripts/backup-db.ts new file mode 100644 index 00000000..04c66438 --- /dev/null +++ b/functions/src/scripts/backup-db.ts @@ -0,0 +1,16 @@ +import * as firestore from '@google-cloud/firestore' +import { getServiceAccountCredentials } from './script-init' +import { backupDbCore } from '../backup-db' + +async function backupDb() { + const credentials = getServiceAccountCredentials() + const projectId = credentials.project_id + const client = new firestore.v1.FirestoreAdminClient({ credentials }) + const bucket = 'manifold-firestore-backup' + const resp = await backupDbCore(client, projectId, bucket) + console.log(`Operation: ${resp[0]['name']}`) +} + +if (require.main === module) { + backupDb().then(() => process.exit()) +} diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index 8f65e4be..cc17a620 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => { } } -export const initAdmin = (env?: string) => { +export const getServiceAccountCredentials = (env?: string) => { env = env || getFirebaseActiveProject(process.cwd()) if (env == null) { - console.error( + throw new Error( "Couldn't find active Firebase project; did you do `firebase use <alias>?`" ) - return } const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` const keyPath = process.env[envVar] if (keyPath == null) { - console.error( + throw new Error( `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` ) - return } - console.log(`Initializing connection to ${env} Firebase...`) /* eslint-disable-next-line @typescript-eslint/no-var-requires */ - const serviceAccount = require(keyPath) - admin.initializeApp({ + return require(keyPath) +} + +export const initAdmin = (env?: string) => { + const serviceAccount = getServiceAccountCredentials(env) + console.log(`Initializing connection to ${serviceAccount.project_id}...`) + return admin.initializeApp({ + projectId: serviceAccount.project_id, credential: admin.credential.cert(serviceAccount), }) } From 7dea9cbfa89336bcb2672800e4ac3418ac807280 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Sat, 2 Jul 2022 16:24:03 -0700 Subject: [PATCH 09/39] Use `getAll` Firestore technology to improve some code (#612) --- functions/src/place-bet.ts | 5 +---- functions/src/sell-bet.ts | 10 +++++----- functions/src/sell-shares.ts | 5 ++--- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index b6c7d267..43906f3c 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -41,10 +41,7 @@ export const placebet = newEndpoint({}, async (req, auth) => { log('Inside main transaction.') const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) - const [contractSnap, userSnap] = await Promise.all([ - trans.get(contractDoc), - trans.get(userDoc), - ]) + const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') log('Loaded user and contract snapshots.') diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index b3362159..18df4536 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -21,11 +21,11 @@ export const sellbet = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) - const [contractSnap, userSnap, betSnap] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), - transaction.get(betDoc), - ]) + const [contractSnap, userSnap, betSnap] = await transaction.getAll( + contractDoc, + userDoc, + betDoc + ) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!betSnap.exists) throw new APIError(400, 'Bet not found.') diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 26374a16..a0c19f2c 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -24,9 +24,8 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) - const [contractSnap, userSnap, userBets] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), + const [[contractSnap, userSnap], userBets] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), getValues<Bet>(betsQ), // TODO: why is this not in the transaction?? ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') From 960f8a1b3d20e2c6a2829bbd97fb5ba56dad64e0 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Sun, 3 Jul 2022 20:18:12 +0100 Subject: [PATCH 10/39] Toggle weekly leaderboard and daily/weekly/alltime portfolio graph (#616) * Toggle weekly leaderboard and daily/weekly/alltime portfolio graph * Formatmoney for tooltip value --- .../portfolio/portfolio-value-graph.tsx | 3 ++- .../portfolio/portfolio-value-section.tsx | 15 +++++++++++---- web/components/user-page.tsx | 11 +++++++---- web/pages/leaderboards.tsx | 4 +++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/web/components/portfolio/portfolio-value-graph.tsx b/web/components/portfolio/portfolio-value-graph.tsx index 558fc5f6..50a6b59a 100644 --- a/web/components/portfolio/portfolio-value-graph.tsx +++ b/web/components/portfolio/portfolio-value-graph.tsx @@ -52,7 +52,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { margin={{ top: 20, right: 28, bottom: 22, left: 60 }} xScale={{ type: 'time', - min: points[0].x, + min: points[0]?.x, max: endDate, }} yScale={{ @@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { enableGridY={true} enableSlices="x" animate={false} + yFormat={(value) => formatMoney(+value)} ></ResponsiveLine> </div> ) diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index a992e87e..55260bb5 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -13,7 +13,7 @@ export const PortfolioValueSection = memo( }) { const { portfolioHistory } = props const lastPortfolioMetrics = last(portfolioHistory) - const [portfolioPeriod] = useState<Period>('allTime') + const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') if (portfolioHistory.length === 0 || !lastPortfolioMetrics) { return <div> No portfolio history data yet </div> @@ -33,9 +33,16 @@ export const PortfolioValueSection = memo( </div> </Col> </div> - { - //TODO: enable day/week/monthly as data becomes available - } + <select + className="select select-bordered self-start" + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">All time</option> + <option value="weekly">Weekly</option> + <option value="daily">Daily</option> + </select> </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index ccacca04..d72a2a16 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -38,6 +38,7 @@ import { FollowButton } from './follow-button' import { PortfolioMetrics } from 'common/user' import { ReferralsButton } from 'web/components/referrals-button' import { GroupsButton } from 'web/components/groups/groups-button' +import { PortfolioValueSection } from './portfolio/portfolio-value-section' export function UserLink(props: { name: string @@ -75,7 +76,9 @@ export function UserPage(props: { 'loading' ) const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading') - const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([]) + const [portfolioHistory, setUsersPortfolioHistory] = useState< + PortfolioMetrics[] + >([]) const [commentsByContract, setCommentsByContract] = useState< Map<Contract, Comment[]> | 'loading' >('loading') @@ -297,9 +300,9 @@ export function UserPage(props: { title: 'Bets', content: ( <div> - { - // TODO: add portfolio-value-section here - } + <PortfolioValueSection + portfolioHistory={portfolioHistory} + /> <BetsList user={user} hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022} diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 44c0a65b..f306493b 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -67,7 +67,9 @@ export default function Leaderboards(props: { <Col className="mx-4 items-center gap-10 lg:flex-row"> {!isLoading ? ( <> - {period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available + {period === 'allTime' || + period == 'weekly' || + period === 'daily' ? ( //TODO: show other periods once they're available <Leaderboard title="🏅 Top bettors" users={topTradersState} From 8fdc44f7f3bd2eaa28bd3448b98c421b02abeda3 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 3 Jul 2022 15:37:22 -0400 Subject: [PATCH 11/39] Switch to firebase dev before serving firebase emulators --- functions/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/package.json b/functions/package.json index ed12b4e7..ee7bc92d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -12,7 +12,7 @@ "start": "yarn shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", - "serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", + "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", From 9839b7b5a40fd349802145eb932f98b8864dbd3a Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 3 Jul 2022 16:45:52 -0700 Subject: [PATCH 12/39] Allow customizing starting balance & antes --- common/antes.ts | 7 ++----- common/user.ts | 7 +++++-- web/pages/create.tsx | 3 +-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/common/antes.ts b/common/antes.ts index becc9b7e..d4cb2ff9 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -10,12 +10,9 @@ import { import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' +import { ENV_CONFIG } from './envs/constants' -export const FIXED_ANTE = 100 - -// deprecated -export const PHANTOM_ANTE = 0.001 -export const MINIMUM_ANTE = 50 +export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id diff --git a/common/user.ts b/common/user.ts index 0a8565dd..d5dd0373 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { ENV_CONFIG } from './envs/constants' + export type User = { id: string createdTime: number @@ -38,8 +40,9 @@ export type User = { referredByContractId?: string } -export const STARTING_BALANCE = 1000 -export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person +export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 +// for sus users, i.e. multiple sign ups for same person +export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 export const REFERRAL_AMOUNT = 500 export type PrivateUser = { id: string // same as User.id diff --git a/web/pages/create.tsx b/web/pages/create.tsx index c7b8f02e..6a5f96ae 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer' import { useUser } from 'web/hooks/use-user' import { Contract, contractPath } from 'web/lib/firebase/contracts' import { createMarket } from 'web/lib/firebase/api-call' -import { FIXED_ANTE, MINIMUM_ANTE } from 'common/antes' +import { FIXED_ANTE } from 'common/antes' import { InfoTooltip } from 'web/components/info-tooltip' import { Page } from 'web/components/page' import { Row } from 'web/components/layout/row' @@ -156,7 +156,6 @@ export function NewContract(props: { question.length > 0 && ante !== undefined && ante !== null && - ante >= MINIMUM_ANTE && ante <= balance && // closeTime must be in the future closeTime && From 579dcd81dc5e2b8b26b3442ad928be72036b6a41 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Fri, 1 Jul 2022 11:01:36 -0700 Subject: [PATCH 13/39] Update env config template --- common/envs/prod.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/envs/prod.ts b/common/envs/prod.ts index f5a0e55e..f8aaf4cc 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -18,13 +18,17 @@ export type EnvConfig = { faviconPath?: string // Should be a file in /public navbarLogoPath?: string newQuestionPlaceholders: string[] + + // Currency controls + fixedAnte?: number + startingBalance?: number } type FirebaseConfig = { apiKey: string authDomain: string projectId: string - region: string + region?: string storageBucket: string messagingSenderId: string appId: string From d78bbcb3df761eca0e42362577bbb1ebcc16a80a Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 3 Jul 2022 23:43:18 -0400 Subject: [PATCH 14/39] fix navbar tracking --- web/components/nav/nav-bar.tsx | 4 +++- web/components/nav/sidebar.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 5a997b46..9f0f8ddd 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -63,6 +63,7 @@ export function BottomNavBar() { currentPage={currentPage} item={{ name: formatMoney(user.balance), + trackingEventName: 'profile', href: `/${user.username}?tab=bets`, icon: () => ( <Avatar @@ -94,6 +95,7 @@ export function BottomNavBar() { function NavBarItem(props: { item: Item; currentPage: string }) { const { item, currentPage } = props + const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) return ( <Link href={item.href}> @@ -102,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) { 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', currentPage === item.href && 'bg-gray-200 text-indigo-700' )} - onClick={trackCallback('navbar: ' + item.name)} + onClick={track} > {item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />} {item.name} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 8c3ceb02..5ce9e239 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -120,6 +120,7 @@ function getMoreMobileNav() { export type Item = { name: string + trackingEventName?: string href: string icon?: React.ComponentType<{ className?: string }> } From e712ad82891e2d14d685442ae7689c7d51fea4e6 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 4 Jul 2022 07:49:41 -0600 Subject: [PATCH 15/39] Allow users to choose who referred them (#611) * Allow users to choose who referred them * Refactor * Rewording * Match list styles * Match empty text styles --- firestore.rules | 19 ++- web/components/filter-select-users.tsx | 194 +++++++++++++++---------- web/components/referrals-button.tsx | 96 +++++++++++- web/components/user-page.tsx | 2 +- web/pages/account.tsx | 41 ------ web/pages/link/[slug].tsx | 2 +- 6 files changed, 221 insertions(+), 133 deletions(-) delete mode 100644 web/pages/account.tsx diff --git a/firestore.rules b/firestore.rules index 4645343d..28ff4485 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,16 +21,15 @@ service cloud.firestore { allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - allow update: if resource.data.id == request.auth.uid - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - // only one referral allowed per user - && !("referredByUserId" in resource.data) - // user can't refer themselves - && (resource.data.id != request.resource.data.referredByUserId) - // user can't refer someone who referred them quid pro quo - && get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id; - + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && !(resource.data.id == request.resource.data.referredByUserId); + // quid pro quos enabled (only once though so nbd) - bc I can't make this work: + // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 93badf20..8d2dbbae 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -1,4 +1,4 @@ -import { UserIcon } from '@heroicons/react/outline' +import { UserIcon, XIcon } from '@heroicons/react/outline' import { useUsers } from 'web/hooks/use-users' import { User } from 'common/user' import { Fragment, useMemo, useState } from 'react' @@ -6,13 +6,24 @@ import clsx from 'clsx' import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' +import { UserLink } from 'web/components/user-page' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void selectedUsers: User[] ignoreUserIds: string[] + showSelectedUsersTitle?: boolean + selectedUsersClassName?: string + maxUsers?: number }) { - const { ignoreUserIds, selectedUsers, setSelectedUsers } = props + const { + ignoreUserIds, + selectedUsers, + setSelectedUsers, + showSelectedUsersTitle, + selectedUsersClassName, + maxUsers, + } = props const users = useUsers() const [query, setQuery] = useState('') const [filteredUsers, setFilteredUsers] = useState<User[]>([]) @@ -29,89 +40,118 @@ export function FilterSelectUsers(props: { }) ) }, [beginQuerying, users, selectedUsers, ignoreUserIds, query]) - + const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true return ( <div> - <div className="relative mt-1 rounded-md"> - <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 - 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 " - placeholder="Austin Chen" - /> - </div> - <Menu - as="div" - className={clsx( - 'relative inline-block w-full overflow-y-scroll text-right', - beginQuerying && 'h-36' - )} - > - {({}) => ( - <Transition - show={beginQuerying} - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" + {shouldShow && ( + <> + <div className="relative mt-1 rounded-md"> + <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 + 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 " + placeholder="Austin Chen" + /> + </div> + <Menu + as="div" + className={clsx( + 'relative inline-block w-full overflow-y-scroll text-right', + beginQuerying && 'h-36' + )} > - <Menu.Items - static={true} - className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" - > - <div className="py-1"> - {filteredUsers.map((user: User) => ( - <Menu.Item key={user.id}> - {({ active }) => ( - <span - className={clsx( - active - ? 'bg-gray-100 text-gray-900' - : 'text-gray-700', - 'group flex items-center px-4 py-2 text-sm' + {({}) => ( + <Transition + show={beginQuerying} + as={Fragment} + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + > + <Menu.Items + static={true} + className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + > + <div className="py-1"> + {filteredUsers.map((user: User) => ( + <Menu.Item key={user.id}> + {({ active }) => ( + <span + className={clsx( + active + ? 'bg-gray-100 text-gray-900' + : 'text-gray-700', + 'group flex items-center px-4 py-2 text-sm' + )} + onClick={() => { + setQuery('') + setSelectedUsers([...selectedUsers, user]) + }} + > + <Avatar + username={user.username} + avatarUrl={user.avatarUrl} + size={'xs'} + className={'mr-2'} + /> + {user.name} + </span> )} - onClick={() => { - setQuery('') - setSelectedUsers([...selectedUsers, user]) - }} - > - <Avatar - username={user.username} - avatarUrl={user.avatarUrl} - size={'xs'} - className={'mr-2'} - /> - {user.name} - </span> - )} - </Menu.Item> - ))} - </div> - </Menu.Items> - </Transition> - )} - </Menu> + </Menu.Item> + ))} + </div> + </Menu.Items> + </Transition> + )} + </Menu> + </> + )} {selectedUsers.length > 0 && ( <> - <div className={'mb-2'}>Added members:</div> - <Row className="mt-0 grid grid-cols-6 gap-2"> + <div className={'mb-2'}> + {showSelectedUsersTitle && 'Added members:'} + </div> + <Row + className={clsx( + 'mt-0 grid grid-cols-6 gap-2', + selectedUsersClassName + )} + > {selectedUsers.map((user: User) => ( - <div key={user.id} className="col-span-2 flex items-center"> - <Avatar - username={user.username} - avatarUrl={user.avatarUrl} - size={'sm'} + <div + key={user.id} + className="col-span-2 flex flex-row items-center justify-between" + > + <Row className={'items-center'}> + <Avatar + username={user.username} + avatarUrl={user.avatarUrl} + size={'sm'} + /> + <UserLink + username={user.username} + className="ml-2" + name={user.name} + /> + </Row> + <XIcon + onClick={() => + setSelectedUsers([ + ...selectedUsers.filter((u) => u.id != user.id), + ]) + } + className=" h-5 w-5 cursor-pointer text-gray-400" + aria-hidden="true" /> - <span className="ml-2">{user.name}</span> </div> ))} </Row> diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index c23958fc..bb9e53cb 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -10,9 +10,11 @@ import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { useReferrals } from 'web/hooks/use-referrals' +import { FilterSelectUsers } from 'web/components/filter-select-users' +import { getUser, updateUser } from 'web/lib/firebase/users' -export function ReferralsButton(props: { user: User }) { - const { user } = props +export function ReferralsButton(props: { user: User; currentUser?: User }) { + const { user, currentUser } = props const [isOpen, setIsOpen] = useState(false) const referralIds = useReferrals(user.id) @@ -28,6 +30,7 @@ export function ReferralsButton(props: { user: User }) { referralIds={referralIds ?? []} isOpen={isOpen} setIsOpen={setIsOpen} + currentUser={currentUser} /> </> ) @@ -38,8 +41,26 @@ function ReferralsDialog(props: { referralIds: string[] isOpen: boolean setIsOpen: (isOpen: boolean) => void + currentUser?: User }) { - const { user, referralIds, isOpen, setIsOpen } = props + const { user, referralIds, isOpen, setIsOpen, currentUser } = props + const [referredBy, setReferredBy] = useState<User[]>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorText, setErrorText] = useState('') + + const [referredByUser, setReferredByUser] = useState<User | null>() + useEffect(() => { + if ( + isOpen && + !referredByUser && + currentUser?.referredByUserId && + currentUser.id === user.id + ) { + getUser(currentUser.referredByUserId).then((user) => { + setReferredByUser(user) + }) + } + }, [currentUser, isOpen, referredByUser, user.id]) useEffect(() => { prefetchUsers(referralIds) @@ -56,6 +77,75 @@ function ReferralsDialog(props: { title: 'Referrals', content: <ReferralsList userIds={referralIds} />, }, + { + title: 'Referred by', + content: ( + <> + {user.id === currentUser?.id && !referredByUser ? ( + <> + <FilterSelectUsers + setSelectedUsers={setReferredBy} + selectedUsers={referredBy} + ignoreUserIds={[currentUser.id]} + showSelectedUsersTitle={false} + selectedUsersClassName={'grid-cols-2 '} + maxUsers={1} + /> + <Row className={'mt-0 justify-end'}> + <button + className={ + referredBy.length === 0 + ? 'hidden' + : 'btn btn-primary btn-md my-2 w-24 normal-case' + } + disabled={referredBy.length === 0 || isSubmitting} + onClick={() => { + setIsSubmitting(true) + updateUser(currentUser.id, { + referredByUserId: referredBy[0].id, + }) + .then(async () => { + setErrorText('') + setIsSubmitting(false) + setReferredBy([]) + setIsOpen(false) + }) + .catch((error) => { + setIsSubmitting(false) + setErrorText(error.message) + }) + }} + > + Save + </button> + </Row> + <span className={'text-warning'}> + {referredBy.length > 0 && + 'Careful: you can only set who referred you once!'} + </span> + <span className={'text-error'}>{errorText}</span> + </> + ) : ( + <div className="justify-center text-gray-700"> + {referredByUser ? ( + <Row className={'items-center gap-2 p-2'}> + <Avatar + username={referredByUser.username} + avatarUrl={referredByUser.avatarUrl} + /> + <UserLink + username={referredByUser.username} + name={referredByUser.name} + /> + </Row> + ) : ( + <span className={'text-gray-500'}>No one...</span> + )} + </div> + )} + </> + ), + }, ]} /> </Col> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index d72a2a16..0a1366c4 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -202,7 +202,7 @@ export function UserPage(props: { <Row className="gap-4"> <FollowingButton user={user} /> <FollowersButton user={user} /> - <ReferralsButton user={user} /> + <ReferralsButton user={user} currentUser={currentUser} /> <GroupsButton user={user} /> </Row> diff --git a/web/pages/account.tsx b/web/pages/account.tsx deleted file mode 100644 index 59d938c3..00000000 --- a/web/pages/account.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { Page } from 'web/components/page' -import { UserPage } from 'web/components/user-page' -import { useUser } from 'web/hooks/use-user' -import { firebaseLogin } from 'web/lib/firebase/users' - -function SignInCard() { - return ( - <div className="card glass sm:card-side text-neutral-content mx-4 my-12 max-w-sm bg-green-600 shadow-xl transition-all hover:bg-green-600 hover:shadow-xl sm:mx-auto"> - <div className="p-4"> - <img - src="/logo-bg-white.png" - className="h-20 w-20 rounded-lg shadow-lg" - /> - </div> - <div className="card-body max-w-md"> - <h2 className="card-title font-major-mono">Welcome!</h2> - <p>Sign in to get started</p> - <div className="card-actions"> - <button - className="btn glass rounded-full hover:bg-green-500" - onClick={firebaseLogin} - > - Sign in with Google - </button> - </div> - </div> - </div> - ) -} - -export default function Account() { - const user = useUser() - return user ? ( - <UserPage user={user} currentUser={user} /> - ) : ( - <Page> - <SignInCard /> - </Page> - ) -} diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index 60966756..eed68e1a 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -46,7 +46,7 @@ export default function ClaimPage() { if (result.data.status == 'error') { throw new Error(result.data.message) } - router.push('/account?claimed-mana=yes') + user && router.push(`/${user.username}?claimed-mana=yes`) } catch (e) { console.log(e) const message = From 22f917e250cdb02de9846e19bbbe022449aa7a82 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 4 Jul 2022 08:32:51 -0600 Subject: [PATCH 16/39] Avatar sizes to 24, size 20 is broken --- web/components/user-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 0a1366c4..07f722d7 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -159,7 +159,7 @@ export function UserPage(props: { <Avatar username={user.username} avatarUrl={user.avatarUrl} - size={20} + size={24} className="bg-white ring-4 ring-white" /> </div> From 790fdad1e3efa07bc214dab7d8fcedd4727e7088 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 4 Jul 2022 09:18:01 -0600 Subject: [PATCH 17/39] Display refered by publicly --- web/components/referrals-button.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index bb9e53cb..74fc113d 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -50,17 +50,12 @@ function ReferralsDialog(props: { const [referredByUser, setReferredByUser] = useState<User | null>() useEffect(() => { - if ( - isOpen && - !referredByUser && - currentUser?.referredByUserId && - currentUser.id === user.id - ) { - getUser(currentUser.referredByUserId).then((user) => { + if (isOpen && !referredByUser && user?.referredByUserId) { + getUser(user.referredByUserId).then((user) => { setReferredByUser(user) }) } - }, [currentUser, isOpen, referredByUser, user.id]) + }, [isOpen, referredByUser, user.referredByUserId]) useEffect(() => { prefetchUsers(referralIds) From af2b148b3415e863e1bcc4fc7a60a0e6be95b7a4 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 4 Jul 2022 13:25:44 -0700 Subject: [PATCH 18/39] show names on admin user table --- web/pages/admin.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index db24996d..e709e875 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -62,13 +62,19 @@ function UsersTable() { class="hover:underline hover:decoration-indigo-400 hover:decoration-2" href="/${cell}">@${cell}</a>`), }, + { + id: 'name', + name: 'Name', + formatter: (cell) => + html(`<span class="whitespace-nowrap">${cell}</span>`), + }, { id: 'email', name: 'Email', }, { id: 'createdTime', - name: 'Created Time', + name: 'Created', formatter: (cell) => html( `<span class="whitespace-nowrap">${dayjs(cell as number).format( From c39e3aedfa024416d2135cc9915bd5bfee956fbf Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 4 Jul 2022 16:04:05 -0700 Subject: [PATCH 19/39] Also send .env file when deploy functions --- functions/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/package.json b/functions/package.json index ee7bc92d..93bea621 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", + "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", "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", From 53b4a2894453a8e5a411e3bc29d4fefea03e14ee Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 4 Jul 2022 16:21:59 -0700 Subject: [PATCH 20/39] Check in .env to git --- functions/.env | 3 +++ functions/.gitignore | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 functions/.env diff --git a/functions/.env b/functions/.env new file mode 100644 index 00000000..0c4303df --- /dev/null +++ b/functions/.env @@ -0,0 +1,3 @@ +# This sets which EnvConfig is deployed to Firebase Cloud Functions + +NEXT_PUBLIC_FIREBASE_ENV=PROD diff --git a/functions/.gitignore b/functions/.gitignore index 2aeae30c..58f30dcb 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -1,5 +1,4 @@ # Secrets -.env* .runtimeconfig.json # GCP deployment artifact From b26648c1cec22af62b43a319e8f1fabb2cb1fc12 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 5 Jul 2022 11:29:26 -0600 Subject: [PATCH 21/39] Daily trading bonuses (#618) * first commit, WIP * Give trading bonuses & paginate notifications * Move read & update into transaction * Move request bonus logic to notifs icon --- common/notification.ts | 2 + common/numeric-constants.ts | 1 + common/txn.ts | 11 +- common/user.ts | 1 + functions/src/create-notification.ts | 15 + functions/src/get-daily-bonuses.ts | 139 ++++++++ functions/src/index.ts | 1 + web/components/nav/sidebar.tsx | 1 - web/components/notifications-icon.tsx | 20 +- web/hooks/use-notifications.ts | 39 ++- web/lib/firebase/api-call.ts | 4 + web/pages/notifications.tsx | 448 ++++++++++++++++++-------- 12 files changed, 525 insertions(+), 157 deletions(-) create mode 100644 functions/src/get-daily-bonuses.ts diff --git a/common/notification.ts b/common/notification.ts index 64a00a36..e90624a4 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -34,6 +34,7 @@ export type notification_source_types = | 'admin_message' | 'group' | 'user' + | 'bonus' export type notification_source_update_types = | 'created' @@ -56,3 +57,4 @@ export type notification_reason_types = | 'added_you_to_group' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' + | 'unique_bettors_on_your_contract' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index ef364b74..46885668 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,3 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' +export const UNIQUE_BETTOR_BONUS_AMOUNT = 5 diff --git a/common/txn.ts b/common/txn.ts index 0e772e0d..53b08501 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink | Referral +type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn<T extends AnyTxnType = AnyTxnType> = { @@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + // Any extra data data?: { [key: string]: any } @@ -52,6 +53,12 @@ type Referral = { category: 'REFERRAL' } +type Bonus = { + fromType: 'BANK' + toType: 'USER' + category: 'UNIQUE_BETTOR_BONUS' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink diff --git a/common/user.ts b/common/user.ts index d5dd0373..477139fd 100644 --- a/common/user.ts +++ b/common/user.ts @@ -57,6 +57,7 @@ export type PrivateUser = { initialIpAddress?: string apiKey?: string notificationPreferences?: notification_subscribe_types + lastTimeCheckedBonuses?: number } export type notification_subscribe_types = 'all' | 'less' | 'none' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index a32ed3bc..b63958f0 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -267,6 +267,15 @@ export const createNotification = async ( } } + const notifyContractCreatorOfUniqueBettorsBonus = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + userToReasonTexts[userId] = { + reason: 'unique_bettors_on_your_contract', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -309,6 +318,12 @@ export const createNotification = async ( }) } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { await notifyContractCreator(userToReasonTexts, sourceContract) + } else if (sourceType === 'bonus' && sourceUpdateType === 'created') { + // Note: the daily bonus won't have a contract attached to it + await notifyContractCreatorOfUniqueBettorsBonus( + userToReasonTexts, + sourceContract.creatorId + ) } return userToReasonTexts } diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts new file mode 100644 index 00000000..c5c1a1b3 --- /dev/null +++ b/functions/src/get-daily-bonuses.ts @@ -0,0 +1,139 @@ +import { APIError, newEndpoint } from './api' +import { log } from './utils' +import * as admin from 'firebase-admin' +import { PrivateUser } from '../../common/lib/user' +import { uniq } from 'lodash' +import { Bet } from '../../common/lib/bet' +const firestore = admin.firestore() +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { runTxn, TxnData } from './transact' +import { createNotification } from './create-notification' +import { User } from '../../common/lib/user' +import { Contract } from '../../common/lib/contract' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' + +const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime() +const QUERY_LIMIT_SECONDS = 60 + +export const getdailybonuses = newEndpoint({}, async (req, auth) => { + const { user, lastTimeCheckedBonuses } = await firestore.runTransaction( + async (trans) => { + const userSnap = await trans.get( + firestore.doc(`private-users/${auth.uid}`) + ) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as PrivateUser + const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0 + if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000) + throw new APIError( + 400, + `Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.` + ) + await trans.update(userSnap.ref, { + lastTimeCheckedBonuses: Date.now(), + }) + return { + user, + lastTimeCheckedBonuses, + } + } + ) + // TODO: switch to prod id + // const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account + const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account + const fromSnap = await firestore.doc(`users/${fromUserId}`).get() + if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + // Get all users contracts made since implementation time + const userContractsSnap = await firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('createdTime', '>=', BONUS_START_DATE) + .get() + const userContracts = userContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + const nullReturn = { status: 'no bets', txn: null } + for (const contract of userContracts) { + const result = await firestore.runTransaction(async (trans) => { + const contractId = contract.id + // Get all bets made on user's contracts + const bets = ( + await firestore + .collection(`contracts/${contractId}/bets`) + .where('userId', '!=', user.id) + .get() + ).docs.map((bet) => bet.ref) + if (bets.length === 0) { + return nullReturn + } + const contractBetsSnap = await trans.getAll(...bets) + const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet) + + const uniqueBettorIdsBeforeLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime < lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter users for ONLY those that have made bets since the last daily bonus received time + const uniqueBettorIdsWithBetsAfterLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime > lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter for users only present in the above list + const newUniqueBettorIds = + uniqueBettorIdsWithBetsAfterLastResetTime.filter( + (userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId) + ) + newUniqueBettorIds.length > 0 && + log( + `Got ${newUniqueBettorIds.length} new unique bettors since last bonus` + ) + if (newUniqueBettorIds.length === 0) { + return nullReturn + } + // Create combined txn for all unique bettors + const bonusTxnDetails = { + contractId: contractId, + uniqueBettors: newUniqueBettorIds.length, + } + const bonusTxn: TxnData = { + fromId: fromUser.id, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length, + token: 'M$', + category: 'UNIQUE_BETTOR_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + result.status != nullReturn.status && + log(`No bonus for user: ${user.id} - reason:`, result.status) + } else { + log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id) + await createNotification( + result.txn.id, + 'bonus', + 'created', + fromUser, + result.txn.id, + result.txn.amount + '', + contract, + undefined, + // No need to set the user id, we'll use the contract creator id + undefined, + contract.slug, + contract.question + ) + } + } + + return { userId: user.id, message: 'success' } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index b643ff5e..e4a30761 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -38,3 +38,4 @@ export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' +export * from './get-daily-bonuses' diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 5ce9e239..ba46bd80 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -182,7 +182,6 @@ export default function Sidebar(props: { className?: string }) { const { className } = props const router = useRouter() const currentPage = router.pathname - const user = useUser() const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index e2618870..ac4d772f 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Row } from 'web/components/layout/row' import { useEffect, useState } from 'react' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications' +import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' +import { requestBonuses } from 'web/lib/firebase/api-call' export default function NotificationsIcon(props: { className?: string }) { const user = useUser() - const notifications = usePreferredGroupedNotifications(user?.id, { + const privateUser = usePrivateUser(user?.id) + const notifications = usePreferredGroupedNotifications(privateUser?.id, { unseenOnly: true, }) const [seen, setSeen] = useState(false) + useEffect(() => { + if (!privateUser) return + + if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000) + requestBonuses({}).catch((error) => { + console.log("couldn't get bonuses:", error.message) + }) + }, [privateUser]) + const router = useRouter() useEffect(() => { if (router.pathname.endsWith('notifications')) return setSeen(true) @@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) { <div className={'relative'}> {!seen && notifications && notifications.length > 0 && ( <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2"> - {notifications.length} + {notifications.length > NOTIFICATIONS_PER_PAGE + ? `${NOTIFICATIONS_PER_PAGE}+` + : notifications.length} </div> )} <BellIcon className={clsx(props.className)} /> diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index c947e8d0..0a15754d 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash' export type NotificationGroup = { notifications: Notification[] - sourceContractId: string + groupedById: string isSeen: boolean timePeriod: string + type: 'income' | 'normal' } export function usePreferredGroupedNotifications( @@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) { new Date(notification.createdTime).toDateString() ) Object.keys(notificationGroupsByDay).forEach((day) => { - // Group notifications by contract: + const notificationsGroupedByDay = notificationGroupsByDay[day] + const bonusNotifications = notificationsGroupedByDay.filter( + (notification) => notification.sourceType === 'bonus' + ) + const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( + (notification) => notification.sourceType !== 'bonus' + ) + if (bonusNotifications.length > 0) { + notificationGroups = notificationGroups.concat({ + notifications: bonusNotifications, + groupedById: 'income' + day, + isSeen: bonusNotifications[0].isSeen, + timePeriod: day, + type: 'income', + }) + } + // Group notifications by contract, filtering out bonuses: const groupedNotificationsByContractId = groupBy( - notificationGroupsByDay[day], + normalNotificationsGroupedByDay, (notification) => { return notification.sourceContractId } ) notificationGroups = notificationGroups.concat( map(groupedNotificationsByContractId, (notifications, contractId) => { + const notificationsForContractId = groupedNotificationsByContractId[ + contractId + ].sort((a, b) => { + return b.createdTime - a.createdTime + }) // Create a notification group for each contract within each day const notificationGroup: NotificationGroup = { - notifications: groupedNotificationsByContractId[contractId].sort( - (a, b) => { - return b.createdTime - a.createdTime - } - ), - sourceContractId: contractId, - isSeen: groupedNotificationsByContractId[contractId][0].isSeen, + notifications: notificationsForContractId, + groupedById: contractId, + isSeen: notificationsForContractId[0].isSeen, timePeriod: day, + type: 'normal', } return notificationGroup }) diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index e02872ae..db41e592 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -73,3 +73,7 @@ export function sellBet(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } + +export function requestBonuses(params: any) { + return call(getFunctionUrl('getdailybonuses'), 'POST', params) +} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index f3512c56..229e8c8d 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,12 +1,7 @@ import { Tabs } from 'web/components/layout/tabs' import { useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' -import { - Notification, - notification_reason_types, - notification_source_types, - notification_source_update_types, -} from 'common/notification' +import { Notification } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -31,47 +26,40 @@ import { ProbPercentLabel, } from 'web/components/outcome-label' import { - groupNotifications, NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' -import { CheckIcon, XIcon } from '@heroicons/react/outline' +import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' +import { groupBy } from 'lodash' + +export const NOTIFICATIONS_PER_PAGE = 30 +export const HIGHLIGHT_DURATION = 30 * 1000 export default function Notifications() { const user = useUser() - const [unseenNotificationGroups, setUnseenNotificationGroups] = useState< - NotificationGroup[] | undefined - >(undefined) - const allNotificationGroups = usePreferredGroupedNotifications(user?.id, { + const [page, setPage] = useState(1) + + const groupedNotifications = usePreferredGroupedNotifications(user?.id, { unseenOnly: false, }) - + const [paginatedNotificationGroups, setPaginatedNotificationGroups] = + useState<NotificationGroup[]>([]) useEffect(() => { - if (!allNotificationGroups) return - // Don't re-add notifications that are visible right now or have been seen already. - const currentlyVisibleUnseenNotificationIds = Object.values( - unseenNotificationGroups ?? [] - ) - .map((n) => n.notifications.map((n) => n.id)) - .flat() - const unseenGroupedNotifications = groupNotifications( - allNotificationGroups - .map((notification: NotificationGroup) => notification.notifications) - .flat() - .filter( - (notification: Notification) => - !notification.isSeen || - currentlyVisibleUnseenNotificationIds.includes(notification.id) - ) - ) - setUnseenNotificationGroups(unseenGroupedNotifications) - - // We don't want unseenNotificationsGroup to be in the dependencies as we update it here. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allNotificationGroups]) + if (!groupedNotifications) return + const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = groupedNotifications.slice(start, end) + const remainingNotification = groupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + setPaginatedNotificationGroups(maxNotificationsToShow) + }, [groupedNotifications, page]) if (user === undefined) { return <LoadingIndicator /> @@ -80,7 +68,6 @@ export default function Notifications() { return <Custom404 /> } - // TODO: use infinite scroll return ( <Page> <div className={'p-2 sm:p-4'}> @@ -90,53 +77,74 @@ export default function Notifications() { defaultIndex={0} tabs={[ { - title: 'New Notifications', - content: unseenNotificationGroups ? ( + title: 'Notifications', + content: groupedNotifications ? ( <div className={''}> - {unseenNotificationGroups.length === 0 && - "You don't have any new notifications."} - {unseenNotificationGroups.map((notification) => + {paginatedNotificationGroups.length === 0 && + "You don't have any notifications. Try changing your settings to see more."} + {paginatedNotificationGroups.map((notification) => notification.notifications.length === 1 ? ( <NotificationItem notification={notification.notifications[0]} key={notification.notifications[0].id} /> + ) : notification.type === 'income' ? ( + <IncomeNotificationGroupItem + notificationGroup={notification} + key={notification.groupedById + notification.timePeriod} + /> ) : ( <NotificationGroupItem notificationGroup={notification} - key={ - notification.sourceContractId + - notification.timePeriod - } + key={notification.groupedById + notification.timePeriod} /> ) )} - </div> - ) : ( - <LoadingIndicator /> - ), - }, - { - title: 'All Notifications', - content: allNotificationGroups ? ( - <div className={''}> - {allNotificationGroups.length === 0 && - "You don't have any notifications. Try changing your settings to see more."} - {allNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( - <NotificationItem - notification={notification.notifications[0]} - key={notification.notifications[0].id} - /> - ) : ( - <NotificationGroupItem - notificationGroup={notification} - key={ - notification.sourceContractId + - notification.timePeriod - } - /> - ) + {groupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( + <nav + className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + aria-label="Pagination" + > + <div className="hidden sm:block"> + <p className="text-sm text-gray-700"> + Showing{' '} + <span className="font-medium"> + {page === 1 + ? page + : (page - 1) * NOTIFICATIONS_PER_PAGE} + </span>{' '} + to{' '} + <span className="font-medium"> + {page * NOTIFICATIONS_PER_PAGE} + </span>{' '} + of{' '} + <span className="font-medium"> + {groupedNotifications.length} + </span>{' '} + results + </p> + </div> + <div className="flex flex-1 justify-between sm:justify-end"> + <a + href="#" + className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page > 1 && setPage(page - 1)} + > + Previous + </a> + <a + href="#" + className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => + page < + groupedNotifications?.length / + NOTIFICATIONS_PER_PAGE && setPage(page + 1) + } + > + Next + </a> + </div> + </nav> )} </div> ) : ( @@ -164,7 +172,6 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { updateDoc( doc(db, `users/${notification.userId}/notifications/`, notification.id), { - ...notification, isSeen: true, viewTime: new Date(), } @@ -173,6 +180,152 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } +function IncomeNotificationGroupItem(props: { + notificationGroup: NotificationGroup + className?: string +}) { + const { notificationGroup, className } = props + const { notifications } = notificationGroup + const numSummaryLines = 3 + + const [expanded, setExpanded] = useState(false) + const [highlighted, setHighlighted] = useState(false) + useEffect(() => { + if (notifications.some((n) => !n.isSeen)) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } + setNotificationsAsSeen(notifications) + }, [notifications]) + + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + + const totalIncome = notifications.reduce( + (acc, notification) => + acc + + (notification.sourceType && + notification.sourceText && + notification.sourceType === 'bonus' + ? parseInt(notification.sourceText) + : 0), + 0 + ) + // loop through the contracts and combine the notification items into one + function combineNotificationsByAddingSourceTextsAndReturningTheRest( + notifications: Notification[] + ) { + const newNotifications = [] + const groupedNotificationsByContractId = groupBy( + notifications, + (notification) => { + return notification.sourceContractId + } + ) + for (const contractId in groupedNotificationsByContractId) { + const notificationsForContractId = + groupedNotificationsByContractId[contractId] + let sum = 0 + notificationsForContractId.forEach( + (notification) => + notification.sourceText && + (sum = parseInt(notification.sourceText) + sum) + ) + + const newNotification = + notificationsForContractId.length === 1 + ? notificationsForContractId[0] + : { + ...notificationsForContractId[0], + sourceText: sum.toString(), + } + newNotifications.push(newNotification) + } + return newNotifications + } + + const combinedNotifs = + combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications) + + return ( + <div + className={clsx( + 'relative cursor-pointer bg-white px-2 pt-6 text-sm', + className, + !expanded ? 'hover:bg-gray-100' : '', + highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' + )} + onClick={() => setExpanded(!expanded)} + > + {expanded && ( + <span + className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200" + aria-hidden="true" + /> + )} + <Row className={'items-center text-gray-500 sm:justify-start'}> + <TrendingUpIcon className={'text-primary h-7 w-7'} /> + <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> + <div + onClick={() => setExpanded(!expanded)} + className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} + > + <span> + {'Daily Income Summary: '} + <span className={'text-primary'}>{formatMoney(totalIncome)}</span> + </span> + </div> + <RelativeTimestamp time={notifications[0].createdTime} /> + </div> + </Row> + <div> + <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}> + {' '} + <div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}> + {!expanded ? ( + <> + {combinedNotifs + .slice(0, numSummaryLines) + .map((notification) => { + return ( + <NotificationItem + notification={notification} + justSummary={true} + key={notification.id} + /> + ) + })} + <div className={'text-sm text-gray-500 hover:underline '}> + {combinedNotifs.length - numSummaryLines > 0 + ? 'And ' + + (combinedNotifs.length - numSummaryLines) + + ' more...' + : ''} + </div> + </> + ) : ( + <> + {combinedNotifs.map((notification) => ( + <NotificationItem + notification={notification} + key={notification.id} + justSummary={false} + /> + ))} + </> + )} + </div> + </div> + + <div className={'mt-6 border-b border-gray-300'} /> + </div> + </div> + ) +} + function NotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string @@ -187,17 +340,28 @@ function NotificationGroupItem(props: { const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) - + const [highlighted, setHighlighted] = useState(false) useEffect(() => { + if (notifications.some((n) => !n.isSeen)) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } setNotificationsAsSeen(notifications) }, [notifications]) + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + return ( <div className={clsx( 'relative cursor-pointer bg-white px-2 pt-6 text-sm', className, - !expanded ? 'hover:bg-gray-100' : '' + !expanded ? 'hover:bg-gray-100' : '', + highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' )} onClick={() => setExpanded(!expanded)} > @@ -432,7 +596,7 @@ function NotificationSettings() { /> <NotificationSettingLine highlight={notificationSettings !== 'none'} - label={"Referral bonuses you've received"} + label={"Income & referral bonuses you've received"} /> <NotificationSettingLine label={"Activity on questions you've ever bet or commented on"} @@ -476,17 +640,6 @@ function NotificationSettings() { ) } -function isNotificationAboutContractResolution( - sourceType: notification_source_types | undefined, - sourceUpdateType: notification_source_update_types | undefined, - contract: Contract | null | undefined -) { - return ( - (sourceType === 'contract' && sourceUpdateType === 'resolved') || - (sourceType === 'contract' && !sourceUpdateType && contract?.resolution) - ) -} - function NotificationItem(props: { notification: Notification justSummary?: boolean @@ -522,6 +675,16 @@ function NotificationItem(props: { } }, [reasonText, sourceText]) + const [highlighted, setHighlighted] = useState(false) + useEffect(() => { + if (!notification.isSeen) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } + }, [notification.isSeen]) + useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) @@ -559,22 +722,21 @@ function NotificationItem(props: { <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> <div className={'flex pl-1 sm:pl-0'}> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> + {sourceType != 'bonus' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + /> + )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <span className={'flex-shrink-0'}> {sourceType && reason && - getReasonForShowingNotification( - sourceType, - reason, - sourceUpdateType, - undefined, - true - ).replace(' on', '')} + getReasonForShowingNotification(notification, true).replace( + ' on', + '' + )} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel @@ -593,37 +755,41 @@ function NotificationItem(props: { } return ( - <div className={'bg-white px-2 pt-6 text-sm sm:px-4'}> + <div + className={clsx( + 'bg-white px-2 pt-6 text-sm sm:px-4', + highlighted && 'bg-indigo-200' + )} + > <a href={getSourceUrl()}> <Row className={'items-center text-gray-500 sm:justify-start'}> - <Avatar - avatarUrl={sourceUserAvatarUrl} - size={'sm'} - className={'mr-2'} - username={sourceUserName} - /> + {sourceType != 'bonus' ? ( + <Avatar + avatarUrl={sourceUserAvatarUrl} + size={'sm'} + className={'mr-2'} + username={sourceUserName} + /> + ) : ( + <TrendingUpIcon className={'text-primary h-7 w-7'} /> + )} <div className={'flex-1 overflow-hidden sm:flex'}> <div className={ 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' } > - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> + {sourceType != 'bonus' && sourceUpdateType != 'closed' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + /> + )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> {sourceType && reason && ( <div className={'inline truncate'}> - {getReasonForShowingNotification( - sourceType, - reason, - sourceUpdateType, - undefined, - false, - sourceSlug - )} + {getReasonForShowingNotification(notification, false)} <a href={ sourceContractCreatorUsername @@ -684,13 +850,7 @@ function NotificationTextLabel(props: { return <span>{contract?.question || sourceContractTitle}</span> if (!sourceText) return <div /> // Resolved contracts - if ( - isNotificationAboutContractResolution( - sourceType, - sourceUpdateType, - contract - ) - ) { + if (sourceType === 'contract' && sourceUpdateType === 'resolved') { { if (sourceText === 'YES' || sourceText == 'NO') { return <BinaryOutcomeLabel outcome={sourceText as any} /> @@ -730,6 +890,12 @@ function NotificationTextLabel(props: { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> ) + } else if (sourceType === 'bonus' && sourceText) { + return ( + <span className="text-primary"> + {'+' + formatMoney(parseInt(sourceText))} + </span> + ) } // return default text return ( @@ -740,15 +906,13 @@ function NotificationTextLabel(props: { } function getReasonForShowingNotification( - source: notification_source_types, - reason: notification_reason_types, - sourceUpdateType: notification_source_update_types | undefined, - contract: Contract | undefined | null, - simple?: boolean, - sourceSlug?: string + notification: Notification, + simple?: boolean ) { + const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } = + notification let reasonText: string - switch (source) { + switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') reasonText = !simple ? 'replied to your answer on' : 'replied' @@ -768,16 +932,9 @@ function getReasonForShowingNotification( break case 'contract': if (reason === 'you_follow_user') reasonText = 'created a new question' - else if ( - isNotificationAboutContractResolution( - source, - sourceUpdateType, - contract - ) - ) - reasonText = `resolved` + else if (sourceUpdateType === 'resolved') reasonText = `resolved` else if (sourceUpdateType === 'closed') - reasonText = `please resolve your question` + reasonText = `Please resolve your question` else reasonText = `updated` break case 'answer': @@ -805,6 +962,15 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break + case 'bonus': + if (reason === 'unique_bettors_on_your_contract' && sourceText) + reasonText = !simple + ? `You had ${ + parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT + } unique bettors on` + : 'You earned Mana for unique bettors:' + else reasonText = 'You earned your daily manna' + break default: reasonText = '' } From 9bff858696a6a1502b8ff9bb3fec2868d3591524 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:25:44 -0700 Subject: [PATCH 22/39] Fix up lint configuration, lint line endings (#615) * Make sure we ignore all built code in common and functions * Add lint for Unix line endings * Fix line endings in withdraw-liquidity.ts --- common/.eslintrc.js | 2 + functions/.eslintrc.js | 3 +- functions/src/withdraw-liquidity.ts | 276 ++++++++++++++-------------- web/.eslintrc.js | 1 + 4 files changed, 143 insertions(+), 139 deletions(-) diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 3d6cfa82..c6f9703e 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -1,6 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], + ignorePatterns: ['lib'], env: { browser: true, node: true, @@ -31,6 +32,7 @@ module.exports = { rules: { 'no-extra-semi': 'off', 'no-constant-condition': ['error', { checkLoops: false }], + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 7f571610..2c607231 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], - ignorePatterns: ['lib'], + ignorePatterns: ['dist', 'lib'], env: { node: true, }, @@ -30,6 +30,7 @@ module.exports = { }, ], rules: { + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index 4c48ce49..cc8c84cf 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -1,138 +1,138 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { CPMMContract } from '../../common/contract' -import { User } from '../../common/user' -import { subtractObjects } from '../../common/util/object' -import { LiquidityProvision } from '../../common/liquidity-provision' -import { getUserLiquidityShares } from '../../common/calculate-cpmm' -import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' -import { noFees } from '../../common/fees' - -import { APIError } from './api' -import { redeemShares } from './redeem-shares' - -export const withdrawLiquidity = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } - - const { contractId } = data - if (!contractId) - return { status: 'error', message: 'Missing contract id' } - - return await firestore - .runTransaction(async (trans) => { - const lpDoc = firestore.doc(`users/${userId}`) - const lpSnap = await trans.get(lpDoc) - if (!lpSnap.exists) throw new APIError(400, 'User not found.') - const lp = lpSnap.data() as User - - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await trans.get(contractDoc) - if (!contractSnap.exists) - throw new APIError(400, 'Contract not found.') - const contract = contractSnap.data() as CPMMContract - - const liquidityCollection = firestore.collection( - `contracts/${contractId}/liquidity` - ) - - const liquiditiesSnap = await trans.get(liquidityCollection) - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const userShares = getUserLiquidityShares( - userId, - contract, - liquidities - ) - - // zero all added amounts for now - // can add support for partial withdrawals in the future - liquiditiesSnap.docs - .filter( - (_, i) => - !liquidities[i].isAnte && liquidities[i].userId === userId - ) - .forEach((doc) => trans.update(doc.ref, { amount: 0 })) - - const payout = Math.min(...Object.values(userShares)) - if (payout <= 0) return {} - - const newBalance = lp.balance + payout - const newTotalDeposits = lp.totalDeposits + payout - trans.update(lpDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - } as Partial<User>) - - const newPool = subtractObjects(contract.pool, userShares) - - const minPoolShares = Math.min(...Object.values(newPool)) - const adjustedTotal = contract.totalLiquidity - payout - - // total liquidity is a bogus number; use minPoolShares to prevent from going negative - const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) - - trans.update(contractDoc, { - pool: newPool, - totalLiquidity: newTotalLiquidity, - }) - - const prob = getProbability(contract) - - // surplus shares become user's bets - const bets = Object.entries(userShares) - .map(([outcome, shares]) => - shares - payout < 1 // don't create bet if less than 1 share - ? undefined - : ({ - userId: userId, - contractId: contract.id, - amount: - (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), - shares: shares - payout, - outcome, - probBefore: prob, - probAfter: prob, - createdTime: Date.now(), - isLiquidityProvision: true, - fees: noFees, - } as Omit<Bet, 'id'>) - ) - .filter((x) => x !== undefined) - - for (const bet of bets) { - const doc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - trans.create(doc, { id: doc.id, ...bet }) - } - - return userShares - }) - .then(async (result) => { - // redeem surplus bet with pre-existing bets - await redeemShares(userId, contractId) - - console.log('userid', userId, 'withdraws', result) - return { status: 'success', userShares: result } - }) - .catch((e) => { - return { status: 'error', message: e.message } - }) - } - ) - -const firestore = admin.firestore() +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { CPMMContract } from '../../common/contract' +import { User } from '../../common/user' +import { subtractObjects } from '../../common/util/object' +import { LiquidityProvision } from '../../common/liquidity-provision' +import { getUserLiquidityShares } from '../../common/calculate-cpmm' +import { Bet } from '../../common/bet' +import { getProbability } from '../../common/calculate' +import { noFees } from '../../common/fees' + +import { APIError } from './api' +import { redeemShares } from './redeem-shares' + +export const withdrawLiquidity = functions + .runWith({ minInstances: 1 }) + .https.onCall( + async ( + data: { + contractId: string + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId } = data + if (!contractId) + return { status: 'error', message: 'Missing contract id' } + + return await firestore + .runTransaction(async (trans) => { + const lpDoc = firestore.doc(`users/${userId}`) + const lpSnap = await trans.get(lpDoc) + if (!lpSnap.exists) throw new APIError(400, 'User not found.') + const lp = lpSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await trans.get(contractDoc) + if (!contractSnap.exists) + throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as CPMMContract + + const liquidityCollection = firestore.collection( + `contracts/${contractId}/liquidity` + ) + + const liquiditiesSnap = await trans.get(liquidityCollection) + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const userShares = getUserLiquidityShares( + userId, + contract, + liquidities + ) + + // zero all added amounts for now + // can add support for partial withdrawals in the future + liquiditiesSnap.docs + .filter( + (_, i) => + !liquidities[i].isAnte && liquidities[i].userId === userId + ) + .forEach((doc) => trans.update(doc.ref, { amount: 0 })) + + const payout = Math.min(...Object.values(userShares)) + if (payout <= 0) return {} + + const newBalance = lp.balance + payout + const newTotalDeposits = lp.totalDeposits + payout + trans.update(lpDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, + } as Partial<User>) + + const newPool = subtractObjects(contract.pool, userShares) + + const minPoolShares = Math.min(...Object.values(newPool)) + const adjustedTotal = contract.totalLiquidity - payout + + // total liquidity is a bogus number; use minPoolShares to prevent from going negative + const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) + + trans.update(contractDoc, { + pool: newPool, + totalLiquidity: newTotalLiquidity, + }) + + const prob = getProbability(contract) + + // surplus shares become user's bets + const bets = Object.entries(userShares) + .map(([outcome, shares]) => + shares - payout < 1 // don't create bet if less than 1 share + ? undefined + : ({ + userId: userId, + contractId: contract.id, + amount: + (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), + shares: shares - payout, + outcome, + probBefore: prob, + probAfter: prob, + createdTime: Date.now(), + isLiquidityProvision: true, + fees: noFees, + } as Omit<Bet, 'id'>) + ) + .filter((x) => x !== undefined) + + for (const bet of bets) { + const doc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + trans.create(doc, { id: doc.id, ...bet }) + } + + return userShares + }) + .then(async (result) => { + // redeem surplus bet with pre-existing bets + await redeemShares(userId, contractId) + + console.log('userid', userId, 'withdraws', result) + return { status: 'success', userShares: result } + }) + .catch((e) => { + return { status: 'error', message: e.message } + }) + } + ) + +const firestore = admin.firestore() diff --git a/web/.eslintrc.js b/web/.eslintrc.js index b55b3277..fec650f9 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { ], '@next/next/no-img-element': 'off', '@next/next/no-typos': 'off', + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, env: { From a9e74e71119d65b4d7f819370d450d0c4e1e2da3 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:25:58 -0700 Subject: [PATCH 23/39] Add functions framework as explicit dependency (#613) --- functions/package.json | 1 + yarn.lock | 208 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 194 insertions(+), 15 deletions(-) diff --git a/functions/package.json b/functions/package.json index 93bea621..4c9f4338 100644 --- a/functions/package.json +++ b/functions/package.json @@ -23,6 +23,7 @@ "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", + "@google-cloud/functions-framework": "3.1.2", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/yarn.lock b/yarn.lock index c07d548f..0ee2aa0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2181,6 +2181,20 @@ google-gax "^2.24.1" protobufjs "^6.8.6" +"@google-cloud/functions-framework@3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@google-cloud/functions-framework/-/functions-framework-3.1.2.tgz#2cd92ce4307bf7f32555d028dca22e398473b410" + integrity sha512-pYvEH65/Rqh1JNPdcBmorcV7Xoom2/iOSmbtYza8msro7Inl+qOYxbyMiQfySD2gwAyn38WyWPRqsDRcf/BFLg== + dependencies: + "@types/express" "4.17.13" + body-parser "^1.18.3" + cloudevents "^6.0.0" + express "^4.16.4" + minimist "^1.2.5" + on-finished "^2.3.0" + read-pkg-up "^7.0.1" + semver "^7.3.5" + "@google-cloud/paginator@^3.0.7": version "3.0.7" resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" @@ -2926,7 +2940,7 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@*", "@types/express@^4.17.13": +"@types/express@*", "@types/express@4.17.13", "@types/express@^4.17.13": version "4.17.13" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== @@ -3049,6 +3063,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947" integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g== +"@types/normalize-package-data@^2.4.0": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" + integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -3498,7 +3517,7 @@ ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.8.0: +ajv@^8.0.0, ajv@^8.11.0, ajv@^8.8.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== @@ -3750,6 +3769,11 @@ autoprefixer@^10.3.7, autoprefixer@^10.4.2: picocolors "^1.0.0" postcss-value-parser "^4.2.0" +available-typed-arrays@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" + integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== + axe-core@^4.3.5: version "4.4.2" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.2.tgz#dcf7fb6dea866166c3eab33d68208afe4d5f670c" @@ -3880,7 +3904,7 @@ bluebird@^3.7.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -body-parser@1.20.0: +body-parser@1.20.0, body-parser@^1.18.3: version "1.20.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== @@ -4236,6 +4260,16 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +cloudevents@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/cloudevents/-/cloudevents-6.0.2.tgz#7b4990a92c6c30f6790eb4b59207b4d8949fca12" + integrity sha512-mn/4EZnAbhfb/TghubK2jPnxYM15JRjf8LnWJtXidiVKi5ZCkd+p9jyBZbL57w7nRm6oFAzJhjxRLsXd/DNaBQ== + dependencies: + ajv "^8.11.0" + ajv-formats "^2.1.1" + util "^0.12.4" + uuid "^8.3.2" + clsx@1.1.1, clsx@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" @@ -5277,7 +5311,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5: +es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.0: version "1.20.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== @@ -5657,7 +5691,7 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -express@^4.17.1, express@^4.17.3: +express@^4.16.4, express@^4.17.1, express@^4.17.3: version "4.18.1" resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== @@ -5871,7 +5905,7 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" -find-up@^4.0.0: +find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== @@ -5981,6 +6015,13 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.7: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.2" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz#4f67183f2f9eb8ba7df7177ce3cf3e75cdafb340" @@ -6585,6 +6626,11 @@ hoist-non-react-statics@^3.1.0: dependencies: react-is "^16.7.0" +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -6945,6 +6991,14 @@ is-alphanumerical@^1.0.0: is-alphabetical "^1.0.0" is-decimal "^1.0.0" +is-arguments@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -6977,7 +7031,7 @@ is-buffer@^2.0.0: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-callable@^1.1.4, is-callable@^1.2.4: +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== @@ -6989,7 +7043,7 @@ is-ci@^2.0.0: dependencies: ci-info "^2.0.0" -is-core-module@^2.2.0, is-core-module@^2.8.1: +is-core-module@^2.2.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== @@ -7028,6 +7082,13 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -7161,6 +7222,17 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-typed-array@^1.1.3, is-typed-array@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.9.tgz#246d77d2871e7d9f5aeb1d54b9f52c71329ece67" + integrity sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -8126,6 +8198,16 @@ nopt@1.0.10: dependencies: abbrev "1" +normalize-package-data@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -8252,7 +8334,7 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -on-finished@2.4.1: +on-finished@2.4.1, on-finished@^2.3.0: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -9463,6 +9545,25 @@ react@17.0.2, react@^17.0.1: loose-envify "^1.1.0" object-assign "^4.1.1" +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + readable-stream@1.1.x: version "1.1.14" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" @@ -9767,6 +9868,15 @@ resolve@^1.1.6, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.3. path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^1.10.0: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^2.0.0-next.3: version "2.0.0-next.3" resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46" @@ -9848,7 +9958,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -9959,16 +10069,16 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + semver@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.4.1, semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" @@ -10223,6 +10333,32 @@ spawn-command@^0.0.2-1: resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A= +spdx-correct@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.11" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz#50c0d8c40a14ec1bf449bae69a0ea4685a9d9f95" + integrity sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g== + spdy-transport@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" @@ -10706,6 +10842,16 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + type-fest@^2.5.0: version "2.13.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.13.0.tgz#d1ecee38af29eb2e863b22299a3d68ef30d2abfb" @@ -10974,6 +11120,18 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= +util@^0.12.4: + version "0.12.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" + integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + safe-buffer "^5.1.2" + which-typed-array "^1.1.2" + utila@~0.4: version "0.4.0" resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" @@ -10999,6 +11157,14 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + value-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" @@ -11232,6 +11398,18 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-typed-array@^1.1.2: + version "1.1.8" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.8.tgz#0cfd53401a6f334d90ed1125754a42ed663eb01f" + integrity sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + is-typed-array "^1.1.9" + which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" From f0fbdf1b42490a80e0899bcfcfded8d3c26aa61f Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:26:13 -0700 Subject: [PATCH 24/39] Add a missing index (#606) --- firestore.indexes.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/firestore.indexes.json b/firestore.indexes.json index 064f6f2f..e0cee632 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -337,6 +337,20 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "portfolioHistory", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [ From 7f2bbdcb878477604d6c9c27b58d801054493e7a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:26:51 -0700 Subject: [PATCH 25/39] Allow people to sell all their shares (#599) --- functions/src/sell-shares.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index a0c19f2c..62e43105 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -46,7 +46,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) const maxShares = sumBy(outcomeBets, (bet) => bet.shares) - if (shares > maxShares + 0.000000000001) + if (shares > maxShares) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( From 4d1c50a6cca80ba20b674f0bd90dbda9e4a17aac Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 12:35:39 -0700 Subject: [PATCH 26/39] Redemption refactoring (#614) * Refactor share redemption code into a few sensible functions * Put very general share redemption code into common --- common/new-bet.ts | 10 ++-- common/redeem.ts | 54 ++++++++++++++++++++++ functions/src/redeem-shares.ts | 83 ++++++---------------------------- 3 files changed, 74 insertions(+), 73 deletions(-) create mode 100644 common/redeem.ts diff --git a/common/new-bet.ts b/common/new-bet.ts index 236c0908..57739af3 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -20,9 +20,9 @@ import { noFees } from './fees' import { addObjects } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' -export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'> +export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'> export type BetInfo = { - newBet: CandidateBet<Bet> + newBet: CandidateBet newPool?: { [outcome: string]: number } newTotalShares?: { [outcome: string]: number } newTotalBets?: { [outcome: string]: number } @@ -46,7 +46,7 @@ export const getNewBinaryCpmmBetInfo = ( const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, newP) - const newBet: CandidateBet<Bet> = { + const newBet: CandidateBet = { contractId: contract.id, amount, shares, @@ -96,7 +96,7 @@ export const getNewBinaryDpmBetInfo = ( const probBefore = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(newTotalShares) - const newBet: CandidateBet<Bet> = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -133,7 +133,7 @@ export const getNewMultiBetInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: CandidateBet<Bet> = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, diff --git a/common/redeem.ts b/common/redeem.ts new file mode 100644 index 00000000..4a4080f6 --- /dev/null +++ b/common/redeem.ts @@ -0,0 +1,54 @@ +import { partition, sumBy } from 'lodash' + +import { Bet } from './bet' +import { getProbability } from './calculate' +import { CPMMContract } from './contract' +import { noFees } from './fees' +import { CandidateBet } from './new-bet' + +type RedeemableBet = Pick<Bet, 'outcome' | 'shares' | 'loanAmount'> + +export const getRedeemableAmount = (bets: RedeemableBet[]) => { + const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') + const yesShares = sumBy(yesBets, (b) => b.shares) + const noShares = sumBy(noBets, (b) => b.shares) + const shares = Math.max(Math.min(yesShares, noShares), 0) + const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) + const loanPayment = Math.min(loanAmount, shares) + const netAmount = shares - loanPayment + return { shares, loanPayment, netAmount } +} + +export const getRedemptionBets = ( + shares: number, + loanPayment: number, + contract: CPMMContract +) => { + const p = getProbability(contract) + const createdTime = Date.now() + const yesBet: CandidateBet = { + contractId: contract.id, + amount: p * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'YES', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + const noBet: CandidateBet = { + contractId: contract.id, + amount: (1 - p) * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'NO', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + return [yesBet, noBet] +} diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 67922a65..32b1d433 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -1,96 +1,43 @@ import * as admin from 'firebase-admin' -import { partition, sumBy } from 'lodash' import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' +import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' -import { noFees } from '../../common/fees' import { User } from '../../common/user' export const redeemShares = async (userId: string, contractId: string) => { - return await firestore.runTransaction(async (transaction) => { + return await firestore.runTransaction(async (trans) => { const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) + const contractSnap = await trans.get(contractDoc) if (!contractSnap.exists) return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - const { mechanism, outcomeType } = contract - if ( - !(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') || - mechanism !== 'cpmm-1' - ) - return { status: 'success' } + const { mechanism } = contract + if (mechanism !== 'cpmm-1') return { status: 'success' } - const betsSnap = await transaction.get( - firestore - .collection(`contracts/${contract.id}/bets`) - .where('userId', '==', userId) - ) + const betsColl = firestore.collection(`contracts/${contract.id}/bets`) + const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') - const yesShares = sumBy(yesBets, (b) => b.shares) - const noShares = sumBy(noBets, (b) => b.shares) - - const amount = Math.min(yesShares, noShares) - if (amount <= 0) return - - const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) - const loanPaid = Math.min(prevLoanAmount, amount) - const netAmount = amount - loanPaid - - const p = getProbability(contract) - const createdTime = Date.now() - - const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const yesBet: Bet = { - id: yesDoc.id, - userId: userId, - contractId: contract.id, - amount: p * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'YES', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, - } - - const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const noBet: Bet = { - id: noDoc.id, - userId: userId, - contractId: contract.id, - amount: (1 - p) * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'NO', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, - } + const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) + const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) + const userSnap = await trans.get(userDoc) if (!userSnap.exists) return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User - const newBalance = user.balance + netAmount if (!isFinite(newBalance)) { throw new Error('Invalid user balance for ' + user.username) } - transaction.update(userDoc, { balance: newBalance }) - - transaction.create(yesDoc, yesBet) - transaction.create(noDoc, noBet) + const yesDoc = betsColl.doc() + const noDoc = betsColl.doc() + trans.update(userDoc, { balance: newBalance }) + trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet }) + trans.create(noDoc, { id: noDoc.id, userId, ...noBet }) return { status: 'success' } }) From 5eca9def9d011c80e4ea0ea6452f10012b17054a Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Tue, 5 Jul 2022 14:01:57 -0700 Subject: [PATCH 27/39] Don't accidentally make meaningless zero bets (#619) --- functions/src/redeem-shares.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 32b1d433..0a69521f 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -21,6 +21,9 @@ export const redeemShares = async (userId: string, contractId: string) => { const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) + if (netAmount === 0) { + return { status: 'success' } + } const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const userDoc = firestore.doc(`users/${userId}`) From 270a5fc13911e63f0d6a8615a56c478868bc9547 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Tue, 5 Jul 2022 14:34:16 -0700 Subject: [PATCH 28/39] also filter by username when adding people --- web/components/filter-select-users.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 8d2dbbae..7ce73cf8 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -35,7 +35,8 @@ export function FilterSelectUsers(props: { return ( !selectedUsers.map((user) => user.name).includes(user.name) && !ignoreUserIds.includes(user.id) && - user.name.toLowerCase().includes(query.toLowerCase()) + (user.name.toLowerCase().includes(query.toLowerCase()) || + user.username.toLowerCase().includes(query.toLowerCase())) ) }) ) From 3a6d28e2c2b94c26207c5abff126eae50410da4f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 5 Jul 2022 17:18:37 -0600 Subject: [PATCH 29/39] Bold groups with recent chat activity (#621) * Bold groups with recent chat activity * Cleanup * Cleanup --- common/notification.ts | 3 ++ functions/src/create-notification.ts | 16 +++++- functions/src/index.ts | 3 +- ...nt.ts => on-create-comment-on-contract.ts} | 2 +- functions/src/on-create-comment-on-group.ts | 52 +++++++++++++++++++ functions/src/on-update-group.ts | 1 + web/components/nav/sidebar.tsx | 48 ++++++++++++++--- web/hooks/use-notifications.ts | 10 ++-- web/pages/notifications.tsx | 4 +- 9 files changed, 123 insertions(+), 16 deletions(-) rename functions/src/{on-create-comment.ts => on-create-comment-on-contract.ts} (98%) create mode 100644 functions/src/on-create-comment-on-group.ts diff --git a/common/notification.ts b/common/notification.ts index e90624a4..16444c48 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -22,6 +22,8 @@ export type Notification = { sourceSlug?: string sourceTitle?: string + + isSeenOnHref?: string } export type notification_source_types = | 'contract' @@ -58,3 +60,4 @@ export type notification_reason_types = | 'you_referred_user' | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' + | 'on_group_you_are_member_of' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index b63958f0..45db1c4e 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types } + [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } } export const createNotification = async ( @@ -72,6 +72,7 @@ export const createNotification = async ( sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, + isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -276,6 +277,17 @@ export const createNotification = async ( } } + const notifyOtherGroupMembersOfComment = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasonTexts)) + userToReasonTexts[userId] = { + reason: 'on_group_you_are_member_of', + isSeeOnHref: sourceSlug, + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -286,6 +298,8 @@ export const createNotification = async ( await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) } else if (sourceType === 'user' && relatedUserId) { await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) + } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { + await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) } // The following functions need sourceContract to be defined. diff --git a/functions/src/index.ts b/functions/src/index.ts index e4a30761..d9b7a255 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -10,7 +10,7 @@ export * from './stripe' export * from './create-user' export * from './create-answer' export * from './on-create-bet' -export * from './on-create-comment' +export * from './on-create-comment-on-contract' export * from './on-view' export * from './unsubscribe' export * from './update-metrics' @@ -28,6 +28,7 @@ export * from './on-create-liquidity-provision' export * from './on-update-group' export * from './on-create-group' export * from './on-update-user' +export * from './on-create-comment-on-group' // v2 export * from './health' diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment-on-contract.ts similarity index 98% rename from functions/src/on-create-comment.ts rename to functions/src/on-create-comment-on-contract.ts index 8d52fd46..f7839b44 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -11,7 +11,7 @@ import { createNotification } from './create-notification' const firestore = admin.firestore() -export const onCreateComment = functions +export const onCreateCommentOnContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) .firestore.document('contracts/{contractId}/comments/{commentId}') .onCreate(async (change, context) => { diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts new file mode 100644 index 00000000..7217e602 --- /dev/null +++ b/functions/src/on-create-comment-on-group.ts @@ -0,0 +1,52 @@ +import * as functions from 'firebase-functions' +import { Comment } from '../../common/comment' +import * as admin from 'firebase-admin' +import { Group } from '../../common/group' +import { User } from '../../common/user' +import { createNotification } from './create-notification' +const firestore = admin.firestore() + +export const onCreateCommentOnGroup = functions.firestore + .document('groups/{groupId}/comments/{commentId}') + .onCreate(async (change, context) => { + const { eventId } = context + const { groupId } = context.params as { + groupId: string + } + + const comment = change.data() as Comment + const creatorSnapshot = await firestore + .collection('users') + .doc(comment.userId) + .get() + if (!creatorSnapshot.exists) throw new Error('Could not find user') + + const groupSnapshot = await firestore + .collection('groups') + .doc(groupId) + .get() + if (!groupSnapshot.exists) throw new Error('Could not find group') + + const group = groupSnapshot.data() as Group + await firestore.collection('groups').doc(groupId).update({ + mostRecentActivityTime: comment.createdTime, + }) + + await Promise.all( + group.memberIds.map(async (memberId) => { + return await createNotification( + comment.id, + 'comment', + 'created', + creatorSnapshot.data() as User, + eventId, + comment.text, + undefined, + undefined, + memberId, + `/group/${group.slug}`, + `${group.name}` + ) + }) + ) + }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index bc6f6ab4..feaa6443 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return + // TODO: create notification with isSeeOnHref set to the group's /group/questions url await firestore .collection('groups') diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index ba46bd80..b9449ea0 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React from 'react' +import React, { useEffect } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' @@ -26,6 +26,8 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' +import { usePreferredNotifications } from 'web/hooks/use-notifications' +import { setNotificationsAsSeen } from 'web/pages/notifications' function getNavigation() { return [ @@ -182,6 +184,7 @@ export default function Sidebar(props: { className?: string }) { const { className } = props const router = useRouter() const currentPage = router.pathname + const user = useUser() const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user @@ -217,7 +220,11 @@ export default function Sidebar(props: { className?: string }) { /> )} - <GroupsList currentPage={currentPage} memberItems={memberItems} /> + <GroupsList + currentPage={router.asPath} + memberItems={memberItems} + user={user} + /> </div> {/* Desktop navigation */} @@ -236,14 +243,36 @@ export default function Sidebar(props: { className?: string }) { <div className="h-[1px] bg-gray-300" /> </div> )} - <GroupsList currentPage={currentPage} memberItems={memberItems} /> + <GroupsList + currentPage={router.asPath} + memberItems={memberItems} + user={user} + /> </div> </nav> ) } -function GroupsList(props: { currentPage: string; memberItems: Item[] }) { - const { currentPage, memberItems } = props +function GroupsList(props: { + currentPage: string + memberItems: Item[] + user: User | null | undefined +}) { + const { currentPage, memberItems, user } = props + const preferredNotifications = usePreferredNotifications(user?.id, { + unseenOnly: true, + customHref: '/group/', + }) + + // Set notification as seen if our current page is equal to the isSeenOnHref property + useEffect(() => { + preferredNotifications.forEach((notification) => { + if (notification.isSeenOnHref === currentPage) { + setNotificationsAsSeen([notification]) + } + }) + }, [currentPage, preferredNotifications]) + return ( <> <SidebarItem @@ -256,9 +285,14 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) { <a key={item.href} href={item.href} - className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900" + className={clsx( + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900', + preferredNotifications.some( + (n) => !n.isSeen && n.isSeenOnHref === item.href + ) && 'font-bold' + )} > - <span className="truncate">  {item.name}</span> + <span className="truncate">{item.name}</span> </a> ))} </div> diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 0a15754d..539573dd 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -83,11 +83,11 @@ export function groupNotifications(notifications: Notification[]) { return notificationGroups } -function usePreferredNotifications( +export function usePreferredNotifications( userId: string | undefined, - options: { unseenOnly: boolean } + options: { unseenOnly: boolean; customHref?: string } ) { - const { unseenOnly } = options + const { unseenOnly, customHref } = options const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null) const [notifications, setNotifications] = useState<Notification[]>([]) const [userAppropriateNotifications, setUserAppropriateNotifications] = @@ -112,9 +112,11 @@ function usePreferredNotifications( const notificationsToShow = getAppropriateNotifications( notifications, privateUser.notificationPreferences + ).filter((n) => + customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref ) setUserAppropriateNotifications(notificationsToShow) - }, [privateUser, notifications]) + }, [privateUser, notifications, customHref]) return userAppropriateNotifications } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 229e8c8d..569f8ef8 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -166,7 +166,7 @@ export default function Notifications() { ) } -const setNotificationsAsSeen = (notifications: Notification[]) => { +export const setNotificationsAsSeen = (notifications: Notification[]) => { notifications.forEach((notification) => { if (!notification.isSeen) updateDoc( @@ -758,7 +758,7 @@ function NotificationItem(props: { <div className={clsx( 'bg-white px-2 pt-6 text-sm sm:px-4', - highlighted && 'bg-indigo-200' + highlighted && 'bg-indigo-200 hover:bg-indigo-100' )} > <a href={getSourceUrl()}> From cb25a7752d8552f7dd6ae01483508d435067f0ff Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 16:26:58 -0700 Subject: [PATCH 30/39] Duplicate a question from '...' screen (#622) * Duplicate a question from '...' screen * Remove unused code --- .../contract/contract-info-dialog.tsx | 2 + web/components/copy-contract-button.tsx | 54 +++++++++++++ web/pages/create.tsx | 78 +++++++++++-------- 3 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 web/components/copy-contract-button.tsx diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 12fd8dd9..3e51902b 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -21,6 +21,7 @@ import { Title } from '../title' import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' import { TagsInput } from 'web/components/tags-input' +import { DuplicateContractButton } from '../copy-contract-button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -71,6 +72,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { tweetText={getTweetText(contract, false)} /> <ShareEmbedButton contract={contract} toastClassName={'-left-20'} /> + <DuplicateContractButton contract={contract} /> </Row> <div /> diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx new file mode 100644 index 00000000..ad378878 --- /dev/null +++ b/web/components/copy-contract-button.tsx @@ -0,0 +1,54 @@ +import { DuplicateIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { getMappedValue } from 'common/pseudo-numeric' +import { trackCallback } from 'web/lib/service/analytics' + +export function DuplicateContractButton(props: { + contract: Contract + className?: string +}) { + const { contract, className } = props + + return ( + <a + className={clsx('btn btn-xs flex-nowrap normal-case', className)} + style={{ + backgroundColor: 'white', + border: '2px solid #a78bfa', + // violet-400 + color: '#a78bfa', + }} + href={duplicateContractHref(contract)} + onClick={trackCallback('duplicate market')} + target="_blank" + > + <DuplicateIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> + <div>Duplicate</div> + </a> + ) +} + +// Pass along the Uri to create a new contract +function duplicateContractHref(contract: Contract) { + const params = { + q: contract.question, + closeTime: contract.closeTime || 0, + description: contract.description, + outcomeType: contract.outcomeType, + } as Record<string, any> + + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + params.min = contract.min + params.max = contract.max + params.isLogScale = contract.isLogScale + params.initValue = getMappedValue(contract)(contract.initialProbability) + } + + return ( + `/create?` + + Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&') + ) +} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 6a5f96ae..95b8e247 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -28,14 +28,32 @@ import { GroupSelector } from 'web/components/groups/group-selector' import { CATEGORIES } from 'common/categories' import { User } from 'common/user' -export default function Create() { - const [question, setQuestion] = useState('') - // get query params: - const router = useRouter() - const { groupId } = router.query as { groupId: string } - useTracking('view create page') - const creator = useUser() +type NewQuestionParams = { + groupId?: string + q: string + type: string + description: string + closeTime: string + outcomeType: string + // Params for PSEUDO_NUMERIC outcomeType + min?: string + max?: string + isLogScale?: string + initValue?: string +} +export default function Create() { + useTracking('view create page') + const router = useRouter() + const params = router.query as NewQuestionParams + // TODO: Not sure why Question is pulled out as its own component; + // Maybe merge into newContract and then we don't need useEffect here. + const [question, setQuestion] = useState('') + useEffect(() => { + setQuestion(params.q ?? '') + }, [params.q]) + + const creator = useUser() useEffect(() => { if (creator === null) router.push('/') }, [creator, router]) @@ -65,11 +83,7 @@ export default function Create() { </div> </form> <Spacer h={6} /> - <NewContract - question={question} - groupId={groupId} - creator={creator} - /> + <NewContract question={question} params={params} creator={creator} /> </div> </div> </Page> @@ -80,20 +94,21 @@ export default function Create() { export function NewContract(props: { creator: User question: string - groupId?: string + params?: NewQuestionParams }) { - const { creator, question, groupId } = props - const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') + const { creator, question, params } = props + const { groupId, initValue } = params ?? {} + const [outcomeType, setOutcomeType] = useState<outcomeType>( + (params?.outcomeType as outcomeType) ?? 'BINARY' + ) const [initialProb] = useState(50) - const [minString, setMinString] = useState('') - const [maxString, setMaxString] = useState('') - const [isLogScale, setIsLogScale] = useState(false) - const [initialValueString, setInitialValueString] = useState('') + const [minString, setMinString] = useState(params?.min ?? '') + const [maxString, setMaxString] = useState(params?.max ?? '') + const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale) + const [initialValueString, setInitialValueString] = useState(initValue) - const [description, setDescription] = useState('') - // const [tagText, setTagText] = useState<string>(tag ?? '') - // const tags = parseWordsAsTags(tagText) + const [description, setDescription] = useState(params?.description ?? '') useEffect(() => { if (groupId && creator) getGroup(groupId).then((group) => { @@ -105,18 +120,17 @@ export function NewContract(props: { }, [creator, groupId]) const [ante, _setAnte] = useState(FIXED_ANTE) - // useEffect(() => { - // if (ante === null && creator) { - // const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100 - // setAnte(initialAnte) - // } - // }, [ante, creator]) - - // const [anteError, setAnteError] = useState<string | undefined>() + // If params.closeTime is set, extract out the specified date and time // By default, close the market a week from today const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DD') - const [closeDate, setCloseDate] = useState<undefined | string>(weekFromToday) - const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>('23:59') + const timeInMs = Number(params?.closeTime ?? 0) + const initDate = timeInMs + ? dayjs(timeInMs).format('YYYY-MM-DD') + : weekFromToday + const initTime = timeInMs ? dayjs(timeInMs).format('HH:mm') : '23:59' + const [closeDate, setCloseDate] = useState<undefined | string>(initDate) + const [closeHoursMinutes, setCloseHoursMinutes] = useState<string>(initTime) + const [marketInfoText, setMarketInfoText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [selectedGroup, setSelectedGroup] = useState<Group | undefined>( From b71944607b7cd4701ce1a901a08c93c2196dd6b8 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 16:48:59 -0700 Subject: [PATCH 31/39] Simplify Tweet text --- .../contract/contract-info-dialog.tsx | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 3e51902b..b5ecea15 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,11 +7,7 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { - contractPath, - contractPool, - getBinaryProbPercent, -} from 'web/lib/firebase/contracts' +import { contractPath, contractPool } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' @@ -69,7 +65,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { <Row className="justify-start gap-4"> <TweetButton className="self-start" - tweetText={getTweetText(contract, false)} + tweetText={getTweetText(contract)} /> <ShareEmbedButton contract={contract} toastClassName={'-left-20'} /> <DuplicateContractButton contract={contract} /> @@ -157,23 +153,13 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { ) } -const getTweetText = (contract: Contract, isCreator: boolean) => { - const { question, creatorName, resolution, outcomeType } = contract - const isBinary = outcomeType === 'BINARY' +const getTweetText = (contract: Contract) => { + const { question, resolution } = contract - const tweetQuestion = isCreator - ? question - : `${question}\nAsked by ${creatorName}.` - const tweetDescription = resolution - ? `Resolved ${resolution}!` - : isBinary - ? `Currently ${getBinaryProbPercent( - contract - )} chance, place your bets here:` - : `Submit your own answer:` + const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : '' const timeParam = `${Date.now()}`.substring(7) const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}` - return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` + return `${question}\n\n${url}${tweetDescription}` } From 6cd8b04bd01f1f9852eff8d056c7fa683a3e34e8 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 16:53:00 -0700 Subject: [PATCH 32/39] Nit: Fix spacing --- common/pseudo-numeric.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts index 9a322e35..c99e670f 100644 --- a/common/pseudo-numeric.ts +++ b/common/pseudo-numeric.ts @@ -17,7 +17,7 @@ export const getMappedValue = if (isLogScale) { const logValue = p * Math.log10(max - min) - return 10 ** logValue + min + return 10 ** logValue + min } return p * (max - min) + min From 029021b35117a572e16ef8b30eb025cc41daa663 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Tue, 5 Jul 2022 17:20:37 -0700 Subject: [PATCH 33/39] Remove Categories from /create --- web/pages/create.tsx | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 95b8e247..df83fb9f 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -25,7 +25,6 @@ import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { track } from 'web/lib/service/analytics' import { GroupSelector } from 'web/components/groups/group-selector' -import { CATEGORIES } from 'common/categories' import { User } from 'common/user' type NewQuestionParams = { @@ -137,7 +136,6 @@ export function NewContract(props: { undefined ) const [showGroupSelector, setShowGroupSelector] = useState(true) - const [category, setCategory] = useState<string>('') const closeTime = closeDate ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() @@ -210,7 +208,6 @@ export function NewContract(props: { initialValue, isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, - tags: category ? [category] : undefined, }) ) track('create market', { @@ -352,28 +349,6 @@ export function NewContract(props: { </> )} - <div className="form-control max-w-[265px] items-start"> - <label className="label gap-2"> - <span className="mb-1">Category</span> - </label> - - <select - className={clsx( - 'select select-bordered w-full text-sm', - category === '' ? 'font-normal text-gray-500' : '' - )} - value={category} - onChange={(e) => setCategory(e.currentTarget.value ?? '')} - > - <option value={''}>None</option> - {Object.entries(CATEGORIES).map(([id, name]) => ( - <option key={id} value={id}> - {name} - </option> - ))} - </select> - </div> - <div className={'mt-2'}> <GroupSelector selectedGroup={selectedGroup} From a6143c1abb791507fee58d4bce7c5f6f5a966830 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 07:27:21 -0600 Subject: [PATCH 34/39] Always group income --- web/pages/notifications.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 569f8ef8..e20b6028 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -83,16 +83,16 @@ export default function Notifications() { {paginatedNotificationGroups.length === 0 && "You don't have any notifications. Try changing your settings to see more."} {paginatedNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( - <NotificationItem - notification={notification.notifications[0]} - key={notification.notifications[0].id} - /> - ) : notification.type === 'income' ? ( + notification.type === 'income' ? ( <IncomeNotificationGroupItem notificationGroup={notification} key={notification.groupedById + notification.timePeriod} /> + ) : notification.notifications.length === 1 ? ( + <NotificationItem + notification={notification.notifications[0]} + key={notification.notifications[0].id} + /> ) : ( <NotificationGroupItem notificationGroup={notification} From 83a02c4b20035592067b2bee97de23b4b7838163 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 07:45:47 -0600 Subject: [PATCH 35/39] Small notifications ux improvements --- web/pages/notifications.tsx | 98 +++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index e20b6028..185225e9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -275,7 +275,9 @@ function IncomeNotificationGroupItem(props: { > <span> {'Daily Income Summary: '} - <span className={'text-primary'}>{formatMoney(totalIncome)}</span> + <span className={'text-primary'}> + {'+' + formatMoney(totalIncome)} + </span> </span> </div> <RelativeTimestamp time={notifications[0].createdTime} /> @@ -291,11 +293,44 @@ function IncomeNotificationGroupItem(props: { .slice(0, numSummaryLines) .map((notification) => { return ( - <NotificationItem - notification={notification} - justSummary={true} + <Row + className={ + 'items-center text-sm text-gray-500 sm:justify-start' + } key={notification.id} - /> + > + <div + className={ + 'line-clamp-1 flex-1 overflow-hidden sm:flex' + } + > + <div className={'flex pl-1 sm:pl-0'}> + <div + className={ + 'inline-flex overflow-hidden text-ellipsis pl-1' + } + > + <div className={'mr-1 text-black'}> + <NotificationTextLabel + contract={null} + defaultText={notification.sourceText ?? ''} + className={'line-clamp-1'} + notification={notification} + justSummary={true} + /> + </div> + <span className={'flex-shrink-0'}> + {getReasonForShowingNotification( + notification, + true + )} + {` on`} + <NotificationLink notification={notification} /> + </span> + </div> + </div> + </div> + </Row> ) })} <div className={'text-sm text-gray-500 hover:underline '}> @@ -640,6 +675,34 @@ function NotificationSettings() { ) } +function NotificationLink(props: { notification: Notification }) { + const { notification } = props + const { + sourceType, + sourceContractTitle, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + sourceTitle, + } = notification + return ( + <a + href={ + sourceContractCreatorUsername + ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` + : sourceType === 'group' && sourceSlug + ? `${groupPath(sourceSlug)}` + : '' + } + className={ + 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' + } + > + {sourceContractTitle || sourceTitle} + </a> + ) +} + function NotificationItem(props: { notification: Notification justSummary?: boolean @@ -656,11 +719,9 @@ function NotificationItem(props: { sourceUserUsername, createdTime, sourceText, - sourceContractTitle, sourceContractCreatorUsername, sourceContractSlug, sourceSlug, - sourceTitle, } = notification const [defaultNotificationText, setDefaultNotificationText] = @@ -790,20 +851,7 @@ function NotificationItem(props: { {sourceType && reason && ( <div className={'inline truncate'}> {getReasonForShowingNotification(notification, false)} - <a - href={ - sourceContractCreatorUsername - ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : sourceType === 'group' && sourceSlug - ? `${groupPath(sourceSlug)}` - : '' - } - className={ - 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' - } - > - {sourceContractTitle || sourceTitle} - </a> + <NotificationLink notification={notification} /> </div> )} </div> @@ -892,9 +940,7 @@ function NotificationTextLabel(props: { ) } else if (sourceType === 'bonus' && sourceText) { return ( - <span className="text-primary"> - {'+' + formatMoney(parseInt(sourceText))} - </span> + <span className="text-primary">{formatMoney(parseInt(sourceText))}</span> ) } // return default text @@ -931,7 +977,7 @@ function getReasonForShowingNotification( else reasonText = `commented on` break case 'contract': - if (reason === 'you_follow_user') reasonText = 'created a new question' + if (reason === 'you_follow_user') reasonText = 'asked' else if (sourceUpdateType === 'resolved') reasonText = `resolved` else if (sourceUpdateType === 'closed') reasonText = `Please resolve your question` @@ -968,7 +1014,7 @@ function getReasonForShowingNotification( ? `You had ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT } unique bettors on` - : 'You earned Mana for unique bettors:' + : ' for unique bettors' else reasonText = 'You earned your daily manna' break default: From 434b8b9dbe2f29b4ecbdd61aab0e45d23fafa053 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 07:51:32 -0600 Subject: [PATCH 36/39] Just show first names to save space --- web/components/notifications-icon.tsx | 2 +- web/components/user-page.tsx | 5 +++-- web/pages/notifications.tsx | 14 +++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index ac4d772f..8f45a054 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -19,7 +19,7 @@ export default function NotificationsIcon(props: { className?: string }) { useEffect(() => { if (!privateUser) return - if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000) + if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 65 * 1000) requestBonuses({}).catch((error) => { console.log("couldn't get bonuses:", error.message) }) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 07f722d7..c33476aa 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -45,15 +45,16 @@ export function UserLink(props: { username: string showUsername?: boolean className?: string + justFirstName?: boolean }) { - const { name, username, showUsername, className } = props + const { name, username, showUsername, className, justFirstName } = props return ( <SiteLink href={`/${username}`} className={clsx('z-10 truncate', className)} > - {name} + {justFirstName ? name.split(' ')[0] : name} {showUsername && ` (@${username})`} </SiteLink> ) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 185225e9..2c6c2433 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -783,13 +783,12 @@ function NotificationItem(props: { <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> <div className={'flex pl-1 sm:pl-0'}> - {sourceType != 'bonus' && ( - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> - )} + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + justFirstName={true} + /> <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <span className={'flex-shrink-0'}> {sourceType && @@ -845,6 +844,7 @@ function NotificationItem(props: { name={sourceUserName || ''} username={sourceUserUsername || ''} className={'mr-0 flex-shrink-0'} + justFirstName={true} /> )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> From 2d1e76eae8cbe22cd2c9af14f6249df696a902f2 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 6 Jul 2022 10:39:19 -0700 Subject: [PATCH 37/39] When duplicating, add the original link in description --- web/components/copy-contract-button.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx index ad378878..fcb3a347 100644 --- a/web/components/copy-contract-button.tsx +++ b/web/components/copy-contract-button.tsx @@ -1,7 +1,9 @@ import { DuplicateIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Contract } from 'common/contract' +import { ENV_CONFIG } from 'common/envs/constants' import { getMappedValue } from 'common/pseudo-numeric' +import { contractPath } from 'web/lib/firebase/contracts' import { trackCallback } from 'web/lib/service/analytics' export function DuplicateContractButton(props: { @@ -34,7 +36,9 @@ function duplicateContractHref(contract: Contract) { const params = { q: contract.question, closeTime: contract.closeTime || 0, - description: contract.description, + description: + (contract.description ? `${contract.description}\n\n` : '') + + `(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`, outcomeType: contract.outcomeType, } as Record<string, any> From de20ee9fb9a4eddfd1049beebe5b2f881ee5322b Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 13:30:51 -0600 Subject: [PATCH 38/39] Show tip notifications (#623) * Show tip notifications * Optimizing notifications for mobile * Unused vars * Move income reason logic to income notif * Remove unnecessary icons * Unused vars --- common/antes.ts | 1 + common/notification.ts | 1 + functions/src/create-notification.ts | 18 +- functions/src/get-daily-bonuses.ts | 13 +- functions/src/index.ts | 1 + functions/src/on-create-txn.ts | 68 ++++ web/components/avatar.tsx | 2 +- web/components/feed/feed-comments.tsx | 2 +- web/hooks/use-notifications.ts | 14 +- web/pages/notifications.tsx | 498 +++++++++++++++----------- 10 files changed, 391 insertions(+), 227 deletions(-) create mode 100644 functions/src/on-create-txn.ts diff --git a/common/antes.ts b/common/antes.ts index d4cb2ff9..b3dd990b 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -15,6 +15,7 @@ import { ENV_CONFIG } from './envs/constants' export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id +export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id export function getCpmmInitialLiquidity( providerId: string, diff --git a/common/notification.ts b/common/notification.ts index 16444c48..da8a045a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -61,3 +61,4 @@ export type notification_reason_types = | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' | 'on_group_you_are_member_of' + | 'tip_received' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 45db1c4e..49bff5f7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -66,9 +66,7 @@ export const createNotification = async ( sourceUserAvatarUrl: sourceUser.avatarUrl, sourceText, sourceContractCreatorUsername: sourceContract?.creatorUsername, - // TODO: move away from sourceContractTitle to sourceTitle sourceContractTitle: sourceContract?.question, - // TODO: move away from sourceContractSlug to sourceSlug sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, @@ -278,13 +276,22 @@ export const createNotification = async ( } const notifyOtherGroupMembersOfComment = async ( + userToReasons: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasons)) + userToReasons[userId] = { + reason: 'on_group_you_are_member_of', + isSeeOnHref: sourceSlug, + } + } + const notifyTippedUserOfNewTip = async ( userToReasonTexts: user_to_reason_texts, userId: string ) => { if (shouldGetNotification(userId, userToReasonTexts)) userToReasonTexts[userId] = { - reason: 'on_group_you_are_member_of', - isSeeOnHref: sourceSlug, + reason: 'tip_received', } } @@ -304,6 +311,7 @@ export const createNotification = async ( // The following functions need sourceContract to be defined. if (!sourceContract) return userToReasonTexts + if ( sourceType === 'comment' || sourceType === 'answer' || @@ -338,6 +346,8 @@ export const createNotification = async ( userToReasonTexts, sourceContract.creatorId ) + } else if (sourceType === 'tip' && relatedUserId) { + await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId) } return userToReasonTexts } diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts index c5c1a1b3..017c32fc 100644 --- a/functions/src/get-daily-bonuses.ts +++ b/functions/src/get-daily-bonuses.ts @@ -1,11 +1,14 @@ import { APIError, newEndpoint } from './api' -import { log } from './utils' +import { isProd, log } from './utils' import * as admin from 'firebase-admin' import { PrivateUser } from '../../common/lib/user' import { uniq } from 'lodash' import { Bet } from '../../common/lib/bet' const firestore = admin.firestore() -import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' import { runTxn, TxnData } from './transact' import { createNotification } from './create-notification' import { User } from '../../common/lib/user' @@ -38,9 +41,9 @@ export const getdailybonuses = newEndpoint({}, async (req, auth) => { } } ) - // TODO: switch to prod id - // const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account - const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID const fromSnap = await firestore.doc(`users/${fromUserId}`).get() if (!fromSnap.exists) throw new APIError(400, 'From user not found.') const fromUser = fromSnap.data() as User diff --git a/functions/src/index.ts b/functions/src/index.ts index d9b7a255..8d1756f2 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -29,6 +29,7 @@ export * from './on-update-group' export * from './on-create-group' export * from './on-update-user' export * from './on-create-comment-on-group' +export * from './on-create-txn' // v2 export * from './health' diff --git a/functions/src/on-create-txn.ts b/functions/src/on-create-txn.ts new file mode 100644 index 00000000..d877ecac --- /dev/null +++ b/functions/src/on-create-txn.ts @@ -0,0 +1,68 @@ +import * as functions from 'firebase-functions' +import { Txn } from 'common/txn' +import { getContract, getUser, log } from './utils' +import { createNotification } from './create-notification' +import * as admin from 'firebase-admin' +import { Comment } from 'common/comment' + +const firestore = admin.firestore() + +export const onCreateTxn = functions.firestore + .document('txns/{txnId}') + .onCreate(async (change, context) => { + const txn = change.data() as Txn + const { eventId } = context + + if (txn.category === 'TIP') { + await handleTipTxn(txn, eventId) + } + }) + +async function handleTipTxn(txn: Txn, eventId: string) { + // get user sending and receiving tip + const [sender, receiver] = await Promise.all([ + getUser(txn.fromId), + getUser(txn.toId), + ]) + if (!sender || !receiver) { + log('Could not find corresponding users') + return + } + + if (!txn.data?.contractId || !txn.data?.commentId) { + log('No contractId or comment id in tip txn.data') + return + } + + const contract = await getContract(txn.data.contractId) + if (!contract) { + log('Could not find contract') + return + } + + const commentSnapshot = await firestore + .collection('contracts') + .doc(contract.id) + .collection('comments') + .doc(txn.data.commentId) + .get() + if (!commentSnapshot.exists) { + log('Could not find comment') + return + } + const comment = commentSnapshot.data() as Comment + + await createNotification( + txn.id, + 'tip', + 'created', + sender, + eventId, + txn.amount.toString(), + contract, + 'comment', + receiver.id, + txn.data?.commentId, + comment.text + ) +} diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index e6506c03..53257deb 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -53,7 +53,7 @@ export function EmptyAvatar(props: { size?: number; multi?: boolean }) { return ( <div - className={`flex h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`} + className={`flex flex-shrink-0 h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`} > <Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden /> </div> diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index ed02128e..c327d8af 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -224,7 +224,7 @@ export function FeedComment(props: { return ( <Row className={clsx( - 'flex space-x-1.5 transition-all duration-1000 sm:space-x-3', + 'flex space-x-1.5 sm:space-x-3', highlighted ? `-m-1 rounded bg-indigo-500/[0.2] p-2` : '' )} > diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 539573dd..98b0f2fd 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -39,17 +39,19 @@ export function groupNotifications(notifications: Notification[]) { ) Object.keys(notificationGroupsByDay).forEach((day) => { const notificationsGroupedByDay = notificationGroupsByDay[day] - const bonusNotifications = notificationsGroupedByDay.filter( - (notification) => notification.sourceType === 'bonus' + const incomeNotifications = notificationsGroupedByDay.filter( + (notification) => + notification.sourceType === 'bonus' || notification.sourceType === 'tip' ) const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( - (notification) => notification.sourceType !== 'bonus' + (notification) => + notification.sourceType !== 'bonus' && notification.sourceType !== 'tip' ) - if (bonusNotifications.length > 0) { + if (incomeNotifications.length > 0) { notificationGroups = notificationGroups.concat({ - notifications: bonusNotifications, + notifications: incomeNotifications, groupedById: 'income' + day, - isSeen: bonusNotifications[0].isSeen, + isSeen: incomeNotifications[0].isSeen, timePeriod: day, type: 'income', }) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 2c6c2433..45ca234a 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,7 +1,7 @@ import { Tabs } from 'web/components/layout/tabs' import { useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' -import { Notification } from 'common/notification' +import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -34,10 +34,10 @@ import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' -import { groupBy } from 'lodash' +import { groupBy, sum, uniq } from 'lodash' export const NOTIFICATIONS_PER_PAGE = 30 -export const HIGHLIGHT_DURATION = 30 * 1000 +const MULTIPLE_USERS_KEY = 'multipleUsers' export default function Notifications() { const user = useUser() @@ -187,16 +187,12 @@ function IncomeNotificationGroupItem(props: { const { notificationGroup, className } = props const { notifications } = notificationGroup const numSummaryLines = 3 - const [expanded, setExpanded] = useState(false) - const [highlighted, setHighlighted] = useState(false) + const [highlighted, setHighlighted] = useState( + notifications.some((n) => !n.isSeen) + ) + useEffect(() => { - if (notifications.some((n) => !n.isSeen)) { - setHighlighted(true) - setTimeout(() => { - setHighlighted(false) - }, HIGHLIGHT_DURATION) - } setNotificationsAsSeen(notifications) }, [notifications]) @@ -204,51 +200,62 @@ function IncomeNotificationGroupItem(props: { if (expanded) setHighlighted(false) }, [expanded]) - const totalIncome = notifications.reduce( - (acc, notification) => - acc + - (notification.sourceType && - notification.sourceText && - notification.sourceType === 'bonus' - ? parseInt(notification.sourceText) - : 0), - 0 + const totalIncome = sum( + notifications.map((notification) => + notification.sourceText ? parseInt(notification.sourceText) : 0 + ) ) - // loop through the contracts and combine the notification items into one - function combineNotificationsByAddingSourceTextsAndReturningTheRest( + // Loop through the contracts and combine the notification items into one + function combineNotificationsByAddingNumericSourceTexts( notifications: Notification[] ) { const newNotifications = [] - const groupedNotificationsByContractId = groupBy( + const groupedNotificationsBySourceType = groupBy( notifications, - (notification) => { - return notification.sourceContractId - } + (n) => n.sourceType ) - for (const contractId in groupedNotificationsByContractId) { - const notificationsForContractId = - groupedNotificationsByContractId[contractId] - let sum = 0 - notificationsForContractId.forEach( - (notification) => - notification.sourceText && - (sum = parseInt(notification.sourceText) + sum) + for (const sourceType in groupedNotificationsBySourceType) { + const groupedNotificationsByContractId = groupBy( + groupedNotificationsBySourceType[sourceType], + (notification) => { + return notification.sourceContractId + } ) + for (const contractId in groupedNotificationsByContractId) { + const notificationsForContractId = + groupedNotificationsByContractId[contractId] + if (notificationsForContractId.length === 1) { + newNotifications.push(notificationsForContractId[0]) + continue + } + let sum = 0 + notificationsForContractId.forEach( + (notification) => + notification.sourceText && + (sum = parseInt(notification.sourceText) + sum) + ) + const uniqueUsers = uniq( + notificationsForContractId.map((notification) => { + return notification.sourceUserUsername + }) + ) - const newNotification = - notificationsForContractId.length === 1 - ? notificationsForContractId[0] - : { - ...notificationsForContractId[0], - sourceText: sum.toString(), - } - newNotifications.push(newNotification) + const newNotification = { + ...notificationsForContractId[0], + sourceText: sum.toString(), + sourceUserUsername: + uniqueUsers.length > 1 + ? MULTIPLE_USERS_KEY + : notificationsForContractId[0].sourceType, + } + newNotifications.push(newNotification) + } } return newNotifications } const combinedNotifs = - combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications) + combineNotificationsByAddingNumericSourceTexts(notifications) return ( <div @@ -286,53 +293,23 @@ function IncomeNotificationGroupItem(props: { <div> <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}> {' '} - <div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}> + <div + className={clsx( + 'mt-1 ml-1 gap-1 whitespace-pre-line', + !expanded ? 'line-clamp-4' : '' + )} + > {!expanded ? ( <> {combinedNotifs .slice(0, numSummaryLines) - .map((notification) => { - return ( - <Row - className={ - 'items-center text-sm text-gray-500 sm:justify-start' - } - key={notification.id} - > - <div - className={ - 'line-clamp-1 flex-1 overflow-hidden sm:flex' - } - > - <div className={'flex pl-1 sm:pl-0'}> - <div - className={ - 'inline-flex overflow-hidden text-ellipsis pl-1' - } - > - <div className={'mr-1 text-black'}> - <NotificationTextLabel - contract={null} - defaultText={notification.sourceText ?? ''} - className={'line-clamp-1'} - notification={notification} - justSummary={true} - /> - </div> - <span className={'flex-shrink-0'}> - {getReasonForShowingNotification( - notification, - true - )} - {` on`} - <NotificationLink notification={notification} /> - </span> - </div> - </div> - </div> - </Row> - ) - })} + .map((notification) => ( + <IncomeNotificationItem + notification={notification} + justSummary={true} + key={notification.id} + /> + ))} <div className={'text-sm text-gray-500 hover:underline '}> {combinedNotifs.length - numSummaryLines > 0 ? 'And ' + @@ -344,7 +321,7 @@ function IncomeNotificationGroupItem(props: { ) : ( <> {combinedNotifs.map((notification) => ( - <NotificationItem + <IncomeNotificationItem notification={notification} key={notification.id} justSummary={false} @@ -361,28 +338,130 @@ function IncomeNotificationGroupItem(props: { ) } +function IncomeNotificationItem(props: { + notification: Notification + justSummary?: boolean +}) { + const { notification, justSummary } = props + const { + sourceType, + sourceUserName, + reason, + sourceUserUsername, + createdTime, + } = notification + const [highlighted] = useState(!notification.isSeen) + + useEffect(() => { + setNotificationsAsSeen([notification]) + }, [notification]) + + function getReasonForShowingIncomeNotification(simple: boolean) { + const { sourceText } = notification + let reasonText = '' + if (sourceType === 'bonus' && sourceText) { + reasonText = !simple + ? `bonus for ${ + parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT + } unique bettors` + : ' bonus for unique bettors on' + } else if (sourceType === 'tip') { + reasonText = !simple ? `tipped you` : `in tips on` + } + return <span className={'flex-shrink-0'}>{reasonText}</span> + } + + if (justSummary) { + return ( + <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> + <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> + <div className={'flex pl-1 sm:pl-0'}> + <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> + <div className={'mr-1 text-black'}> + <NotificationTextLabel + contract={null} + defaultText={notification.sourceText ?? ''} + className={'line-clamp-1'} + notification={notification} + justSummary={true} + /> + </div> + <span className={'flex truncate'}> + {getReasonForShowingIncomeNotification(true)} + <NotificationLink notification={notification} /> + </span> + </div> + </div> + </div> + </Row> + ) + } + + return ( + <div + className={clsx( + 'bg-white px-2 pt-6 text-sm sm:px-4', + highlighted && 'bg-indigo-200 hover:bg-indigo-100' + )} + > + <a href={getSourceUrl(notification)}> + <Row className={'items-center text-gray-500 sm:justify-start'}> + <div className={'flex max-w-xl shrink '}> + {sourceType && reason && ( + <div className={'inline'}> + <span className={'mr-1'}> + <NotificationTextLabel + contract={null} + defaultText={notification.sourceText ?? ''} + notification={notification} + /> + </span> + + {sourceType != 'bonus' && + (sourceUserUsername === MULTIPLE_USERS_KEY ? ( + <span className={'mr-1 truncate'}>Multiple users</span> + ) : ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-1 flex-shrink-0'} + justFirstName={true} + /> + ))} + </div> + )} + {getReasonForShowingIncomeNotification(false)} + <span className={'ml-1 flex hidden sm:inline-block'}> + on + <NotificationLink notification={notification} /> + </span> + <RelativeTimestamp time={createdTime} /> + </div> + </Row> + <span className={'flex truncate text-gray-500 sm:hidden'}> + on + <NotificationLink notification={notification} /> + </span> + <div className={'mt-4 border-b border-gray-300'} /> + </a> + </div> + ) +} + function NotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string }) { const { notificationGroup, className } = props const { notifications } = notificationGroup - const { - sourceContractTitle, - sourceContractSlug, - sourceContractCreatorUsername, - } = notifications[0] + const { sourceContractTitle } = notifications[0] const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) - const [highlighted, setHighlighted] = useState(false) + const [highlighted, setHighlighted] = useState( + notifications.some((n) => !n.isSeen) + ) useEffect(() => { - if (notifications.some((n) => !n.isSeen)) { - setHighlighted(true) - setTimeout(() => { - setHighlighted(false) - }, HIGHLIGHT_DURATION) - } setNotificationsAsSeen(notifications) }, [notifications]) @@ -408,27 +487,18 @@ function NotificationGroupItem(props: { )} <Row className={'items-center text-gray-500 sm:justify-start'}> <EmptyAvatar multi /> - <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> + <div className={'flex truncate pl-2'}> <div onClick={() => setExpanded(!expanded)} - className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} + className={' flex cursor-pointer truncate pl-1 sm:pl-0'} > {sourceContractTitle ? ( - <span> - {'Activity on '} - <a - href={ - sourceContractCreatorUsername - ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : '' - } - className={ - 'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' - } - > - {sourceContractTitle} - </a> - </span> + <> + <span className={'flex-shrink-0'}>{'Activity on '}</span> + <span className={'truncate'}> + <NotificationLink notification={notifications[0]} /> + </span> + </> ) : ( 'Other activity' )} @@ -439,7 +509,13 @@ function NotificationGroupItem(props: { <div> <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}> {' '} - <div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}> + <div + className={clsx( + 'mt-1 ml-1 gap-1 whitespace-pre-line', + !expanded ? 'line-clamp-4' : '' + )} + > + {' '} {!expanded ? ( <> {notifications.slice(0, numSummaryLines).map((notification) => { @@ -466,6 +542,7 @@ function NotificationGroupItem(props: { notification={notification} key={notification.id} justSummary={false} + hideTitle={true} /> ))} </> @@ -695,7 +772,7 @@ function NotificationLink(props: { notification: Notification }) { : '' } className={ - 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' + 'ml-1 inline max-w-xs truncate font-bold text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2 sm:max-w-sm' } > {sourceContractTitle || sourceTitle} @@ -703,11 +780,54 @@ function NotificationLink(props: { notification: Notification }) { ) } +function getSourceUrl(notification: Notification) { + const { + sourceType, + sourceId, + sourceUserUsername, + sourceContractCreatorUsername, + sourceContractSlug, + sourceSlug, + } = notification + if (sourceType === 'follow') return `/${sourceUserUsername}` + if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` + if ( + sourceContractCreatorUsername && + sourceContractSlug && + sourceType === 'user' + ) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}` + if (sourceType === 'tip') + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` + if (sourceContractCreatorUsername && sourceContractSlug) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( + sourceId ?? '', + sourceType + )}` +} + +function getSourceIdForLinkComponent( + sourceId: string, + sourceType?: notification_source_types +) { + switch (sourceType) { + case 'answer': + return `answer-${sourceId}` + case 'comment': + return sourceId + case 'contract': + return '' + default: + return sourceId + } +} + function NotificationItem(props: { notification: Notification justSummary?: boolean + hideTitle?: boolean }) { - const { notification, justSummary } = props + const { notification, justSummary, hideTitle } = props const { sourceType, sourceId, @@ -721,7 +841,6 @@ function NotificationItem(props: { sourceText, sourceContractCreatorUsername, sourceContractSlug, - sourceSlug, } = notification const [defaultNotificationText, setDefaultNotificationText] = @@ -736,48 +855,12 @@ function NotificationItem(props: { } }, [reasonText, sourceText]) - const [highlighted, setHighlighted] = useState(false) - useEffect(() => { - if (!notification.isSeen) { - setHighlighted(true) - setTimeout(() => { - setHighlighted(false) - }, HIGHLIGHT_DURATION) - } - }, [notification.isSeen]) + const [highlighted] = useState(!notification.isSeen) useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) - function getSourceUrl() { - if (sourceType === 'follow') return `/${sourceUserUsername}` - if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` - if ( - sourceContractCreatorUsername && - sourceContractSlug && - sourceType === 'user' - ) - return `/${sourceContractCreatorUsername}/${sourceContractSlug}` - if (sourceContractCreatorUsername && sourceContractSlug) - return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( - sourceId ?? '' - )}` - } - - function getSourceIdForLinkComponent(sourceId: string) { - switch (sourceType) { - case 'answer': - return `answer-${sourceId}` - case 'comment': - return sourceId - case 'contract': - return '' - default: - return sourceId - } - } - if (justSummary) { return ( <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> @@ -793,10 +876,7 @@ function NotificationItem(props: { <span className={'flex-shrink-0'}> {sourceType && reason && - getReasonForShowingNotification(notification, true).replace( - ' on', - '' - )} + getReasonForShowingNotification(notification, true, true)} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel @@ -821,25 +901,21 @@ function NotificationItem(props: { highlighted && 'bg-indigo-200 hover:bg-indigo-100' )} > - <a href={getSourceUrl()}> + <a href={getSourceUrl(notification)}> <Row className={'items-center text-gray-500 sm:justify-start'}> - {sourceType != 'bonus' ? ( - <Avatar - avatarUrl={sourceUserAvatarUrl} - size={'sm'} - className={'mr-2'} - username={sourceUserName} - /> - ) : ( - <TrendingUpIcon className={'text-primary h-7 w-7'} /> - )} + <Avatar + avatarUrl={sourceUserAvatarUrl} + size={'sm'} + className={'mr-2'} + username={sourceUserName} + /> <div className={'flex-1 overflow-hidden sm:flex'}> <div className={ 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' } > - {sourceType != 'bonus' && sourceUpdateType != 'closed' && ( + {sourceUpdateType != 'closed' && ( <UserLink name={sourceUserName || ''} username={sourceUserUsername || ''} @@ -847,26 +923,30 @@ function NotificationItem(props: { justFirstName={true} /> )} - <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> - {sourceType && reason && ( - <div className={'inline truncate'}> - {getReasonForShowingNotification(notification, false)} + {sourceType && reason && ( + <div className={'inline flex truncate'}> + <span className={'ml-1 flex-shrink-0'}> + {getReasonForShowingNotification(notification, false, true)} + </span> + {!hideTitle && ( <NotificationLink notification={notification} /> - </div> - )} - </div> + )} + </div> + )} + {sourceId && + sourceContractSlug && + sourceContractCreatorUsername ? ( + <CopyLinkDateTimeComponent + prefix={sourceContractCreatorUsername} + slug={sourceContractSlug} + createdTime={createdTime} + elementId={getSourceIdForLinkComponent(sourceId)} + className={'-mx-1 inline-flex sm:inline-block'} + /> + ) : ( + <RelativeTimestamp time={createdTime} /> + )} </div> - {sourceId && sourceContractSlug && sourceContractCreatorUsername ? ( - <CopyLinkDateTimeComponent - prefix={sourceContractCreatorUsername} - slug={sourceContractSlug} - createdTime={createdTime} - elementId={getSourceIdForLinkComponent(sourceId)} - className={'-mx-1 inline-flex sm:inline-block'} - /> - ) : ( - <RelativeTimestamp time={createdTime} /> - )} </div> </Row> <div className={'mt-1 ml-1 md:text-base'}> @@ -938,9 +1018,11 @@ function NotificationTextLabel(props: { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> ) - } else if (sourceType === 'bonus' && sourceText) { + } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) { return ( - <span className="text-primary">{formatMoney(parseInt(sourceText))}</span> + <span className="text-primary"> + {'+' + formatMoney(parseInt(sourceText))} + </span> ) } // return default text @@ -953,19 +1035,19 @@ function NotificationTextLabel(props: { function getReasonForShowingNotification( notification: Notification, - simple?: boolean + simple?: boolean, + replaceOn?: boolean ) { - const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } = - notification + const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') - reasonText = !simple ? 'replied to your answer on' : 'replied' + reasonText = !simple ? 'replied to you on' : 'replied' else if (reason === 'tagged_user') - reasonText = !simple ? 'tagged you in a comment on' : 'tagged you' + reasonText = !simple ? 'tagged you on' : 'tagged you' else if (reason === 'reply_to_users_comment') - reasonText = !simple ? 'replied to your comment on' : 'replied' + reasonText = !simple ? 'replied to you on' : 'replied' else if (reason === 'on_users_contract') reasonText = !simple ? `commented on your question` : 'commented' else if (reason === 'on_contract_with_users_comment') @@ -973,7 +1055,7 @@ function getReasonForShowingNotification( else if (reason === 'on_contract_with_users_answer') reasonText = `commented on` else if (reason === 'on_contract_with_users_shares_in') - reasonText = `commented` + reasonText = `commented on` else reasonText = `commented on` break case 'contract': @@ -1008,17 +1090,13 @@ function getReasonForShowingNotification( else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break - case 'bonus': - if (reason === 'unique_bettors_on_your_contract' && sourceText) - reasonText = !simple - ? `You had ${ - parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT - } unique bettors on` - : ' for unique bettors' - else reasonText = 'You earned your daily manna' - break default: reasonText = '' } - return reasonText + + return ( + <span className={'flex-shrink-0'}> + {replaceOn ? reasonText.replace(' on', '') : reasonText} + </span> + ) } From 54b4f97a84a61b9f43e8f658a69f3cbb97e422c9 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Wed, 6 Jul 2022 13:45:31 -0600 Subject: [PATCH 39/39] Move timestamp to same line --- web/pages/notifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 45ca234a..54dbdd09 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -275,7 +275,7 @@ function IncomeNotificationGroupItem(props: { )} <Row className={'items-center text-gray-500 sm:justify-start'}> <TrendingUpIcon className={'text-primary h-7 w-7'} /> - <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> + <div className={'flex truncate'}> <div onClick={() => setExpanded(!expanded)} className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} @@ -286,8 +286,8 @@ function IncomeNotificationGroupItem(props: { {'+' + formatMoney(totalIncome)} </span> </span> + <RelativeTimestamp time={notifications[0].createdTime} /> </div> - <RelativeTimestamp time={notifications[0].createdTime} /> </div> </Row> <div>