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",
|
"javascript.preferences.importModuleSpecifier": "shortest",
|
||||||
"typescript.preferences.importModuleSpecifier": "shortest",
|
"typescript.preferences.importModuleSpecifier": "shortest",
|
||||||
"files.eol": "\r\n",
|
"files.eol": "\n",
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/node_modules": true,
|
"**/node_modules": true,
|
||||||
"**/package-lock.json": true,
|
"**/package-lock.json": true,
|
||||||
|
|
|
@ -24,9 +24,8 @@ service cloud.firestore {
|
||||||
allow write: if request.auth.uid == userId;
|
allow write: if request.auth.uid == userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
match /users/{userId}/follows/{followUserId} {
|
match /{somePath=**}/follows/{followUserId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow write: if request.auth.uid == userId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match /private-users/{userId} {
|
match /private-users/{userId} {
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
},
|
},
|
||||||
"main": "lib/functions/src/index.js",
|
"main": "lib/functions/src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-query-firebase/firestore": "0.4.2",
|
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"fetch": "1.1.0",
|
"fetch": "1.1.0",
|
||||||
"firebase-admin": "10.0.0",
|
"firebase-admin": "10.0.0",
|
||||||
|
@ -28,7 +27,6 @@
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mailgun-js": "0.22.0",
|
"mailgun-js": "0.22.0",
|
||||||
"module-alias": "2.2.2",
|
"module-alias": "2.2.2",
|
||||||
"react-query": "3.39.0",
|
|
||||||
"stripe": "8.194.0",
|
"stripe": "8.194.0",
|
||||||
"zod": "3.17.2"
|
"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 { Bet } from 'common/bet'
|
||||||
import { getUserBets } from 'web/lib/firebase/bets'
|
import { getUserBets } from 'web/lib/firebase/bets'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
|
import { FollowersButton, FollowingButton } from './following-button'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -149,9 +150,10 @@ export function UserPage(props: {
|
||||||
<span className="text-2xl font-bold">{user.name}</span>
|
<span className="text-2xl font-bold">{user.name}</span>
|
||||||
<span className="text-gray-500">@{user.username}</span>
|
<span className="text-gray-500">@{user.username}</span>
|
||||||
|
|
||||||
|
<Spacer h={4} />
|
||||||
|
|
||||||
{user.bio && (
|
{user.bio && (
|
||||||
<>
|
<>
|
||||||
<Spacer h={4} />
|
|
||||||
<div>
|
<div>
|
||||||
<Linkify text={user.bio}></Linkify>
|
<Linkify text={user.bio}></Linkify>
|
||||||
</div>
|
</div>
|
||||||
|
@ -160,6 +162,9 @@ export function UserPage(props: {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Col className="sm:flex-row sm:gap-4">
|
<Col className="sm:flex-row sm:gap-4">
|
||||||
|
<FollowingButton user={user} />
|
||||||
|
<FollowersButton user={user} />
|
||||||
|
|
||||||
{user.website && (
|
{user.website && (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
href={
|
href={
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
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) => {
|
export const useFollows = (userId: string | undefined) => {
|
||||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||||
|
@ -10,3 +10,13 @@ export const useFollows = (userId: string | undefined) => {
|
||||||
|
|
||||||
return followIds
|
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 { 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 { PrivateUser } from 'common/user'
|
||||||
import {
|
import {
|
||||||
listenForLogin,
|
listenForLogin,
|
||||||
listenForPrivateUser,
|
listenForPrivateUser,
|
||||||
listenForUser,
|
listenForUser,
|
||||||
User,
|
User,
|
||||||
|
userDocRef,
|
||||||
} from 'web/lib/firebase/users'
|
} from 'web/lib/firebase/users'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { useStateCheckEquality } from './use-state-check-equality'
|
||||||
|
|
||||||
|
@ -35,3 +40,25 @@ export const usePrivateUser = (userId?: string) => {
|
||||||
|
|
||||||
return privateUser
|
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,
|
orderBy,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
|
collectionGroup,
|
||||||
|
onSnapshot,
|
||||||
} 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'
|
||||||
|
@ -29,14 +31,17 @@ import { DAY_MS } from 'common/util/time'
|
||||||
import { feed } from 'common/feed'
|
import { feed } from 'common/feed'
|
||||||
import { CATEGORY_LIST } from 'common/categories'
|
import { CATEGORY_LIST } from 'common/categories'
|
||||||
import { safeLocalStorage } from '../util/local'
|
import { safeLocalStorage } from '../util/local'
|
||||||
|
import { filterDefined } from 'common/util/array'
|
||||||
|
|
||||||
export type { User }
|
export type { User }
|
||||||
|
|
||||||
const db = getFirestore(app)
|
const db = getFirestore(app)
|
||||||
export const auth = getAuth(app)
|
export const auth = getAuth(app)
|
||||||
|
|
||||||
|
export const userDocRef = (userId: string) => doc(db, 'users', userId)
|
||||||
|
|
||||||
export async function getUser(userId: string) {
|
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
|
return docSnap.data() as User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -263,3 +268,23 @@ export function listenForFollows(
|
||||||
setFollowIds(docs.map(({ userId }) => userId))
|
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",
|
"@heroicons/react": "1.0.5",
|
||||||
"@nivo/core": "0.74.0",
|
"@nivo/core": "0.74.0",
|
||||||
"@nivo/line": "0.74.0",
|
"@nivo/line": "0.74.0",
|
||||||
|
"@react-query-firebase/firestore": "0.4.2",
|
||||||
"algoliasearch": "4.13.0",
|
"algoliasearch": "4.13.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
|
@ -37,7 +38,8 @@
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-expanding-textarea": "2.3.5",
|
"react-expanding-textarea": "2.3.5",
|
||||||
"react-hot-toast": "^2.2.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "0.4.0",
|
"@tailwindcss/forms": "0.4.0",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user