Following and follower list (#456)

* Create following button that opens follow list in modal.

* Move react query deps to web package.json

* UseFollowers hook

* Following and followers button, dialog with tabs.

* Fix line endings

* Remove carriage return from default vscode eol

* Add placeholder message if no users followed / no followers

* Tweak spacing
This commit is contained in:
James Grugett 2022-06-07 22:24:18 -05:00 committed by GitHub
parent 18044e7302
commit 879ab272e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 247 additions and 9 deletions

View File

@ -1,7 +1,7 @@
{
"javascript.preferences.importModuleSpecifier": "shortest",
"typescript.preferences.importModuleSpecifier": "shortest",
"files.eol": "\r\n",
"files.eol": "\n",
"search.exclude": {
"**/node_modules": true,
"**/package-lock.json": true,

View File

@ -24,9 +24,8 @@ service cloud.firestore {
allow write: if request.auth.uid == userId;
}
match /users/{userId}/follows/{followUserId} {
match /{somePath=**}/follows/{followUserId} {
allow read;
allow write: if request.auth.uid == userId;
}
match /private-users/{userId} {

View File

@ -20,7 +20,6 @@
},
"main": "lib/functions/src/index.js",
"dependencies": {
"@react-query-firebase/firestore": "0.4.2",
"cors": "2.8.5",
"fetch": "1.1.0",
"firebase-admin": "10.0.0",
@ -28,7 +27,6 @@
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
"module-alias": "2.2.2",
"react-query": "3.39.0",
"stripe": "8.194.0",
"zod": "3.17.2"
},

View File

@ -0,0 +1,68 @@
import clsx from 'clsx'
import { useFollows } from 'web/hooks/use-follows'
import { useUser, useUserById } from 'web/hooks/use-user'
import { follow, unfollow } from 'web/lib/firebase/users'
import { Avatar } from './avatar'
import { FollowButton } from './follow-button'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { UserLink } from './user-page'
export function FollowList(props: { userIds: string[] }) {
const { userIds } = props
const currentUser = useUser()
const followedUserIds = useFollows(currentUser?.id)
const onFollow = (userId: string) => {
if (!currentUser) return
follow(currentUser.id, userId)
}
const onUnfollow = (userId: string) => {
if (!currentUser) return
unfollow(currentUser.id, userId)
}
return (
<Col className="gap-2">
{userIds.length === 0 && (
<div className="text-gray-500">No users yet...</div>
)}
{userIds.map((userId) => (
<UserFollowItem
key={userId}
userId={userId}
isFollowing={
followedUserIds ? followedUserIds.includes(userId) : false
}
onFollow={() => onFollow(userId)}
onUnfollow={() => onUnfollow(userId)}
/>
))}
</Col>
)
}
function UserFollowItem(props: {
userId: string
isFollowing: boolean
onFollow: () => void
onUnfollow: () => void
className?: string
}) {
const { userId, isFollowing, onFollow, onUnfollow, className } = props
const user = useUserById(userId)
return (
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
<Row className="items-center gap-2">
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} />
{user && <UserLink name={user.name} username={user.username} />}
</Row>
<FollowButton
isFollowing={isFollowing}
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
</Row>
)
}

View File

@ -0,0 +1,104 @@
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useFollowers, useFollows } from 'web/hooks/use-follows'
import { prefetchUsers } from 'web/hooks/use-user'
import { FollowList } from './follow-list'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Tabs } from './layout/tabs'
export function FollowingButton(props: { user: User }) {
const { user } = props
const [open, setOpen] = useState(false)
const followingIds = useFollows(user.id)
const followerIds = useFollowers(user.id)
return (
<>
<div
className="cursor-pointer gap-2 hover:underline focus:underline"
tabIndex={0}
onClick={() => setOpen(true)}
>
<span className="font-semibold">{followingIds?.length ?? ''}</span>{' '}
Following
</div>
<FollowingFollowersDialog
user={user}
defaultTab="following"
followingIds={followingIds ?? []}
followerIds={followerIds ?? []}
isOpen={open}
setIsOpen={setOpen}
/>
</>
)
}
export function FollowersButton(props: { user: User }) {
const { user } = props
const [isOpen, setIsOpen] = useState(false)
const followingIds = useFollows(user.id)
const followerIds = useFollowers(user.id)
return (
<>
<div
className="cursor-pointer gap-2 hover:underline focus:underline"
tabIndex={0}
onClick={() => setIsOpen(true)}
>
<span className="font-semibold">{followerIds?.length ?? ''}</span>{' '}
Followers
</div>
<FollowingFollowersDialog
user={user}
defaultTab="followers"
followingIds={followingIds ?? []}
followerIds={followerIds ?? []}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
</>
)
}
function FollowingFollowersDialog(props: {
user: User
followingIds: string[]
followerIds: string[]
defaultTab: 'following' | 'followers'
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
}) {
const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } =
props
useEffect(() => {
prefetchUsers([...followingIds, ...followerIds])
}, [followingIds, followerIds])
return (
<Modal open={isOpen} setOpen={setIsOpen}>
<Col className="rounded bg-white p-6">
<div className="p-2 pb-1 text-xl">{user.name}</div>
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
<Tabs
tabs={[
{
title: 'Following',
content: <FollowList userIds={followingIds} />,
},
{
title: 'Followers',
content: <FollowList userIds={followerIds} />,
},
]}
defaultIndex={defaultTab === 'following' ? 0 : 1}
/>
</Col>
</Modal>
)
}

