Follow other users. Filter markets by followed (#387)

* Add follow button to user page

* Update follows in the database using follow button.

* Add toggle for followed market creators to home

* Hide follow toggle from user's markets page

* Check that sold bet is by auth'd user

* Change follow toggle to category pill

* Remove unused imports

* Remove console.logs
This commit is contained in:
James Grugett 2022-06-02 23:52:14 -05:00 committed by GitHub
parent 749f7aad40
commit c1bda8a775
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 153 additions and 32 deletions

View File

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

View File

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

View File

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

@ -1,5 +1,5 @@
import clsx from 'clsx' 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 { CreatorContractsList } from './contract/contracts-list'
import { SEO } from './SEO' import { SEO } from './SEO'
import { Page } from './page' import { Page } from './page'
@ -89,6 +89,18 @@ export function UserPage(props: {
}) })
}, [usersComments]) }, [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 ( return (
<Page> <Page>
<SEO <SEO
@ -116,6 +128,13 @@ export function UserPage(props: {
{/* Top right buttons (e.g. edit, follow) */} {/* Top right buttons (e.g. edit, follow) */}
<div className="absolute right-0 top-0 mt-4 mr-4"> <div className="absolute right-0 top-0 mt-4 mr-4">
{!isCurrentUser && (
<FollowButton
isFollowing={isFollowing}
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
)}
{isCurrentUser && ( {isCurrentUser && (
<SiteLink className="btn" href="/profile"> <SiteLink className="btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '} <PencilIcon className="h-5 w-5" />{' '}
@ -281,6 +300,8 @@ export function defaultBannerUrl(userId: string) {
} }
import { ExclamationIcon } from '@heroicons/react/solid' 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 }) { function AlertBox(props: { title: string; text: string }) {
const { title, text } = props 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

@ -10,6 +10,7 @@ import {
getDocs, getDocs,
orderBy, orderBy,
updateDoc, updateDoc,
deleteDoc,
} from 'firebase/firestore' } from 'firebase/firestore'
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' 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 ?? []) const feeds = feedData.map((data) => data?.feed ?? [])
return Object.fromEntries(zip(CATEGORY_LIST, feeds) as [string, 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))
)
}