Allow adding anyone's contract to a group

This commit is contained in:
Ian Philips 2022-07-01 16:37:30 -06:00
parent cb68530e2a
commit b9931e65da
15 changed files with 150 additions and 107 deletions

View File

@ -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);
// 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} {

View File

@ -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: {
<Row className="gap-1 sm:gap-2">
<SearchBox
className="flex-1"
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
classNames={{
form: 'before:top-6',
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
@ -153,6 +163,7 @@ export function ContractSearch(props: {
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
<SortBy
items={sortIndexes}
classNames={{
@ -160,6 +171,7 @@ export function ContractSearch(props: {
}}
onBlur={trackCallback('select search sort')}
/>
)}
<Configure
facetFilters={filters}
numericFilters={numericFilters}
@ -187,6 +199,9 @@ export function ContractSearch(props: {
<ContractSearchInner
querySortOptions={querySortOptions}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
excludeContractIds={additionalFilter?.excludeContractIds}
/>
)}
</InstantSearch>
@ -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 (
<ContractsGrid
contracts={contracts}
@ -256,6 +283,8 @@ export function ContractSearchInner(props: {
hasMore={!isLastPage}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
/>
)
}

View File

@ -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 (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="items-center gap-2">
@ -153,14 +176,15 @@ export function ContractDetails(props: {
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row>
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/}
{groups && groups.length > 0 && (
{groupToDisplay ? (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
<SiteLink href={`${groupPath(groups[0].slug)}`}>
<SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groups[0].name}</span>
<span>{groupToDisplay.name}</span>
</SiteLink>
</Row>
) : (
<div />
)}
{(!!closeTime || !!resolvedDate) && (

View File

@ -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)

View File

@ -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

View File

@ -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 (
<Transition.Root show={open} as={Fragment}>
@ -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"
>
<div className="inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle">
<div
className={clsx(
'inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle',
className
)}
>
{children}
</div>
</Transition.Child>

View File

@ -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 (
<div>
<>
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
<nav className="-mb-px mb-4 flex space-x-8" aria-label="Tabs">
{tabs.map((tab, i) => (
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
<a
@ -42,7 +42,7 @@ export function Tabs(props: {
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium',
className
labelClassName
)}
aria-current={activeIndex === i ? 'page' : undefined}
>
@ -56,7 +56,7 @@ export function Tabs(props: {
</nav>
</div>
<div className="mt-4">{activeTab?.content}</div>
</div>
{activeTab?.content}
</>
)
}

View File

@ -254,7 +254,7 @@ function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
<div className="mt-1 space-y-0.5">
{memberItems.map((item) => (
<a
key={item.name}
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"
>

View File

@ -258,7 +258,7 @@ export function UserPage(props: {
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
<Tabs
className={'pb-2 pt-1 '}
labelClassName={'pb-2 pt-1 '}
defaultIndex={
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
}

View File

@ -74,9 +74,7 @@ export function useMembers(group: Group) {
}
export async function listMembers(group: Group) {
return (await Promise.all(group.memberIds.map(getUser))).filter(
(user) => user
)
return await Promise.all(group.memberIds.map(getUser))
}
export const useGroupsWithContract = (contractId: string | undefined) => {

View File

@ -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<Group> {
export async function addUserToGroup(
group: Group,
userId: string
): Promise<Group> {
const { memberIds } = group
if (memberIds.includes(userId)) {
return group
@ -125,3 +128,14 @@ export async function leaveGroup(group: Group, userId: string): Promise<Group> {
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
})
}

View File

@ -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))

View File

@ -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<Contract[] | undefined>(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 (
<>
<Modal open={open} setOpen={setOpen}>
<Col className={'max-h-[60vh] w-full gap-4 rounded-md bg-white p-8'}>
<Modal open={open} setOpen={setOpen} className={'sm:p-0'}>
<Col
className={
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white p-8'
}
>
<div className={'text-lg text-indigo-700'}>
Add a question to your group
</div>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your questions"
className="input input-bordered mb-4 w-full"
/>
<div className={'overflow-y-scroll'}>
{contracts ? (
<ContractsGrid
contracts={matches}
loadMore={() => {}}
hasMore={false}
onContractClick={(contract) => {
addContractToGroup(contract)
}}
<div className={'overflow-y-scroll p-1'}>
<ContractSearch
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
showCategorySelector={false}
overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'}
showPlaceHolder={true}
hideQuickBet={true}
additionalFilter={{ excludeContractIds: group.contractIds }}
/>
) : (
<LoadingIndicator />
)}
</div>
</Col>
</Modal>
<Row className={'items-center justify-center'}>
<button
className={
'btn btn-sm btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case'
'btn btn-md btn-outline cursor-pointer gap-2 whitespace-nowrap text-sm normal-case'
}
onClick={() => setOpen(true)}
>
<PlusIcon className="mr-1 h-5 w-5" />
Add old questions to this group
Add an old question
</button>
</Row>
</>
@ -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],
}),
{
toast.promise(addUserToGroup(group, user.id), {
loading: 'Joining group...',
success: 'Joined group!',
error: "Couldn't join group",
}
)
})
}
}
return (

View File

@ -64,7 +64,7 @@ export default function LinkPage() {
<Col className="w-full px-8">
<Title text="Manalinks" />
<Tabs
className={'pb-2 pt-1 '}
labelClassName={'pb-2 pt-1 '}
defaultIndex={0}
tabs={[
{

View File

@ -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={[
{