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",