From 048de52bee660e76131a41f88cebdba4de531ab6 Mon Sep 17 00:00:00 2001 From: Pico2x Date: Fri, 2 Sep 2022 16:30:31 +0100 Subject: [PATCH] Adds comments to posts --- common/comment.ts | 8 +- firestore.rules | 4 + web/components/tipper.tsx | 4 +- web/hooks/use-comments.ts | 18 ++- web/hooks/use-tip-txns.ts | 7 +- web/lib/firebase/comments.ts | 105 ++++++++++---- web/lib/firebase/txns.ts | 14 ++ web/pages/post/[...slugs]/index.tsx | 70 ++++++++- web/posts/post-comments.tsx | 216 ++++++++++++++++++++++++++++ 9 files changed, 411 insertions(+), 35 deletions(-) create mode 100644 web/posts/post-comments.tsx diff --git a/common/comment.ts b/common/comment.ts index c7f9b855..de17e0e5 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,6 +1,6 @@ import type { JSONContent } from '@tiptap/core' -export type AnyCommentType = OnContract | OnGroup +export type AnyCommentType = OnContract | OnGroup | OnPost // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. @@ -34,5 +34,11 @@ type OnGroup = { groupId: string } +type OnPost = { + commentType: 'post' + postId: string +} + export type ContractComment = Comment export type GroupComment = Comment +export type PostComment = Comment diff --git a/firestore.rules b/firestore.rules index e42e3ed7..6490c83f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -188,6 +188,10 @@ service cloud.firestore { .affectedKeys() .hasOnly(['name', 'content']); allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; + match /comments/{commentId} { + allow read; + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ; + } } } } diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index b9ebdefc..7aef6189 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { comment.commentType === 'contract' ? comment.contractId : undefined const groupId = comment.commentType === 'group' ? comment.groupId : undefined + const postId = comment.commentType === 'post' ? comment.postId : undefined await transact({ amount: change, fromId: user.id, @@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { toType: 'USER', token: 'M$', category: 'TIP', - data: { commentId: comment.id, contractId, groupId }, + data: { commentId: comment.id, contractId, groupId, postId }, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, }) @@ -62,6 +63,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { commentId: comment.id, contractId, groupId, + postId, amount: change, fromId: user.id, toId: comment.userId, diff --git a/web/hooks/use-comments.ts b/web/hooks/use-comments.ts index 172d2cee..b380d53f 100644 --- a/web/hooks/use-comments.ts +++ b/web/hooks/use-comments.ts @@ -1,8 +1,14 @@ import { useEffect, useState } from 'react' -import { Comment, ContractComment, GroupComment } from 'common/comment' +import { + Comment, + ContractComment, + GroupComment, + PostComment, +} from 'common/comment' import { listenForCommentsOnContract, listenForCommentsOnGroup, + listenForCommentsOnPost, listenForRecentComments, } from 'web/lib/firebase/comments' @@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => { return comments } +export const useCommentsOnPost = (postId: string | undefined) => { + const [comments, setComments] = useState() + + useEffect(() => { + if (postId) return listenForCommentsOnPost(postId, setComments) + }, [postId]) + + return comments +} + export const useRecentComments = () => { const [recentComments, setRecentComments] = useState() useEffect(() => listenForRecentComments(setRecentComments), []) diff --git a/web/hooks/use-tip-txns.ts b/web/hooks/use-tip-txns.ts index 8d26176f..8726fd6e 100644 --- a/web/hooks/use-tip-txns.ts +++ b/web/hooks/use-tip-txns.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react' import { listenForTipTxns, listenForTipTxnsOnGroup, + listenForTipTxnsOnPost, } from 'web/lib/firebase/txns' export type CommentTips = { [userId: string]: number } @@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips } export function useTipTxns(on: { contractId?: string groupId?: string + postId?: string }): CommentTipMap { const [txns, setTxns] = useState([]) - const { contractId, groupId } = on + const { contractId, groupId, postId } = on useEffect(() => { if (contractId) return listenForTipTxns(contractId, setTxns) if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns) - }, [contractId, groupId, setTxns]) + if (postId) return listenForTipTxnsOnPost(postId, setTxns) + }, [contractId, groupId, postId, setTxns]) return useMemo(() => { const byComment = groupBy(txns, 'data.commentId') diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index aab4de85..6cc120cf 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -7,12 +7,19 @@ import { query, setDoc, where, + DocumentData, + DocumentReference, } from 'firebase/firestore' import { getValues, listenForValues } from './utils' import { db } from './init' import { User } from 'common/user' -import { Comment, ContractComment, GroupComment } from 'common/comment' +import { + Comment, + ContractComment, + GroupComment, + PostComment, +} from 'common/comment' import { removeUndefinedProps } from 'common/util/object' import { track } from '@amplitude/analytics-browser' import { JSONContent } from '@tiptap/react' @@ -24,7 +31,7 @@ export const MAX_COMMENT_LENGTH = 10000 export async function createCommentOnContract( contractId: string, content: JSONContent, - commenter: User, + user: User, betId?: string, answerOutcome?: string, replyToCommentId?: string @@ -32,28 +39,15 @@ export async function createCommentOnContract( const ref = betId ? doc(getCommentsCollection(contractId), betId) : doc(getCommentsCollection(contractId)) - // contract slug and question are set via trigger - const comment = removeUndefinedProps({ - id: ref.id, - commentType: 'contract', + return await createComment( contractId, - userId: commenter.id, - content: content, - createdTime: Date.now(), - userName: commenter.name, - userUsername: commenter.username, - userAvatarUrl: commenter.avatarUrl, - betId: betId, - answerOutcome: answerOutcome, - replyToCommentId: replyToCommentId, - }) - track('comment', { - contractId, - commentId: ref.id, - betId: betId, - replyToCommentId: replyToCommentId, - }) - return await setDoc(ref, comment) + 'contract', + content, + user, + ref, + replyToCommentId, + { answerOutcome: answerOutcome, betId: betId } + ) } export async function createCommentOnGroup( groupId: string, @@ -62,10 +56,45 @@ export async function createCommentOnGroup( replyToCommentId?: string ) { const ref = doc(getCommentsOnGroupCollection(groupId)) + return await createComment( + groupId, + 'group', + content, + user, + ref, + replyToCommentId + ) +} + +export async function createCommentOnPost( + postId: string, + content: JSONContent, + user: User, + replyToCommentId?: string +) { + const ref = doc(getCommentsOnPostCollection(postId)) + + return await createComment( + postId, + 'post', + content, + user, + ref, + replyToCommentId + ) +} + +async function createComment( + surfaceId: string, + surfaceType: 'contract' | 'group' | 'post', + content: JSONContent, + user: User, + ref: DocumentReference, + replyToCommentId?: string, + extraFields: { [key: string]: any } = {} +) { const comment = removeUndefinedProps({ id: ref.id, - commentType: 'group', - groupId, userId: user.id, content: content, createdTime: Date.now(), @@ -73,11 +102,13 @@ export async function createCommentOnGroup( userUsername: user.username, userAvatarUrl: user.avatarUrl, replyToCommentId: replyToCommentId, + ...extraFields, }) - track('group message', { + + track(`${surfaceType} message`, { user, commentId: ref.id, - groupId, + surfaceId, replyToCommentId: replyToCommentId, }) return await setDoc(ref, comment) @@ -91,6 +122,10 @@ function getCommentsOnGroupCollection(groupId: string) { return collection(db, 'groups', groupId, 'comments') } +function getCommentsOnPostCollection(postId: string) { + return collection(db, 'posts', postId, 'comments') +} + export async function listAllComments(contractId: string) { return await getValues( query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')) @@ -103,6 +138,12 @@ export async function listAllCommentsOnGroup(groupId: string) { ) } +export async function listAllCommentsOnPost(postId: string) { + return await getValues( + query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')) + ) +} + export function listenForCommentsOnContract( contractId: string, setComments: (comments: ContractComment[]) => void @@ -126,6 +167,16 @@ export function listenForCommentsOnGroup( ) } +export function listenForCommentsOnPost( + postId: string, + setComments: (comments: PostComment[]) => void +) { + return listenForValues( + query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')), + setComments + ) +} + const DAY_IN_MS = 24 * 60 * 60 * 1000 // Define "recent" as "<3 days ago" for now diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 88ab1352..141217e4 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) => where('data.groupId', '==', groupId) ) +const getTipsOnPostQuery = (postId: string) => + query( + txns, + where('category', '==', 'TIP'), + where('data.postId', '==', postId) + ) + export function listenForTipTxns( contractId: string, setTxns: (txns: TipTxn[]) => void @@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup( return listenForValues(getTipsOnGroupQuery(groupId), setTxns) } +export function listenForTipTxnsOnPost( + postId: string, + setTxns: (txns: TipTxn[]) => void +) { + return listenForValues(getTipsOnPostQuery(postId), setTxns) +} + // Find all manalink Txns that are from or to this user export function useManalinkTxns(userId: string) { const [fromTxns, setFromTxns] = useState([]) diff --git a/web/pages/post/[...slugs]/index.tsx b/web/pages/post/[...slugs]/index.tsx index 737e025f..28d02488 100644 --- a/web/pages/post/[...slugs]/index.tsx +++ b/web/pages/post/[...slugs]/index.tsx @@ -16,17 +16,25 @@ import { Col } from 'web/components/layout/col' import { ENV_CONFIG } from 'common/envs/constants' import Custom404 from 'web/pages/404' import { UserLink } from 'web/components/user-link' +import { listAllCommentsOnPost } from 'web/lib/firebase/comments' +import { PostComment } from 'common/comment' +import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' +import { groupBy, sortBy } from 'lodash' +import { PostCommentThread, CommentInput } from 'web/posts/post-comments' +import { useCommentsOnPost } from 'web/hooks/use-comments' export async function getStaticProps(props: { params: { slugs: string[] } }) { const { slugs } = props.params const post = await getPostBySlug(slugs[0]) const creator = post ? await getUser(post.creatorId) : null + const comments = post && (await listAllCommentsOnPost(post.id)) return { props: { post: post, creator: creator, + comments: comments, }, revalidate: 60, // regenerate after a minute @@ -37,15 +45,22 @@ export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } -export default function PostPage(props: { post: Post; creator: User }) { +export default function PostPage(props: { + post: Post + creator: User + comments: PostComment[] +}) { const [isShareOpen, setShareOpen] = useState(false) + const tips = useTipTxns({ postId: props.post.id }) + const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}` + const updatedComments = useCommentsOnPost(props.post.id) + const comments = updatedComments ?? props.comments + if (props.post == null) { return } - const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}` - return (
@@ -91,7 +106,56 @@ export default function PostPage(props: { post: Post; creator: User }) {
+ + +
+ +
) } + +export function PostCommentsActivity(props: { + post: Post + comments: PostComment[] + tips: CommentTipMap + user: User | null | undefined +}) { + const { post, comments, user, tips } = props + const commentsByUserId = groupBy(comments, (c) => c.userId) + const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') + const topLevelComments = sortBy( + commentsByParentId['_'] ?? [], + (c) => -c.createdTime + ) + + return ( + <> + + {topLevelComments.map((parent) => ( + c.createdTime + )} + tips={tips} + commentsByUserId={commentsByUserId} + /> + ))} + + ) +} diff --git a/web/posts/post-comments.tsx b/web/posts/post-comments.tsx new file mode 100644 index 00000000..57ca0eec --- /dev/null +++ b/web/posts/post-comments.tsx @@ -0,0 +1,216 @@ +import { track } from '@amplitude/analytics-browser' +import clsx from 'clsx' +import { PostComment } from 'common/comment' +import { Post } from 'common/post' +import { User } from 'common/user' +import { Dictionary } from 'lodash' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { Avatar } from 'web/components/avatar' +import { Content, useTextEditor } from 'web/components/editor' +import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' +import { CommentInputTextArea } from 'web/components/feed/feed-comments' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Tipper } from 'web/components/tipper' +import { UserLink } from 'web/components/user-link' +import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' +import { useUser } from 'web/hooks/use-user' +import { + createCommentOnPost, + MAX_COMMENT_LENGTH, +} from 'web/lib/firebase/comments' +import { firebaseLogin } from 'web/lib/firebase/users' + +export function PostCommentThread(props: { + user: User | null | undefined + post: Post + threadComments: PostComment[] + tips: CommentTipMap + parentComment: PostComment + commentsByUserId: Dictionary +}) { + const { user, post, threadComments, commentsByUserId, tips, parentComment } = + props + const [showReply, setShowReply] = useState(false) + const [replyTo, setReplyTo] = useState<{ id: string; username: string }>() + + function scrollAndOpenReplyInput(comment: PostComment) { + setReplyTo({ id: comment.userId, username: comment.userUsername }) + setShowReply(true) + } + + return ( + + sdafasdfadsf +