From b9931e65dad9fe8d0d5de921e785a858a8a286b8 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 1 Jul 2022 16:37:30 -0600 Subject: [PATCH] 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={[ {