Merge branch 'main' into automated-market-resolution

This commit is contained in:
Milli 2022-06-03 19:06:52 +02:00
commit f7bc69d76e
14 changed files with 193 additions and 46 deletions

View File

@ -24,6 +24,11 @@ service cloud.firestore {
allow write: if request.auth.uid == userId;
}
match /users/{userId}/follows/{followUserId} {
allow read;
allow write: if request.auth.uid == userId;
}
match /private-users/{userId} {
allow read: if resource.data.id == request.auth.uid || isAdmin();
allow update: if (resource.data.id == request.auth.uid || isAdmin())

View File

@ -5,7 +5,13 @@ import Stripe from 'stripe'
import { getPrivateUser, getUser, isProd, payUser } from './utils'
import { sendThankYouEmail } from './emails'
export type StripeSession = Stripe.Event.Data.Object & { id: any, metadata: any}
export type StripeSession = Stripe.Event.Data.Object & {
id: string
metadata: {
userId: string
manticDollarQuantity: string
}
}
export type StripeTransaction = {
userId: string

View File

@ -101,7 +101,7 @@ export function BetsList(props: { user: User; hideBetsBefore?: number }) {
return <LoadingIndicator />
}
if (bets.length === 0) return <NoBets />
if (bets.length === 0) return <NoBets user={user} />
// Decending creation time.
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
const contractBets = groupBy(bets, 'contractId')
@ -219,7 +219,7 @@ export function BetsList(props: { user: User; hideBetsBefore?: number }) {
<Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? (
<NoBets />
<NoBets user={user} />
) : (
displayedContracts.map((contract) => (
<ContractBets
@ -236,13 +236,20 @@ export function BetsList(props: { user: User; hideBetsBefore?: number }) {
)
}
const NoBets = () => {
const NoBets = ({ user }: { user: User }) => {
const me = useUser()
return (
<div className="mx-4 text-gray-500">
You have not made any bets yet.{' '}
<SiteLink href="/home" className="underline">
Find a prediction market!
</SiteLink>
{user.id === me?.id ? (
<>
You have not made any bets yet.{' '}
<SiteLink href="/home" className="underline">
Find a prediction market!
</SiteLink>
</>
) : (
<>{user.name} has not made any public bets yet.</>
)}
</div>
)
}

View File

@ -8,7 +8,7 @@ export function ConfirmationButton(props: {
id: string
openModalBtn: {
label: string
icon?: any
icon?: JSX.Element
className?: string
}
cancelBtn?: {

View File

@ -20,10 +20,10 @@ import { ContractsGrid } from './contract/contracts-list'
import { Row } from './layout/row'
import { useEffect, useRef, useState } from 'react'
import { Spacer } from './layout/spacer'
import { useRouter } from 'next/router'
import { ENV } from 'common/envs/constants'
import { CategorySelector } from './feed/category-selector'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -60,6 +60,8 @@ export function ContractSearch(props: {
const { querySortOptions, additionalFilter, showCategorySelector } = props
const user = useUser()
const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions)
const sort = sortIndexes
@ -73,15 +75,32 @@ export function ContractSearch(props: {
)
const [category, setCategory] = useState<string>('all')
const showFollows = category === 'following'
const followsKey =
showFollows && follows?.length ? `${follows.join(',')}` : ''
if (!sort) return <></>
const indexName = `${indexPrefix}contracts-${sort}`
return (
<InstantSearch
searchClient={searchClient}
indexName={`${indexPrefix}contracts-${sort}`}
indexName={indexName}
key={`search-${
additionalFilter?.tag ?? additionalFilter?.creatorId ?? ''
}`}
}${followsKey}`}
initialUiState={
showFollows
? {
[indexName]: {
refinementList: {
creatorId: ['', ...(follows ?? [])],
},
},
}
: undefined
}
>
<Row className="gap-1 sm:gap-2">
<SearchBox
@ -120,11 +139,20 @@ export function ContractSearch(props: {
/>
)}
<ContractSearchInner
querySortOptions={querySortOptions}
filter={filter}
additionalFilter={{ category, ...additionalFilter }}
/>
<Spacer h={4} />
{showFollows && (follows ?? []).length === 0 ? (
<>You're not following anyone yet.</>
) : (
<ContractSearchInner
querySortOptions={querySortOptions}
filter={filter}
additionalFilter={{
category: category === 'following' ? 'all' : category,
...additionalFilter,
}}
/>
)}
</InstantSearch>
)
}

View File

@ -27,6 +27,15 @@ export function CategorySelector(props: {
}}
/>
<CategoryButton
key="following"
category="Following"
isFollowed={category === 'following'}
toggle={() => {
setCategory('following')
}}
/>
{CATEGORY_LIST.map((cat) => (
<CategoryButton
key={cat}

View File

@ -1,8 +1,8 @@
import clsx from 'clsx'
import { Fold } from 'common/fold'
import { useFollowedFoldIds } from 'web/hooks/use-fold'
import { useUser } from 'web/hooks/use-user'
import { followFold, unfollowFold } from 'web/lib/firebase/folds'
import { FollowButton } from '../follow-button'
export function FollowFoldButton(props: { fold: Fold; className?: string }) {
const { fold, className } = props
@ -10,7 +10,7 @@ export function FollowFoldButton(props: { fold: Fold; className?: string }) {
const user = useUser()
const followedFoldIds = useFollowedFoldIds(user)
const following = followedFoldIds
const isFollowing = followedFoldIds
? followedFoldIds.includes(fold.id)
: undefined
@ -22,27 +22,12 @@ export function FollowFoldButton(props: { fold: Fold; className?: string }) {
if (user) unfollowFold(fold, user)
}
if (!user || following === undefined)
return (
<button className={clsx('btn btn-sm invisible', className)}>
Follow
</button>
)
if (following) {
return (
<button
className={clsx('btn btn-outline btn-sm', className)}
onClick={onUnfollow}
>
Following
</button>
)
}
return (
<button className={clsx('btn btn-sm', className)} onClick={onFollow}>
Follow
</button>
<FollowButton
isFollowing={isFollowing}
onFollow={onFollow}
onUnfollow={onUnfollow}
className={className}
/>
)
}

View File

@ -0,0 +1,37 @@
import clsx from 'clsx'
import { useUser } from 'web/hooks/use-user'
export function FollowButton(props: {
isFollowing: boolean | undefined
onFollow: () => void
onUnfollow: () => void
className?: string
}) {
const { isFollowing, onFollow, onUnfollow, className } = props
const user = useUser()
if (!user || isFollowing === undefined)
return (
<button className={clsx('btn btn-sm invisible', className)}>
Follow
</button>
)
if (isFollowing) {
return (
<button
className={clsx('btn btn-outline btn-sm', className)}
onClick={onUnfollow}
>
Following
</button>
)
}
return (
<button className={clsx('btn btn-sm', className)} onClick={onFollow}>
Follow
</button>
)
}

View File

@ -3,7 +3,7 @@ import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
export function MenuButton(props: {
buttonContent: any
buttonContent: JSX.Element
menuItems: { name: string; href: string; onClick?: () => void }[]
className?: string
}) {

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import { User } from 'web/lib/firebase/users'
import { follow, unfollow, User } from 'web/lib/firebase/users'
import { CreatorContractsList } from './contract/contracts-list'
import { SEO } from './SEO'
import { Page } from './page'
@ -89,6 +89,18 @@ export function UserPage(props: {
})
}, [usersComments])
const yourFollows = useFollows(currentUser?.id)
const isFollowing = yourFollows?.includes(user.id)
const onFollow = () => {
if (!currentUser) return
follow(currentUser.id, user.id)
}
const onUnfollow = () => {
if (!currentUser) return
unfollow(currentUser.id, user.id)
}
return (
<Page>
<SEO
@ -116,6 +128,13 @@ export function UserPage(props: {
{/* Top right buttons (e.g. edit, follow) */}
<div className="absolute right-0 top-0 mt-4 mr-4">
{!isCurrentUser && (
<FollowButton
isFollowing={isFollowing}
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
)}
{isCurrentUser && (
<SiteLink className="btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '}
@ -281,6 +300,8 @@ export function defaultBannerUrl(userId: string) {
}
import { ExclamationIcon } from '@heroicons/react/solid'
import { FollowButton } from './follow-button'
import { useFollows } from 'web/hooks/use-follows'
function AlertBox(props: { title: string; text: string }) {
const { title, text } = props

12
web/hooks/use-follows.ts Normal file
View File

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
import { listenForFollows } from 'web/lib/firebase/users'
export const useFollows = (userId: string | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
useEffect(() => {
if (userId) return listenForFollows(userId, setFollowIds)
}, [userId])
return followIds
}

View File

@ -1,4 +1,5 @@
import { auth } from './users'
import { FIREBASE_CONFIG } from 'common/envs/constants'
export class APIError extends Error {
code: number
@ -32,10 +33,20 @@ export async function call(url: string, method: string, params: any) {
})
}
// Our users access the API through the Vercel proxy routes at /api/v0/blah,
// but right now at least until we get performance under control let's have the
// app just hit the cloud functions directly -- there's no difference and it's
// one less hop
function getFunctionUrl(name: string) {
const { projectId, region } = FIREBASE_CONFIG
return `https://${region}-${projectId}.cloudfunctions.net/${name}`
}
export function createContract(params: any) {
return call('/api/v0/market', 'POST', params)
return call(getFunctionUrl('createContract'), 'POST', params)
}
export function placeBet(params: any) {
return call('/api/v0/bets', 'POST', params)
return call(getFunctionUrl('placeBet'), 'POST', params)
}

View File

@ -10,6 +10,7 @@ import {
getDocs,
orderBy,
updateDoc,
deleteDoc,
} from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
@ -239,3 +240,26 @@ export async function getCategoryFeeds(userId: string) {
const feeds = feedData.map((data) => data?.feed ?? [])
return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, feed][])
}
export async function follow(userId: string, followedUserId: string) {
const followDoc = doc(db, 'users', userId, 'follows', followedUserId)
await setDoc(followDoc, {
userId: followedUserId,
timestamp: Date.now(),
})
}
export async function unfollow(userId: string, unfollowedUserId: string) {
const followDoc = doc(db, 'users', userId, 'follows', unfollowedUserId)
await deleteDoc(followDoc)
}
export function listenForFollows(
userId: string,
setFollowIds: (followIds: string[]) => void
) {
const follows = collection(db, 'users', userId, 'follows')
return listenForValues<{ userId: string }>(follows, (docs) =>
setFollowIds(docs.map(({ userId }) => userId))
)
}

View File

@ -110,11 +110,13 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) {
}
}
type Bid = { yesBid: number; noBid: number }
function NewBidTable(props: {
steps: number
bids: Array<{ yesBid: number; noBid: number }>
bids: Array<Bid>
setSteps: (steps: number) => void
setBids: (bids: any[]) => void
setBids: (bids: Array<Bid>) => void
}) {
const { steps, bids, setSteps, setBids } = props
// Prepare for new bids