From 5a10132e2b25f186cbb6c2f09557627445743dab Mon Sep 17 00:00:00 2001 From: FRC Date: Fri, 23 Sep 2022 15:11:50 -0400 Subject: [PATCH] Add a "Posts" tab to groups (#926) * Add a "Posts" sidebar item to groups * Fix James's nits * Show "Add Post" button only to users --- common/group.ts | 1 + functions/src/create-group.ts | 1 + functions/src/create-post.ts | 15 ++- functions/src/scripts/convert-tag-to-group.ts | 1 + web/components/create-post.tsx | 94 ++++++++++++++ web/components/groups/group-about-post.tsx | 18 ++- web/hooks/use-post.ts | 26 ++++ web/lib/firebase/api.ts | 6 +- web/lib/firebase/groups.ts | 1 + web/lib/firebase/posts.ts | 5 + web/pages/create-post.tsx | 93 ------------- web/pages/group/[...slugs]/index.tsx | 122 +++++++++++++++++- 12 files changed, 272 insertions(+), 111 deletions(-) create mode 100644 web/components/create-post.tsx delete mode 100644 web/pages/create-post.tsx 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/create-post.ts b/functions/src/create-post.ts index 40d39bba..113a34bd 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -34,11 +34,12 @@ const contentSchema: z.ZodType = z.lazy(() => const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), content: contentSchema, + groupId: z.string().optional(), }) export const createpost = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() - const { title, content } = validate(postSchema, req.body) + const { title, content, groupId } = validate(postSchema, req.body) const creator = await getUser(auth.uid) if (!creator) @@ -60,6 +61,18 @@ export const createpost = newEndpoint({}, async (req, auth) => { } await postRef.create(post) + if (groupId) { + const groupRef = firestore.collection('groups').doc(groupId) + const group = await groupRef.get() + if (group.exists) { + const groupData = group.data() + if (groupData) { + const postIds = groupData.postIds ?? [] + postIds.push(postRef.id) + await groupRef.update({ postIds }) + } + } + } return { status: 'success', post } }) 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/web/components/create-post.tsx b/web/components/create-post.tsx new file mode 100644 index 00000000..c176e61d --- /dev/null +++ b/web/components/create-post.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react' +import { Spacer } from 'web/components/layout/spacer' +import { Title } from 'web/components/title' +import Textarea from 'react-expanding-textarea' + +import { TextEditor, useTextEditor } from 'web/components/editor' +import { createPost } from 'web/lib/firebase/api' +import clsx from 'clsx' +import Router from 'next/router' +import { MAX_POST_TITLE_LENGTH } from 'common/post' +import { postPath } from 'web/lib/firebase/posts' +import { Group } from 'common/group' + +export function CreatePost(props: { group?: Group }) { + const [title, setTitle] = useState('') + const [error, setError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const { group } = props + + const { editor, upload } = useTextEditor({ + disabled: isSubmitting, + }) + + const isValid = editor && title.length > 0 && editor.isEmpty === false + + async function savePost(title: string) { + if (!editor) return + const newPost = { + title: title, + content: editor.getJSON(), + groupId: group?.id, + } + + const result = await createPost(newPost).catch((e) => { + console.log(e) + setError('There was an error creating the post, please try again') + return e + }) + if (result.post) { + await Router.push(postPath(result.post.slug)) + } + } + + return ( +
+
+ + <form> + <div className="form-control w-full"> + <label className="label"> + <span className="mb-1"> + Title<span className={'text-red-700'}> *</span> + </span> + </label> + <Textarea + placeholder="e.g. Elon Mania Post" + className="input input-bordered resize-none" + autoFocus + maxLength={MAX_POST_TITLE_LENGTH} + value={title} + onChange={(e) => setTitle(e.target.value || '')} + /> + <Spacer h={6} /> + <label className="label"> + <span className="mb-1"> + Content<span className={'text-red-700'}> *</span> + </span> + </label> + <TextEditor editor={editor} upload={upload} /> + <Spacer h={6} /> + + <button + type="submit" + className={clsx( + 'btn btn-primary normal-case', + isSubmitting && 'loading disabled' + )} + disabled={isSubmitting || !isValid || upload.isLoading} + onClick={async () => { + setIsSubmitting(true) + await savePost(title) + setIsSubmitting(false) + }} + > + {isSubmitting ? 'Creating...' : 'Create a post'} + </button> + {error !== '' && <div className="text-red-700">{error}</div>} + </div> + </form> + </div> + </div> + ) +} diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-about-post.tsx index b76d8037..4d3046e9 100644 --- a/web/components/groups/group-about-post.tsx +++ b/web/components/groups/group-about-post.tsx @@ -16,29 +16,26 @@ import { usePost } from 'web/hooks/use-post' export function GroupAboutPost(props: { group: Group isEditable: boolean - post: Post + post: Post | null }) { const { group, isEditable } = props const post = usePost(group.aboutPostId) ?? props.post return ( <div className="rounded-md bg-white p-4 "> - {isEditable ? ( - <RichEditGroupAboutPost group={group} post={post} /> - ) : ( - <Content content={post.content} /> - )} + {isEditable && <RichEditGroupAboutPost group={group} post={post} />} + {!isEditable && post && <Content content={post.content} />} </div> ) } -function RichEditGroupAboutPost(props: { group: Group; post: Post }) { +function RichEditGroupAboutPost(props: { group: Group; post: Post | null }) { const { group, post } = props const [editing, setEditing] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const { editor, upload } = useTextEditor({ - defaultValue: post.content, + defaultValue: post?.content, disabled: isSubmitting, }) @@ -49,7 +46,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) { content: editor.getJSON(), } - if (group.aboutPostId == null) { + if (post == null) { const result = await createPost(newPost).catch((e) => { console.error(e) return e @@ -65,6 +62,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) { } async function deleteGroupAboutPost() { + if (post == null) return await deletePost(post) await deleteFieldFromGroup(group, 'aboutPostId') } @@ -91,7 +89,7 @@ function RichEditGroupAboutPost(props: { group: Group; post: Post }) { </> ) : ( <> - {group.aboutPostId == null ? ( + {post == null ? ( <div className="text-center text-gray-500"> <p className="text-sm"> No post has been added yet. diff --git a/web/hooks/use-post.ts b/web/hooks/use-post.ts index 9daf2d22..ff7bf6b9 100644 --- a/web/hooks/use-post.ts +++ b/web/hooks/use-post.ts @@ -11,3 +11,29 @@ export const usePost = (postId: string | undefined) => { return post } + +export const usePosts = (postIds: string[]) => { + const [posts, setPosts] = useState<Post[]>([]) + useEffect(() => { + if (postIds.length === 0) return + setPosts([]) + + const unsubscribes = postIds.map((postId) => + listenForPost(postId, (post) => { + if (post) { + setPosts((posts) => [...posts, post]) + } + }) + ) + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()) + } + }, [postIds]) + + return posts + .filter( + (post, index, self) => index === self.findIndex((t) => t.id === post.id) + ) + .sort((a, b) => b.createdTime - a.createdTime) +} diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 6b1b43d8..8aa7a067 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -90,6 +90,10 @@ export function getCurrentUser(params: any) { return call(getFunctionUrl('getcurrentuser'), 'GET', params) } -export function createPost(params: { title: string; content: JSONContent }) { +export function createPost(params: { + title: string + content: JSONContent + groupId?: string +}) { return call(getFunctionUrl('createpost'), 'POST', params) } 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/create-post.tsx b/web/pages/create-post.tsx deleted file mode 100644 index 01147cc0..00000000 --- a/web/pages/create-post.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from 'react' -import { Spacer } from 'web/components/layout/spacer' -import { Page } from 'web/components/page' -import { Title } from 'web/components/title' -import Textarea from 'react-expanding-textarea' - -import { TextEditor, useTextEditor } from 'web/components/editor' -import { createPost } from 'web/lib/firebase/api' -import clsx from 'clsx' -import Router from 'next/router' -import { MAX_POST_TITLE_LENGTH } from 'common/post' -import { postPath } from 'web/lib/firebase/posts' - -export default function CreatePost() { - const [title, setTitle] = useState('') - const [error, setError] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - const { editor, upload } = useTextEditor({ - disabled: isSubmitting, - }) - - const isValid = editor && title.length > 0 && editor.isEmpty === false - - async function savePost(title: string) { - if (!editor) return - const newPost = { - title: title, - content: editor.getJSON(), - } - - const result = await createPost(newPost).catch((e) => { - console.log(e) - setError('There was an error creating the post, please try again') - return e - }) - if (result.post) { - await Router.push(postPath(result.post.slug)) - } - } - - return ( - <Page> - <div className="mx-auto w-full max-w-3xl"> - <div className="rounded-lg px-6 py-4 sm:py-0"> - <Title className="!mt-0" text="Create a post" /> - <form> - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1"> - Title<span className={'text-red-700'}> *</span> - </span> - </label> - <Textarea - placeholder="e.g. Elon Mania Post" - className="input input-bordered resize-none" - autoFocus - maxLength={MAX_POST_TITLE_LENGTH} - value={title} - onChange={(e) => setTitle(e.target.value || '')} - /> - <Spacer h={6} /> - <label className="label"> - <span className="mb-1"> - Content<span className={'text-red-700'}> *</span> - </span> - </label> - <TextEditor editor={editor} upload={upload} /> - <Spacer h={6} /> - - <button - type="submit" - className={clsx( - 'btn btn-primary normal-case', - isSubmitting && 'loading disabled' - )} - disabled={isSubmitting || !isValid || upload.isLoading} - onClick={async () => { - setIsSubmitting(true) - await savePost(title) - setIsSubmitting(false) - }} - > - {isSubmitting ? 'Creating...' : 'Create a post'} - </button> - {error !== '' && <div className="text-red-700">{error}</div>} - </div> - </form> - </div> - </div> - </Page> - ) -} diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f06247cd..0dfe40a0 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 { ArrowLeftIcon } from '@heroicons/react/solid' @@ -53,6 +53,10 @@ import { SelectMarketsModal } from 'web/components/contract-select-modal' import { BETTORS } from 'common/user' import { Page } from 'web/components/page' import { Tabs } from 'web/components/layout/tabs' +import { Avatar } from 'web/components/avatar' +import { Title } from 'web/components/title' +import { fromNow } from 'web/lib/util/time' +import { CreatePost } from 'web/components/create-post' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -70,7 +74,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 +88,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 +101,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { messages, aboutPost, suggestedFilter, + posts, }, revalidate: 60, // regenerate after a minute @@ -107,6 +116,7 @@ const groupSubpages = [ 'markets', 'leaderboards', 'about', + 'posts', ] as const export default function GroupPage(props: { @@ -116,8 +126,9 @@ export default function GroupPage(props: { topTraders: { user: User; score: number }[] topCreators: { user: User; score: number }[] messages: GroupComment[] - aboutPost: Post + aboutPost: Post | null suggestedFilter: 'open' | 'all' + posts: Post[] }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -127,8 +138,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[] } @@ -137,6 +149,12 @@ export default function GroupPage(props: { const group = useGroup(props.group?.id) ?? props.group const aboutPost = usePost(props.aboutPost?.id) ?? props.aboutPost + let groupPosts = usePosts(group?.postIds ?? []) ?? posts + + if (aboutPost != null) { + groupPosts = [aboutPost, ...groupPosts] + } + const user = useUser() const isAdmin = useAdmin() const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds @@ -172,6 +190,16 @@ export default function GroupPage(props: { </Col> ) + const postsPage = ( + <> + <Col> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + {posts && <GroupPosts posts={groupPosts} group={group} />} + </div> + </Col> + </> + ) + const aboutTab = ( <Col> {(group.aboutPostId != null || isCreator || isAdmin) && ( @@ -234,6 +262,10 @@ export default function GroupPage(props: { title: 'About', content: aboutTab, }, + { + title: 'Posts', + content: postsPage, + }, ] return ( @@ -413,6 +445,84 @@ function GroupLeaderboard(props: { ) } +function GroupPosts(props: { posts: Post[]; group: Group }) { + const { posts, group } = props + const [showCreatePost, setShowCreatePost] = useState(false) + const user = useUser() + + const createPost = <CreatePost group={group} /> + + const postList = ( + <div className=" align-start w-full items-start"> + <Row className="flex justify-between"> + <Col> + <Title text={'Posts'} className="!mt-0" /> + </Col> + <Col> + {user && ( + <Button + className="btn-md" + onClick={() => setShowCreatePost(!showCreatePost)} + > + Add a Post + </Button> + )} + </Col> + </Row> + + <div className="mt-2"> + {posts.map((post) => ( + <PostCard key={post.id} post={post} /> + ))} + {posts.length === 0 && ( + <div className="text-center text-gray-500">No posts yet</div> + )} + </div> + </div> + ) + + return showCreatePost ? createPost : postList +} + +function PostCard(props: { post: Post }) { + const { post } = props + const creatorId = post.creatorId + + const user = useUserById(creatorId) + + if (!user) return <> </> + + return ( + <div className="py-1"> + <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)