Adds comments to posts (#844)

* Adds comments to posts

* Uncoupled CommentInput from Contracts

* Fix nits
This commit is contained in:
FRC 2022-09-07 23:09:20 +01:00 committed by GitHub
parent ce52f21ce9
commit 0acdec787d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 635 additions and 238 deletions

View File

@ -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.
@ -20,7 +20,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userAvatarUrl?: string
} & T
type OnContract = {
export type OnContract = {
commentType: 'contract'
contractId: string
answerOutcome?: string
@ -35,10 +35,16 @@ type OnContract = {
betOutcome?: string
}
type OnGroup = {
export type OnGroup = {
commentType: 'group'
groupId: string
}
export type OnPost = {
commentType: 'post'
postId: string
}
export type ContractComment = Comment<OnContract>
export type GroupComment = Comment<OnGroup>
export type PostComment = Comment<OnPost>

View File

@ -203,6 +203,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) ;
}
}
}
}

View File

@ -0,0 +1,175 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react'
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: {
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
setIsSubmitting(false)
}
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
import { FeedCommentThread, CommentInput } from './feed-comments'
import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
return (
<>
<CommentInput
<ContractCommentInput
className="mb-5"
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}

View File

@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import {
CommentInput,
ContractCommentInput,
FeedComment,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
@ -177,7 +177,7 @@ export function FeedAnswerCommentGroup(props: {
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput
<ContractCommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}

View File

@ -13,22 +13,18 @@ import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { firebaseLogin } from 'web/lib/firebase/users'
import {
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import { createCommentOnContract } from 'web/lib/firebase/comments'
import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, TextEditor, useTextEditor } from '../editor'
import { Content } from '../editor'
import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: {
user: User | null | undefined
@ -90,14 +86,16 @@ export function FeedCommentThread(props: {
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput
<ContractCommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id}
replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => setShowReply(false)}
onSubmitComment={() => {
setShowReply(false)
}}
/>
</Col>
)}
@ -267,67 +265,76 @@ function CommentStatus(props: {
)
}
//TODO: move commentinput and comment input text area into their own files
export function CommentInput(props: {
export function ContractCommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[]
className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: () => void
}) {
const {
contract,
betsByCurrentUser,
commentsByCurrentUser,
className,
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
user,
parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
async function submitComment(betId: string | undefined) {
async function onSubmitComment(editor: Editor, betId: string | undefined) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract(
contract.id,
props.contract.id,
editor.getJSON(),
user,
betId,
parentAnswerOutcome,
parentCommentId
props.parentAnswerOutcome,
props.parentCommentId
)
onSubmitComment?.()
setIsSubmitting(false)
props.onSubmitComment?.()
}
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<Col>
<CommentBetArea
betsByCurrentUser={props.betsByCurrentUser}
contract={props.contract}
commentsByCurrentUser={props.commentsByCurrentUser}
parentAnswerOutcome={props.parentAnswerOutcome}
user={useUser()}
className={props.className}
mostRecentCommentableBet={mostRecentCommentableBet}
/>
<CommentInput
replyToUser={props.replyToUser}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
</Col>
)
}
function CommentBetArea(props: {
betsByCurrentUser: Bet[]
contract: Contract
commentsByCurrentUser: ContractComment[]
parentAnswerOutcome?: string
user?: User | null
className?: string
mostRecentCommentableBet?: Bet
}) {
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract,
Date.now(),
@ -336,26 +343,15 @@ export function CommentInput(props: {
const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<div className="mb-1 text-gray-500">
{mostRecentCommentableBet && (
<BetStatusText
contract={contract}
bet={mostRecentCommentableBet}
isSelf={true}
hideOutcome={
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
}
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
/>
)}
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
@ -373,121 +369,10 @@ export function CommentInput(props: {
</>
)}
</div>
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={id}
/>
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}
function getBettorsLargestPositionBeforeTime(
contract: Contract,
createdTime: number,

View File

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

View File

@ -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<PostComment[] | undefined>()
useEffect(() => {
if (postId) return listenForCommentsOnPost(postId, setComments)
}, [postId])
return comments
}
export const useRecentComments = () => {
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
useEffect(() => listenForRecentComments(setRecentComments), [])

View File

@ -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<TipTxn[]>([])
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')

View File

@ -7,12 +7,22 @@ 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,
OnContract,
OnGroup,
OnPost,
PostComment,
} from 'common/comment'
import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser'
import { JSONContent } from '@tiptap/react'
@ -24,7 +34,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 +42,20 @@ 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,
const onContract = {
commentType: 'contract',
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', {
betId,
answerOutcome,
} as OnContract
return await createComment(
contractId,
commentId: ref.id,
betId: betId,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment)
onContract,
content,
user,
ref,
replyToCommentId
)
}
export async function createCommentOnGroup(
groupId: string,
@ -62,10 +64,45 @@ export async function createCommentOnGroup(
replyToCommentId?: string
) {
const ref = doc(getCommentsOnGroupCollection(groupId))
const onGroup = { commentType: 'group', groupId: groupId } as OnGroup
return await createComment(
groupId,
onGroup,
content,
user,
ref,
replyToCommentId
)
}
export async function createCommentOnPost(
postId: string,
content: JSONContent,
user: User,
replyToCommentId?: string
) {
const ref = doc(getCommentsOnPostCollection(postId))
const onPost = { postId: postId, commentType: 'post' } as OnPost
return await createComment(
postId,
onPost,
content,
user,
ref,
replyToCommentId
)
}
async function createComment(
surfaceId: string,
extraFields: OnContract | OnGroup | OnPost,
content: JSONContent,
user: User,
ref: DocumentReference<DocumentData>,
replyToCommentId?: string
) {
const comment = removeUndefinedProps({
id: ref.id,
commentType: 'group',
groupId,
userId: user.id,
content: content,
createdTime: Date.now(),
@ -73,11 +110,13 @@ export async function createCommentOnGroup(
userUsername: user.username,
userAvatarUrl: user.avatarUrl,
replyToCommentId: replyToCommentId,
...extraFields,
})
track('group message', {
track(`${extraFields.commentType} message`, {
user,
commentId: ref.id,
groupId,
surfaceId,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment)
@ -91,6 +130,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<Comment>(
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
@ -103,6 +146,12 @@ export async function listAllCommentsOnGroup(groupId: string) {
)
}
export async function listAllCommentsOnPost(postId: string) {
return await getValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc'))
)
}
export function listenForCommentsOnContract(
contractId: string,
setComments: (comments: ContractComment[]) => void
@ -126,6 +175,16 @@ export function listenForCommentsOnGroup(
)
}
export function listenForCommentsOnPost(
postId: string,
setComments: (comments: PostComment[]) => void
) {
return listenForValues<PostComment>(
query(getCommentsOnPostCollection(postId), orderBy('createdTime', 'desc')),
setComments
)
}
const DAY_IN_MS = 24 * 60 * 60 * 1000
// Define "recent" as "<3 days ago" for now

View File

@ -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<TipTxn>(getTipsOnGroupQuery(groupId), setTxns)
}
export function listenForTipTxnsOnPost(
postId: string,
setTxns: (txns: TipTxn[]) => void
) {
return listenForValues<TipTxn>(getTipsOnPostQuery(postId), setTxns)
}
// Find all manalink Txns that are from or to this user
export function useManalinkTxns(userId: string) {
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])

View File

@ -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 { PostCommentInput, PostCommentThread } 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,28 +45,36 @@ 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 { post, creator } = props
if (props.post == null) {
const tips = useTipTxns({ postId: post.id })
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(post.slug)}`
const updatedComments = useCommentsOnPost(post.id)
const comments = updatedComments ?? props.comments
if (post == null) {
return <Custom404 />
}
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
return (
<Page>
<div className="mx-auto w-full max-w-3xl ">
<Spacer h={1} />
<Title className="!mt-0" text={props.post.title} />
<Title className="!mt-0" text={post.title} />
<Row>
<Col className="flex-1">
<div className={'inline-flex'}>
<div className="mr-1 text-gray-500">Created by</div>
<UserLink
className="text-neutral"
name={props.creator.name}
username={props.creator.username}
name={creator.name}
username={creator.username}
/>
</div>
</Col>
@ -88,10 +104,55 @@ export default function PostPage(props: { post: Post; creator: User }) {
<Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<div className="form-control w-full py-2">
<Content content={props.post.content} />
<Content content={post.content} />
</div>
</div>
<Spacer h={2} />
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
<PostCommentsActivity
post={post}
comments={comments}
tips={tips}
user={creator}
/>
</div>
</div>
</Page>
)
}
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 (
<>
<PostCommentInput post={post} />
{topLevelComments.map((parent) => (
<PostCommentThread
key={parent.id}
user={user}
post={post}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
commentsByUserId={commentsByUserId}
/>
))}
</>
)
}

172
web/posts/post-comments.tsx Normal file
View File

@ -0,0 +1,172 @@
import { track } from '@amplitude/analytics-browser'
import { Editor } from '@tiptap/core'
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 { CommentInput } from 'web/components/comment-input'
import { Content } from 'web/components/editor'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
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 } 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<PostComment[]>
}) {
const { post, threadComments, 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 (
<Col className="relative w-full items-stretch gap-3 pb-4">
<span
className="absolute top-5 left-4 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
{[parentComment].concat(threadComments).map((comment, commentIdx) => (
<PostComment
key={comment.id}
indent={commentIdx != 0}
post={post}
comment={comment}
tips={tips[comment.id]}
onReplyClick={scrollAndOpenReplyInput}
/>
))}
{showReply && (
<Col className="-pb-2 relative ml-6">
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<PostCommentInput
post={post}
parentCommentId={parentComment.id}
replyToUser={replyTo}
onSubmitComment={() => setShowReply(false)}
/>
</Col>
)}
</Col>
)
}
export function PostCommentInput(props: {
post: Post
parentCommentId?: string
replyToUser?: { id: string; username: string }
onSubmitComment?: () => void
}) {
const user = useUser()
const { post, parentCommentId, replyToUser } = props
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
await createCommentOnPost(post.id, editor.getJSON(), user, parentCommentId)
props.onSubmitComment?.()
}
return (
<CommentInput
replyToUser={replyToUser}
parentCommentId={parentCommentId}
onSubmitComment={onSubmitComment}
/>
)
}
export function PostComment(props: {
post: Post
comment: PostComment
tips: CommentTips
indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: PostComment) => void
}) {
const { post, comment, tips, indent, onReplyClick } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
useEffect(() => {
if (router.asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [comment.id, router.asPath])
return (
<Row
id={comment.id}
className={clsx(
'relative',
indent ? 'ml-6' : '',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
)}
>
{/*draw a gray line from the comment to the left:*/}
{indent ? (
<span
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
) : null}
<Avatar size="sm" username={userUsername} avatarUrl={userAvatarUrl} />
<div className="ml-1.5 min-w-0 flex-1 pl-0.5 sm:ml-3">
<div className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
<CopyLinkDateTimeComponent
prefix={comment.userName}
slug={post.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</div>
<Content
className="mt-2 text-[15px] text-gray-700"
content={content || text}
smallImage
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{onReplyClick && (
<button
className="font-bold hover:underline"
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</Row>
</div>
</Row>
)
}