View File

@ -23,6 +23,7 @@ import { BetsList } from './bets-list'
import { Bet } from 'common/bet'
import { getUserBets } from 'web/lib/firebase/bets'
import { uniq } from 'lodash'
import { FollowersButton, FollowingButton } from './following-button'
export function UserLink(props: {
name: string
@ -149,9 +150,10 @@ export function UserPage(props: {
<span className="text-2xl font-bold">{user.name}</span>
<span className="text-gray-500">@{user.username}</span>
<Spacer h={4} />
{user.bio && (
<>
<Spacer h={4} />
<div>
<Linkify text={user.bio}></Linkify>
</div>
@ -160,6 +162,9 @@ export function UserPage(props: {
)}
<Col className="sm:flex-row sm:gap-4">
<FollowingButton user={user} />
<FollowersButton user={user} />
{user.website && (
<SiteLink
href={

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { listenForFollows } from 'web/lib/firebase/users'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
export const useFollows = (userId: string | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
@ -10,3 +10,13 @@ export const useFollows = (userId: string | undefined) => {
return followIds
}
export const useFollowers = (userId: string | undefined) => {
const [followerIds, setFollowerIds] = useState<string[] | undefined>()
useEffect(() => {
if (userId) return listenForFollowers(userId, setFollowerIds)
}, [userId])
return followerIds
}

View File

@ -1,10 +1,15 @@
import { useEffect, useState } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query'
import { DocumentData } from 'firebase/firestore'
import { PrivateUser } from 'common/user'
import {
listenForLogin,
listenForPrivateUser,
listenForUser,
User,
userDocRef,
} from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality'
@ -35,3 +40,25 @@ export const usePrivateUser = (userId?: string) => {
return privateUser
}
export const useUserById = (userId: string) => {
const result = useFirestoreDocumentData<DocumentData, User>(
['users', userId],
userDocRef(userId),
{ subscribe: true, includeMetadataChanges: true }
)
return result.isLoading ? undefined : result.data
}
const queryClient = new QueryClient()
export const prefetchUser = (userId: string) => {
queryClient.prefetchQuery(['users', userId])
}
export const prefetchUsers = (userIds: string[]) => {
userIds.forEach((userId) => {
queryClient.prefetchQuery(['users', userId])
})
}

View File

@ -11,6 +11,8 @@ import {
orderBy,
updateDoc,
deleteDoc,
collectionGroup,
onSnapshot,
} from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
@ -29,14 +31,17 @@ import { DAY_MS } from 'common/util/time'
import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories'
import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array'
export type { User }
const db = getFirestore(app)
export const auth = getAuth(app)
export const userDocRef = (userId: string) => doc(db, 'users', userId)
export async function getUser(userId: string) {
const docSnap = await getDoc(doc(db, 'users', userId))
const docSnap = await getDoc(userDocRef(userId))
return docSnap.data() as User
}
@ -263,3 +268,23 @@ export function listenForFollows(
setFollowIds(docs.map(({ userId }) => userId))
)
}
export function listenForFollowers(
userId: string,
setFollowerIds: (followerIds: string[]) => void
) {
const followersQuery = query(
collectionGroup(db, 'follows'),
where('userId', '==', userId)
)
return onSnapshot(
followersQuery,
{ includeMetadataChanges: true },
(snapshot) => {
if (snapshot.metadata.fromCache) return
const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
setFollowerIds(filterDefined(values))
}
)
}

View File

@ -21,6 +21,7 @@
"@heroicons/react": "1.0.5",
"@nivo/core": "0.74.0",
"@nivo/line": "0.74.0",
"@react-query-firebase/firestore": "0.4.2",
"algoliasearch": "4.13.0",
"clsx": "1.1.1",
"cors": "2.8.5",
@ -37,7 +38,8 @@
"react-dom": "17.0.2",
"react-expanding-textarea": "2.3.5",
"react-hot-toast": "^2.2.0",
"react-instantsearch-hooks-web": "6.24.1"
"react-instantsearch-hooks-web": "6.24.1",
"react-query": "3.39.0"
},
"devDependencies": {
"@tailwindcss/forms": "0.4.0",