diff --git a/common/util/array.ts b/common/util/array.ts new file mode 100644 index 00000000..fba342aa --- /dev/null +++ b/common/util/array.ts @@ -0,0 +1,3 @@ +export function filterDefined(array: (T | null | undefined)[]) { + return array.filter((item) => item) as T[] +} diff --git a/firestore.rules b/firestore.rules index 18572047..73eafb24 100644 --- a/firestore.rules +++ b/firestore.rules @@ -52,5 +52,9 @@ service cloud.firestore { allow read; allow write: if request.auth.uid == userId; } + + match /{somePath=**}/followers/{userId} { + allow read; + } } } \ No newline at end of file diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 5af4bb0d..6cff5c8b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -32,12 +32,12 @@ import { OutcomeLabel, YesLabel, NoLabel, MarketLabel } from './outcome-label' export function BetsList(props: { user: User }) { const { user } = props - const bets = useUserBets(user?.id ?? '') + const bets = useUserBets(user.id) const [contracts, setContracts] = useState([]) useEffect(() => { - const loadedBets = bets === 'loading' ? [] : bets + const loadedBets = bets ? bets : [] const contractIds = _.uniq(loadedBets.map((bet) => bet.contractId)) let disposed = false @@ -52,7 +52,7 @@ export function BetsList(props: { user: User }) { } }, [bets]) - if (bets === 'loading') { + if (!bets) { return <> } diff --git a/web/components/loading-indicator.tsx b/web/components/loading-indicator.tsx new file mode 100644 index 00000000..7ef73099 --- /dev/null +++ b/web/components/loading-indicator.tsx @@ -0,0 +1,14 @@ +import clsx from 'clsx' + +export function LoadingIndicator(props: { className?: string }) { + const { className } = props + + return ( +
+
+
+ ) +} diff --git a/web/components/tags-list.tsx b/web/components/tags-list.tsx index 4894643b..80276897 100644 --- a/web/components/tags-list.tsx +++ b/web/components/tags-list.tsx @@ -1,7 +1,6 @@ import clsx from 'clsx' import { Row } from './layout/row' import { SiteLink } from './site-link' -import { Fold } from '../../common/fold' function Hashtag(props: { tag: string; noLink?: boolean }) { const { tag, noLink } = props @@ -45,11 +44,11 @@ export function TagsList(props: { ) } -export function FoldTag(props: { fold: Fold }) { +export function FoldTag(props: { fold: { slug: string; name: string } }) { const { fold } = props - const { name } = fold + const { slug, name } = fold return ( - +
@@ -70,7 +72,7 @@ export function FoldTagList(props: { folds: Fold[]; className?: string }) { <>
Communities
{folds.map((fold) => ( - + ))} )} diff --git a/web/hooks/use-user-bets.ts b/web/hooks/use-user-bets.ts index e52d3786..6af56902 100644 --- a/web/hooks/use-user-bets.ts +++ b/web/hooks/use-user-bets.ts @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react' import { Bet, listenForUserBets } from '../lib/firebase/bets' -export const useUserBets = (userId: string) => { - const [bets, setBets] = useState('loading') +export const useUserBets = (userId: string | undefined) => { + const [bets, setBets] = useState(undefined) useEffect(() => { - return listenForUserBets(userId, setBets) + if (userId) return listenForUserBets(userId, setBets) }, [userId]) return bets diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 5397e8fe..642c07c4 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -2,7 +2,6 @@ import { collection, collectionGroup, query, - onSnapshot, where, orderBy, } from 'firebase/firestore' @@ -11,7 +10,7 @@ import _ from 'lodash' import { db } from './init' import { Bet } from '../../../common/bet' import { Contract } from '../../../common/contract' -import { getValues } from './utils' +import { getValues, listenForValues } from './utils' export type { Bet } function getBetsCollection(contractId: string) { @@ -51,11 +50,8 @@ export function listenForBets( contractId: string, setBets: (bets: Bet[]) => void ) { - return onSnapshot(getBetsCollection(contractId), (snap) => { - const bets = snap.docs.map((doc) => doc.data() as Bet) - + return listenForValues(getBetsCollection(contractId), (bets) => { bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) - setBets(bets) }) } @@ -68,9 +64,7 @@ export function listenForUserBets( collectionGroup(db, 'bets'), where('userId', '==', userId) ) - - return onSnapshot(userQuery, (snap) => { - const bets = snap.docs.map((doc) => doc.data() as Bet) + return listenForValues(userQuery, (bets) => { bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) setBets(bets) }) @@ -88,5 +82,5 @@ export function withoutAnteBets(contract: Contract, bets?: Bet[]) { return bets.slice(2) } - return bets ?? [] + return bets?.filter((bet) => !bet.isAnte) ?? [] } diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index d272581e..7072a71a 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -1,7 +1,6 @@ import { doc, collection, - onSnapshot, setDoc, query, collectionGroup, @@ -46,13 +45,13 @@ export function listenForComments( contractId: string, setComments: (comments: Comment[]) => void ) { - return onSnapshot(getCommentsCollection(contractId), (snap) => { - const comments = snap.docs.map((doc) => doc.data() as Comment) - - comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - - setComments(comments) - }) + return listenForValues( + getCommentsCollection(contractId), + (comments) => { + comments.sort((c1, c2) => c1.createdTime - c2.createdTime) + setComments(comments) + } + ) } // Return a map of betId -> comment diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 4789d19f..666c7caf 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -8,7 +8,6 @@ import { collection, query, getDocs, - onSnapshot, orderBy, getDoc, updateDoc, @@ -116,9 +115,7 @@ export function listenForContracts( setContracts: (contracts: Contract[]) => void ) { const q = query(contractCollection, orderBy('createdTime', 'desc')) - return onSnapshot(q, (snap) => { - setContracts(snap.docs.map((doc) => doc.data() as Contract)) - }) + return listenForValues(q, setContracts) } export function listenForContract( diff --git a/web/lib/firebase/folds.ts b/web/lib/firebase/folds.ts index 8ff9a385..58618414 100644 --- a/web/lib/firebase/folds.ts +++ b/web/lib/firebase/folds.ts @@ -1,7 +1,9 @@ import { collection, + collectionGroup, deleteDoc, doc, + getDocs, query, setDoc, updateDoc, @@ -12,7 +14,7 @@ import { Fold } from '../../../common/fold' import { Contract, contractCollection } from './contracts' import { db } from './init' import { User } from './users' -import { getValues, listenForValue, listenForValues } from './utils' +import { getValue, getValues, listenForValue, listenForValues } from './utils' const foldCollection = collection(db, 'folds') @@ -39,6 +41,10 @@ export function listenForFolds(setFolds: (folds: Fold[]) => void) { return listenForValues(foldCollection, setFolds) } +export function getFold(foldId: string) { + return getValue(doc(foldCollection, foldId)) +} + export async function getFoldBySlug(slug: string) { const q = query(foldCollection, where('slug', '==', slug)) const folds = await getValues(q) @@ -147,3 +153,13 @@ export async function getFoldsByTags(tags: string[]) { return _.sortBy(folds, (fold) => -1 * fold.followCount) } + +export async function getFollowedFolds(userId: string) { + const snapshot = await getDocs( + query(collectionGroup(db, 'followers'), where('userId', '==', userId)) + ) + const foldIds = snapshot.docs.map( + (doc) => doc.ref.parent.parent?.id as string + ) + return foldIds +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 4141e6c6..57969bb6 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -3,7 +3,6 @@ import { doc, setDoc, getDoc, - onSnapshot, collection, query, where, @@ -22,7 +21,7 @@ import { import { app } from './init' import { PrivateUser, User } from '../../../common/user' import { createUser } from './api-call' -import { getValues, listenForValues } from './utils' +import { getValues, listenForValue, listenForValues } from './utils' export type { User } const db = getFirestore(app) @@ -46,11 +45,12 @@ export async function setUser(userId: string, user: User) { await setDoc(doc(db, 'users', userId), user) } -export function listenForUser(userId: string, setUser: (user: User) => void) { +export function listenForUser( + userId: string, + setUser: (user: User | null) => void +) { const userRef = doc(db, 'users', userId) - return onSnapshot(userRef, (userSnap) => { - setUser(userSnap.data() as User) - }) + return listenForValue(userRef, setUser) } const CACHED_USER_KEY = 'CACHED_USER_KEY' diff --git a/web/pages/activity.tsx b/web/pages/activity.tsx index b740d580..be5a9809 100644 --- a/web/pages/activity.tsx +++ b/web/pages/activity.tsx @@ -100,7 +100,7 @@ export function ActivityFeed(props: {
diff --git a/web/pages/home.tsx b/web/pages/home.tsx index c100fb42..cea7c0a7 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,72 +1,125 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import Router from 'next/router' import _ from 'lodash' import { Contract, listAllContracts } from '../lib/firebase/contracts' import { Page } from '../components/page' import { ActivityFeed, findActiveContracts } from './activity' -import { - getRecentComments, - Comment, - listAllComments, -} from '../lib/firebase/comments' -import { Bet, getRecentBets, listAllBets } from '../lib/firebase/bets' +import { Comment, listAllComments } from '../lib/firebase/comments' +import { Bet, listAllBets } from '../lib/firebase/bets' import FeedCreate from '../components/feed-create' import { Spacer } from '../components/layout/spacer' import { Col } from '../components/layout/col' import { useUser } from '../hooks/use-user' import { useContracts } from '../hooks/use-contracts' -import { FoldTag, TagsList } from '../components/tags-list' -import { Row } from '../components/layout/row' +import { getFollowedFolds, listAllFolds } from '../lib/firebase/folds' +import { Fold } from '../../common/fold' +import { filterDefined } from '../../common/util/array' +import { useUserBets } from '../hooks/use-user-bets' +import { LoadingIndicator } from '../components/loading-indicator' +import { FoldTagList } from '../components/tags-list' export async function getStaticProps() { - const [contracts, recentComments, recentBets] = await Promise.all([ + const [contracts, folds] = await Promise.all([ listAllContracts().catch((_) => []), - getRecentComments().catch(() => []), - getRecentBets().catch(() => []), + listAllFolds().catch(() => []), ]) - const activeContracts = findActiveContracts( - contracts, - recentComments, - recentBets - ) - const activeContractBets = await Promise.all( - activeContracts.map((contract) => listAllBets(contract.id).catch((_) => [])) - ) - const activeContractComments = await Promise.all( - activeContracts.map((contract) => - listAllComments(contract.id).catch((_) => []) - ) - ) - return { props: { - activeContracts, - activeContractBets, - activeContractComments, + contracts, + folds, }, revalidate: 60, // regenerate after a minute } } -const Home = (props: { - activeContracts: Contract[] - activeContractBets: Bet[][] - activeContractComments: Comment[][] -}) => { - const { activeContracts, activeContractBets, activeContractComments } = props +const Home = (props: { contracts: Contract[]; folds: Fold[] }) => { + const { folds } = props const user = useUser() - const contracts = useContracts() ?? activeContracts - const contractsMap = _.fromPairs( - contracts.map((contract) => [contract.id, contract]) + const contracts = useContracts() ?? props.contracts + + const [followedFoldIds, setFollowedFoldIds] = useState( + undefined ) - const updatedContracts = activeContracts.map( - (contract) => contractsMap[contract.id] + + useEffect(() => { + if (user) { + getFollowedFolds(user.id).then((foldIds) => setFollowedFoldIds(foldIds)) + } + }, [user]) + + const followedFolds = filterDefined( + (followedFoldIds ?? []).map((id) => folds.find((fold) => fold.id === id)) ) + const tagSet = new Set( + _.flatten(followedFolds.map((fold) => fold.lowercaseTags)) + ) + + const yourBets = useUserBets(user?.id) + const yourBetContracts = new Set( + (yourBets ?? []).map((bet) => bet.contractId) + ) + + const feedContracts = + followedFoldIds && yourBets + ? contracts.filter( + (contract) => + contract.lowercaseTags.some((tag) => tagSet.has(tag)) || + yourBetContracts.has(contract.id) + ) + : undefined + + const feedContractsKey = feedContracts?.map(({ id }) => id).join(',') + + const [feedBets, setFeedBets] = useState() + const [feedComments, setFeedComments] = useState() + + useEffect(() => { + if (feedContracts) { + Promise.all( + feedContracts.map((contract) => listAllBets(contract.id)) + ).then(setFeedBets) + Promise.all( + feedContracts.map((contract) => listAllComments(contract.id)) + ).then(setFeedComments) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [feedContractsKey]) + + const oneDayMS = 24 * 60 * 60 * 1000 + const recentBets = + feedBets && + feedBets.flat().filter((bet) => bet.createdTime > Date.now() - oneDayMS) + + const activeContracts = + feedContracts && + feedComments && + recentBets && + findActiveContracts(feedContracts, feedComments.flat(), recentBets, 365) + + const contractBets = activeContracts + ? activeContracts.map( + (contract) => feedBets[feedContracts.indexOf(contract)] + ) + : [] + const contractComments = activeContracts + ? activeContracts.map( + (contract) => feedComments[feedContracts.indexOf(contract)] + ) + : [] + + console.log({ + followedFoldIds, + followedFolds, + yourBetContracts, + feedContracts, + feedBets, + feedComments, + }) if (user === null) { Router.replace('/') @@ -76,28 +129,32 @@ const Home = (props: { return ( - + - - - + - - - + + {activeContracts ? ( + + ) : ( + + )} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 4319621d..e009e488 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -22,7 +22,6 @@ module.exports = { extend: {}, }, plugins: [require('@tailwindcss/forms'), require('daisyui')], - daisyui: { themes: [ {