diff --git a/common/group.ts b/common/group.ts index 871bc821..5220a1e8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -10,6 +10,7 @@ export type Group = { totalContracts: number totalMembers: number aboutPostId?: string + postIds: string[] chatDisabled?: boolean mostRecentContractAddedTime?: number cachedLeaderboard?: { diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 9d00bb0b..76dc1298 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -61,6 +61,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => { anyoneCanJoin, totalContracts: 0, totalMembers: memberIds.length, + postIds: [], } await groupRef.create(group) diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index 3240357e..b2e4c4d8 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -41,6 +41,7 @@ const createGroup = async ( anyoneCanJoin: true, totalContracts: contracts.length, totalMembers: 1, + postIds: [], } await groupRef.create(group) // create a GroupMemberDoc for the creator diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 6bb8349a..eb5fa8f8 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -1,5 +1,4 @@ import * as admin from 'firebase-admin' -import fetch from 'node-fetch' import { chunk } from 'lodash' import { Contract } from '../../common/contract' diff --git a/web/components/nav/group-sidebar.tsx b/web/components/nav/group-sidebar.tsx index a68064e0..f5b856ea 100644 --- a/web/components/nav/group-sidebar.tsx +++ b/web/components/nav/group-sidebar.tsx @@ -1,4 +1,4 @@ -import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline' +import { ChatAlt2Icon, ClipboardIcon, HomeIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { useUser } from 'web/hooks/use-user' import { ManifoldLogo } from './manifold-logo' @@ -17,6 +17,7 @@ const groupNavigation = [ { name: 'Markets', key: 'markets', icon: HomeIcon }, { name: 'About', key: 'about', icon: ClipboardIcon }, { name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon }, + { name: 'Posts', key: 'posts', icon: ChatAlt2Icon }, ] const generalNavigation = (user?: User | null) => diff --git a/web/hooks/use-post.ts b/web/hooks/use-post.ts index 9daf2d22..62b72923 100644 --- a/web/hooks/use-post.ts +++ b/web/hooks/use-post.ts @@ -11,3 +11,19 @@ export const usePost = (postId: string | undefined) => { return post } + +export const usePosts = (postIds: string[]) => { + const [posts, setPosts] = useState([]) + + useEffect(() => { + if (postIds.length === 0) return + + postIds.map((postId) => + listenForPost(postId, (post) => { + if (post) setPosts((posts) => [...posts, post]) + }) + ) + }, [postIds]) + + return posts +} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 61424b8f..17e41c53 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -43,6 +43,7 @@ export function groupPath( | 'about' | typeof GROUP_CHAT_SLUG | 'leaderboards' + | 'posts' ) { return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` } diff --git a/web/lib/firebase/posts.ts b/web/lib/firebase/posts.ts index 162933af..36007048 100644 --- a/web/lib/firebase/posts.ts +++ b/web/lib/firebase/posts.ts @@ -39,3 +39,8 @@ export function listenForPost( ) { return listenForValue(doc(posts, postId), setPost) } + +export async function listPosts(postIds?: string[]) { + if (postIds === undefined) return [] + return Promise.all(postIds.map(getPost)) +} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 3adb01c1..fa62b7f6 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -16,7 +16,7 @@ import { import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' +import { useUser, useUserById } from 'web/hooks/use-user' import { useGroup, useGroupContractIds, @@ -42,10 +42,10 @@ import { GroupComment } from 'common/comment' import { REFERRAL_AMOUNT } from 'common/economy' import { UserLink } from 'web/components/user-link' import { GroupAboutPost } from 'web/components/groups/group-about-post' -import { getPost } from 'web/lib/firebase/posts' +import { getPost, listPosts, postPath } from 'web/lib/firebase/posts' import { Post } from 'common/post' import { Spacer } from 'web/components/layout/spacer' -import { usePost } from 'web/hooks/use-post' +import { usePost, usePosts } from 'web/hooks/use-post' import { useAdmin } from 'web/hooks/use-admin' import { track } from '@amplitude/analytics-browser' import { GroupNavBar } from 'web/components/nav/group-nav-bar' @@ -53,6 +53,9 @@ import { ArrowLeftIcon } from '@heroicons/react/solid' import { GroupSidebar } from 'web/components/nav/group-sidebar' import { SelectMarketsModal } from 'web/components/contract-select-modal' import { BETTORS } from 'common/user' +import { Avatar } from 'web/components/avatar' +import { Title } from 'web/components/title' +import { fromNow } from 'web/lib/util/time' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -70,7 +73,8 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { ? 'all' : 'open' const aboutPost = - group && group.aboutPostId != null && (await getPost(group.aboutPostId)) + group && group.aboutPostId != null ? await getPost(group.aboutPostId) : null + const messages = group && (await listAllCommentsOnGroup(group.id)) const cachedTopTraderIds = @@ -83,6 +87,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const creator = await creatorPromise + const posts = ((group && (await listPosts(group.postIds))) ?? []).filter( + (p) => p != null + ) as Post[] return { props: { group, @@ -93,6 +100,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { messages, aboutPost, suggestedFilter, + posts, }, revalidate: 60, // regenerate after a minute @@ -107,6 +115,7 @@ const groupSubpages = [ 'markets', 'leaderboards', 'about', + 'posts', ] as const export default function GroupPage(props: { @@ -118,6 +127,7 @@ export default function GroupPage(props: { messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' + posts: Post[] }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -127,8 +137,9 @@ export default function GroupPage(props: { topCreators: [], messages: [], suggestedFilter: 'open', + posts: [], } - const { creator, topTraders, topCreators, suggestedFilter } = props + const { creator, topTraders, topCreators, suggestedFilter, posts } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -136,13 +147,14 @@ export default function GroupPage(props: { const group = useGroup(props.group?.id) ?? props.group const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost + const groupPosts = [...(usePosts(group?.postIds ?? []) ?? posts), aboutPost] const user = useUser() const isAdmin = useAdmin() const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds // Note: Keep in sync with sidebarPages const [sidebarIndex, setSidebarIndex] = useState( - ['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets') + ['markets', 'leaderboards', 'about', 'posts'].indexOf(page ?? 'markets') ) useSaveReferral(user, { @@ -176,6 +188,14 @@ export default function GroupPage(props: { ) + const postsPage = ( + +
+ {posts && } +
+ + ) + const aboutPage = ( {(group.aboutPostId != null || isCreator || isAdmin) && ( @@ -238,6 +258,12 @@ export default function GroupPage(props: { href: groupPath(group.slug, 'about'), key: 'about', }, + { + title: 'Posts', + content: postsPage, + href: groupPath(group.slug, 'posts'), + key: 'posts', + }, ] const pageContent = sidebarPages[sidebarIndex].content @@ -461,6 +487,57 @@ function GroupLeaderboard(props: { ) } +function GroupPosts(props: { posts: Post[] }) { + const { posts } = props + return ( +
+ + <div className="mt-2"> + {posts.map((post) => ( + <PostCard post={post} /> + ))} + </div> + </div> + ) +} + +function PostCard(props: { post: Post }) { + const { post } = props + const creatorId = post.creatorId + const user = useUserById(creatorId) + if (!user) return <> </> + + return ( + <div> + <Link href={postPath(post.slug)}> + <Row + className={ + 'relative gap-3 rounded-lg bg-white p-2 shadow-md hover:cursor-pointer hover:bg-gray-100' + } + > + <div className="flex-shrink-0"> + <Avatar className="h-12 w-12" username={user?.username} /> + </div> + <div className=""> + <div className="text-sm text-gray-500"> + <UserLink + className="text-neutral" + name={user?.name} + username={user?.username} + /> + <span className="mx-1">•</span> + <span className="text-gray-500">{fromNow(post.createdTime)}</span> + </div> + <div className="text-lg font-medium text-gray-900"> + {post.title} + </div> + </div> + </Row> + </Link> + </div> + ) +} + function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false)