diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7819cbe0..ed5544ec 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -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,
diff --git a/firestore.rules b/firestore.rules
index 32649752..b4a58074 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -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} {
diff --git a/functions/package.json b/functions/package.json
index 3f3315f3..c51afd82 100644
--- a/functions/package.json
+++ b/functions/package.json
@@ -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"
},
diff --git a/web/components/follow-list.tsx b/web/components/follow-list.tsx
new file mode 100644
index 00000000..0eef1170
--- /dev/null
+++ b/web/components/follow-list.tsx
@@ -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 (
+
+ {userIds.length === 0 && (
+ No users yet...
+ )}
+ {userIds.map((userId) => (
+ onFollow(userId)}
+ onUnfollow={() => onUnfollow(userId)}
+ />
+ ))}
+
+ )
+}
+
+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 (
+
+
+
+ {user && }
+
+
+
+ )
+}
diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx
new file mode 100644
index 00000000..b36ee37d
--- /dev/null
+++ b/web/components/following-button.tsx
@@ -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 (
+ <>
+ setOpen(true)}
+ >
+ {followingIds?.length ?? ''}{' '}
+ Following
+
+
+
+ >
+ )
+}
+
+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 (
+ <>
+ setIsOpen(true)}
+ >
+ {followerIds?.length ?? ''}{' '}
+ Followers
+
+
+
+ >
+ )
+}
+
+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 (
+
+
+ {user.name}
+ @{user.username}
+ ,
+ },
+ {
+ title: 'Followers',
+ content: ,
+ },
+ ]}
+ defaultIndex={defaultTab === 'following' ? 0 : 1}
+ />
+
+
+ )
+}
diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx
index 0c704056..8b63d01e 100644
--- a/web/components/user-page.tsx
+++ b/web/components/user-page.tsx
@@ -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: {
{user.name}
@{user.username}
+
+
{user.bio && (
<>
-
@@ -160,6 +162,9 @@ export function UserPage(props: {
)}
+
+
+
{user.website && (
{
const [followIds, setFollowIds] = useState()
@@ -10,3 +10,13 @@ export const useFollows = (userId: string | undefined) => {
return followIds
}
+
+export const useFollowers = (userId: string | undefined) => {
+ const [followerIds, setFollowerIds] = useState()
+
+ useEffect(() => {
+ if (userId) return listenForFollowers(userId, setFollowerIds)
+ }, [userId])
+
+ return followerIds
+}
diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts
index bd4caf6e..7641f6e2 100644
--- a/web/hooks/use-user.ts
+++ b/web/hooks/use-user.ts
@@ -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(
+ ['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])
+ })
+}
diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts
index 61b4fbb3..344ba7e6 100644
--- a/web/lib/firebase/users.ts
+++ b/web/lib/firebase/users.ts
@@ -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))
+ }
+ )
+}
diff --git a/web/package.json b/web/package.json
index 889265c9..35b71341 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",