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:
parent
18044e7302
commit
879ab272e0
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -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,
|
||||
|
|
|
@ -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} {
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
68
web/components/follow-list.tsx
Normal file
68
web/components/follow-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
104
web/components/following-button.tsx
Normal file
104
web/components/following-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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={
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue
Block a user