Adds comments to posts (#844)
* Adds comments to posts * Uncoupled CommentInput from Contracts * Fix nits
This commit is contained in:
parent
ce52f21ce9
commit
0acdec787d
|
@ -1,6 +1,6 @@
|
||||||
import type { JSONContent } from '@tiptap/core'
|
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.
|
// Currently, comments are created after the bet, not atomically with the bet.
|
||||||
// They're uniquely identified by the pair contractId/betId.
|
// They're uniquely identified by the pair contractId/betId.
|
||||||
|
@ -20,7 +20,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
||||||
userAvatarUrl?: string
|
userAvatarUrl?: string
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
type OnContract = {
|
export type OnContract = {
|
||||||
commentType: 'contract'
|
commentType: 'contract'
|
||||||
contractId: string
|
contractId: string
|
||||||
answerOutcome?: string
|
answerOutcome?: string
|
||||||
|
@ -35,10 +35,16 @@ type OnContract = {
|
||||||
betOutcome?: string
|
betOutcome?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type OnGroup = {
|
export type OnGroup = {
|
||||||
commentType: 'group'
|
commentType: 'group'
|
||||||
groupId: string
|
groupId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OnPost = {
|
||||||
|
commentType: 'post'
|
||||||
|
postId: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ContractComment = Comment<OnContract>
|
export type ContractComment = Comment<OnContract>
|
||||||
export type GroupComment = Comment<OnGroup>
|
export type GroupComment = Comment<OnGroup>
|
||||||
|
export type PostComment = Comment<OnPost>
|
||||||
|
|
|
@ -203,6 +203,10 @@ service cloud.firestore {
|
||||||
.affectedKeys()
|
.affectedKeys()
|
||||||
.hasOnly(['name', 'content']);
|
.hasOnly(['name', 'content']);
|
||||||
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
|
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) ;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
175
web/components/comment-input.tsx
Normal file
175
web/components/comment-input.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
|
||||||
import { FeedBet } from './feed-bets'
|
import { FeedBet } from './feed-bets'
|
||||||
import { FeedLiquidity } from './feed-liquidity'
|
import { FeedLiquidity } from './feed-liquidity'
|
||||||
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
|
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 { User } from 'common/user'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||||
|
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CommentInput
|
<ContractCommentInput
|
||||||
className="mb-5"
|
className="mb-5"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
CommentInput,
|
ContractCommentInput,
|
||||||
FeedComment,
|
FeedComment,
|
||||||
getMostRecentCommentableBet,
|
getMostRecentCommentableBet,
|
||||||
} from 'web/components/feed/feed-comments'
|
} 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"
|
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<CommentInput
|
<ContractCommentInput
|
||||||
contract={contract}
|
contract={contract}
|
||||||
betsByCurrentUser={betsByCurrentUser}
|
betsByCurrentUser={betsByCurrentUser}
|
||||||
commentsByCurrentUser={commentsByCurrentUser}
|
commentsByCurrentUser={commentsByCurrentUser}
|
||||||
|
|
|
@ -13,22 +13,18 @@ import { Avatar } from 'web/components/avatar'
|
||||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import {
|
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
||||||
createCommentOnContract,
|
|
||||||
MAX_COMMENT_LENGTH,
|
|
||||||
} from 'web/lib/firebase/comments'
|
|
||||||
import { BetStatusText } from 'web/components/feed/feed-bets'
|
import { BetStatusText } from 'web/components/feed/feed-bets'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { getProbability } from 'common/calculate'
|
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 { track } from 'web/lib/service/analytics'
|
||||||
import { Tipper } from '../tipper'
|
import { Tipper } from '../tipper'
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
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 { Editor } from '@tiptap/react'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
import { CommentInput } from '../comment-input'
|
||||||
|
|
||||||
export function FeedCommentThread(props: {
|
export function FeedCommentThread(props: {
|
||||||
user: User | null | undefined
|
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"
|
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<CommentInput
|
<ContractCommentInput
|
||||||
contract={contract}
|
contract={contract}
|
||||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||||
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
|
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
|
||||||
parentCommentId={parentComment.id}
|
parentCommentId={parentComment.id}
|
||||||
replyToUser={replyTo}
|
replyToUser={replyTo}
|
||||||
parentAnswerOutcome={parentComment.answerOutcome}
|
parentAnswerOutcome={parentComment.answerOutcome}
|
||||||
onSubmitComment={() => setShowReply(false)}
|
onSubmitComment={() => {
|
||||||
|
setShowReply(false)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
@ -267,67 +265,76 @@ function CommentStatus(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: move commentinput and comment input text area into their own files
|
export function ContractCommentInput(props: {
|
||||||
export function CommentInput(props: {
|
|
||||||
contract: Contract
|
contract: Contract
|
||||||
betsByCurrentUser: Bet[]
|
betsByCurrentUser: Bet[]
|
||||||
commentsByCurrentUser: ContractComment[]
|
commentsByCurrentUser: ContractComment[]
|
||||||
className?: string
|
className?: string
|
||||||
|
parentAnswerOutcome?: string | undefined
|
||||||
replyToUser?: { id: string; username: string }
|
replyToUser?: { id: string; username: string }
|
||||||
// Reply to a free response answer
|
|
||||||
parentAnswerOutcome?: string
|
|
||||||
// Reply to another comment
|
|
||||||
parentCommentId?: string
|
parentCommentId?: string
|
||||||
onSubmitComment?: () => void
|
onSubmitComment?: () => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
|
||||||
contract,
|
|
||||||
betsByCurrentUser,
|
|
||||||
commentsByCurrentUser,
|
|
||||||
className,
|
|
||||||
parentAnswerOutcome,
|
|
||||||
parentCommentId,
|
|
||||||
replyToUser,
|
|
||||||
onSubmitComment,
|
|
||||||
} = props
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const { editor, upload } = useTextEditor({
|
async function onSubmitComment(editor: Editor, betId: string | undefined) {
|
||||||
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) {
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
track('sign in to comment')
|
track('sign in to comment')
|
||||||
return await firebaseLogin()
|
return await firebaseLogin()
|
||||||
}
|
}
|
||||||
if (!editor || editor.isEmpty || isSubmitting) return
|
|
||||||
setIsSubmitting(true)
|
|
||||||
await createCommentOnContract(
|
await createCommentOnContract(
|
||||||
contract.id,
|
props.contract.id,
|
||||||
editor.getJSON(),
|
editor.getJSON(),
|
||||||
user,
|
user,
|
||||||
betId,
|
betId,
|
||||||
parentAnswerOutcome,
|
props.parentAnswerOutcome,
|
||||||
parentCommentId
|
props.parentCommentId
|
||||||
)
|
)
|
||||||
onSubmitComment?.()
|
props.onSubmitComment?.()
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
|
||||||
contract,
|
contract,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
@ -336,158 +343,36 @@ export function CommentInput(props: {
|
||||||
|
|
||||||
const isNumeric = contract.outcomeType === 'NUMERIC'
|
const isNumeric = contract.outcomeType === 'NUMERIC'
|
||||||
|
|
||||||
if (user?.isBannedFromPosting) return <></>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}>
|
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
|
||||||
<Avatar
|
<div className="mb-1 text-gray-500">
|
||||||
avatarUrl={user?.avatarUrl}
|
{mostRecentCommentableBet && (
|
||||||
username={user?.username}
|
<BetStatusText
|
||||||
size="sm"
|
contract={contract}
|
||||||
className="mt-2"
|
bet={mostRecentCommentableBet}
|
||||||
/>
|
isSelf={true}
|
||||||
<div className="min-w-0 flex-1 pl-0.5 text-sm">
|
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
|
||||||
<div className="mb-1 text-gray-500">
|
/>
|
||||||
{mostRecentCommentableBet && (
|
)}
|
||||||
<BetStatusText
|
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
||||||
|
<>
|
||||||
|
{"You're"}
|
||||||
|
<CommentStatus
|
||||||
|
outcome={outcome}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bet={mostRecentCommentableBet}
|
prob={
|
||||||
isSelf={true}
|
contract.outcomeType === 'BINARY'
|
||||||
hideOutcome={
|
? getProbability(contract)
|
||||||
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
)}
|
||||||
<>
|
|
||||||
{"You're"}
|
|
||||||
<CommentStatus
|
|
||||||
outcome={outcome}
|
|
||||||
contract={contract}
|
|
||||||
prob={
|
|
||||||
contract.outcomeType === 'BINARY'
|
|
||||||
? getProbability(contract)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CommentInputTextArea
|
|
||||||
editor={editor}
|
|
||||||
upload={upload}
|
|
||||||
replyToUser={replyToUser}
|
|
||||||
user={user}
|
|
||||||
submitComment={submitComment}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
presetId={id}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</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(
|
function getBettorsLargestPositionBeforeTime(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
createdTime: number,
|
createdTime: number,
|
||||||
|
|
|
@ -46,6 +46,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
comment.commentType === 'contract' ? comment.contractId : undefined
|
comment.commentType === 'contract' ? comment.contractId : undefined
|
||||||
const groupId =
|
const groupId =
|
||||||
comment.commentType === 'group' ? comment.groupId : undefined
|
comment.commentType === 'group' ? comment.groupId : undefined
|
||||||
|
const postId = comment.commentType === 'post' ? comment.postId : undefined
|
||||||
await transact({
|
await transact({
|
||||||
amount: change,
|
amount: change,
|
||||||
fromId: user.id,
|
fromId: user.id,
|
||||||
|
@ -54,7 +55,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
toType: 'USER',
|
toType: 'USER',
|
||||||
token: 'M$',
|
token: 'M$',
|
||||||
category: 'TIP',
|
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`,
|
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,
|
commentId: comment.id,
|
||||||
contractId,
|
contractId,
|
||||||
groupId,
|
groupId,
|
||||||
|
postId,
|
||||||
amount: change,
|
amount: change,
|
||||||
fromId: user.id,
|
fromId: user.id,
|
||||||
toId: comment.userId,
|
toId: comment.userId,
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Comment, ContractComment, GroupComment } from 'common/comment'
|
import {
|
||||||
|
Comment,
|
||||||
|
ContractComment,
|
||||||
|
GroupComment,
|
||||||
|
PostComment,
|
||||||
|
} from 'common/comment'
|
||||||
import {
|
import {
|
||||||
listenForCommentsOnContract,
|
listenForCommentsOnContract,
|
||||||
listenForCommentsOnGroup,
|
listenForCommentsOnGroup,
|
||||||
|
listenForCommentsOnPost,
|
||||||
listenForRecentComments,
|
listenForRecentComments,
|
||||||
} from 'web/lib/firebase/comments'
|
} from 'web/lib/firebase/comments'
|
||||||
|
|
||||||
|
@ -25,6 +31,16 @@ export const useCommentsOnGroup = (groupId: string | undefined) => {
|
||||||
return comments
|
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 = () => {
|
export const useRecentComments = () => {
|
||||||
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
|
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
|
||||||
useEffect(() => listenForRecentComments(setRecentComments), [])
|
useEffect(() => listenForRecentComments(setRecentComments), [])
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
listenForTipTxns,
|
listenForTipTxns,
|
||||||
listenForTipTxnsOnGroup,
|
listenForTipTxnsOnGroup,
|
||||||
|
listenForTipTxnsOnPost,
|
||||||
} from 'web/lib/firebase/txns'
|
} from 'web/lib/firebase/txns'
|
||||||
|
|
||||||
export type CommentTips = { [userId: string]: number }
|
export type CommentTips = { [userId: string]: number }
|
||||||
|
@ -12,14 +13,16 @@ export type CommentTipMap = { [commentId: string]: CommentTips }
|
||||||
export function useTipTxns(on: {
|
export function useTipTxns(on: {
|
||||||
contractId?: string
|
contractId?: string
|
||||||
groupId?: string
|
groupId?: string
|
||||||
|
postId?: string
|
||||||
}): CommentTipMap {
|
}): CommentTipMap {
|
||||||
const [txns, setTxns] = useState<TipTxn[]>([])
|
const [txns, setTxns] = useState<TipTxn[]>([])
|
||||||
const { contractId, groupId } = on
|
const { contractId, groupId, postId } = on
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contractId) return listenForTipTxns(contractId, setTxns)
|
if (contractId) return listenForTipTxns(contractId, setTxns)
|
||||||
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
|
if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
|
||||||
}, [contractId, groupId, setTxns])
|
if (postId) return listenForTipTxnsOnPost(postId, setTxns)
|
||||||
|
}, [contractId, groupId, postId, setTxns])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const byComment = groupBy(txns, 'data.commentId')
|
const byComment = groupBy(txns, 'data.commentId')
|
||||||
|
|
|
@ -7,12 +7,22 @@ import {
|
||||||
query,
|
query,
|
||||||
setDoc,
|
setDoc,
|
||||||
where,
|
where,
|
||||||
|
DocumentData,
|
||||||
|
DocumentReference,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
|
|
||||||
import { getValues, listenForValues } from './utils'
|
import { getValues, listenForValues } from './utils'
|
||||||
import { db } from './init'
|
import { db } from './init'
|
||||||
import { User } from 'common/user'
|
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 { removeUndefinedProps } from 'common/util/object'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { JSONContent } from '@tiptap/react'
|
import { JSONContent } from '@tiptap/react'
|
||||||
|
@ -24,7 +34,7 @@ export const MAX_COMMENT_LENGTH = 10000
|
||||||
export async function createCommentOnContract(
|
export async function createCommentOnContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
content: JSONContent,
|
content: JSONContent,
|
||||||
commenter: User,
|
user: User,
|
||||||
betId?: string,
|
betId?: string,
|
||||||
answerOutcome?: string,
|
answerOutcome?: string,
|
||||||
replyToCommentId?: string
|
replyToCommentId?: string
|
||||||
|
@ -32,28 +42,20 @@ export async function createCommentOnContract(
|
||||||
const ref = betId
|
const ref = betId
|
||||||
? doc(getCommentsCollection(contractId), betId)
|
? doc(getCommentsCollection(contractId), betId)
|
||||||
: doc(getCommentsCollection(contractId))
|
: doc(getCommentsCollection(contractId))
|
||||||
// contract slug and question are set via trigger
|
const onContract = {
|
||||||
const comment = removeUndefinedProps({
|
|
||||||
id: ref.id,
|
|
||||||
commentType: 'contract',
|
commentType: 'contract',
|
||||||
contractId,
|
contractId,
|
||||||
userId: commenter.id,
|
betId,
|
||||||
content: content,
|
answerOutcome,
|
||||||
createdTime: Date.now(),
|
} as OnContract
|
||||||
userName: commenter.name,
|
return await createComment(
|
||||||
userUsername: commenter.username,
|
|
||||||
userAvatarUrl: commenter.avatarUrl,
|
|
||||||
betId: betId,
|
|
||||||
answerOutcome: answerOutcome,
|
|
||||||
replyToCommentId: replyToCommentId,
|
|
||||||
})
|
|
||||||
track('comment', {
|
|
||||||
contractId,
|
contractId,
|
||||||
commentId: ref.id,
|
onContract,
|
||||||
betId: betId,
|
content,
|
||||||
replyToCommentId: replyToCommentId,
|
user,
|
||||||
})
|
ref,
|
||||||
return await setDoc(ref, comment)
|
replyToCommentId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
export async function createCommentOnGroup(
|
export async function createCommentOnGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
@ -62,10 +64,45 @@ export async function createCommentOnGroup(
|
||||||
replyToCommentId?: string
|
replyToCommentId?: string
|
||||||
) {
|
) {
|
||||||
const ref = doc(getCommentsOnGroupCollection(groupId))
|
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({
|
const comment = removeUndefinedProps({
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
commentType: 'group',
|
|
||||||
groupId,
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
content: content,
|
content: content,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
|
@ -73,11 +110,13 @@ export async function createCommentOnGroup(
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
userAvatarUrl: user.avatarUrl,
|
userAvatarUrl: user.avatarUrl,
|
||||||
replyToCommentId: replyToCommentId,
|
replyToCommentId: replyToCommentId,
|
||||||
|
...extraFields,
|
||||||
})
|
})
|
||||||
track('group message', {
|
|
||||||
|
track(`${extraFields.commentType} message`, {
|
||||||
user,
|
user,
|
||||||
commentId: ref.id,
|
commentId: ref.id,
|
||||||
groupId,
|
surfaceId,
|
||||||
replyToCommentId: replyToCommentId,
|
replyToCommentId: replyToCommentId,
|
||||||
})
|
})
|
||||||
return await setDoc(ref, comment)
|
return await setDoc(ref, comment)
|
||||||
|
@ -91,6 +130,10 @@ function getCommentsOnGroupCollection(groupId: string) {
|
||||||
return collection(db, 'groups', groupId, 'comments')
|
return collection(db, 'groups', groupId, 'comments')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCommentsOnPostCollection(postId: string) {
|
||||||
|
return collection(db, 'posts', postId, 'comments')
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAllComments(contractId: string) {
|
export async function listAllComments(contractId: string) {
|
||||||
return await getValues<Comment>(
|
return await getValues<Comment>(
|
||||||
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
|
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(
|
export function listenForCommentsOnContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setComments: (comments: ContractComment[]) => void
|
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
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
// Define "recent" as "<3 days ago" for now
|
// Define "recent" as "<3 days ago" for now
|
||||||
|
|
|
@ -41,6 +41,13 @@ const getTipsOnGroupQuery = (groupId: string) =>
|
||||||
where('data.groupId', '==', groupId)
|
where('data.groupId', '==', groupId)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getTipsOnPostQuery = (postId: string) =>
|
||||||
|
query(
|
||||||
|
txns,
|
||||||
|
where('category', '==', 'TIP'),
|
||||||
|
where('data.postId', '==', postId)
|
||||||
|
)
|
||||||
|
|
||||||
export function listenForTipTxns(
|
export function listenForTipTxns(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setTxns: (txns: TipTxn[]) => void
|
setTxns: (txns: TipTxn[]) => void
|
||||||
|
@ -54,6 +61,13 @@ export function listenForTipTxnsOnGroup(
|
||||||
return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns)
|
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
|
// Find all manalink Txns that are from or to this user
|
||||||
export function useManalinkTxns(userId: string) {
|
export function useManalinkTxns(userId: string) {
|
||||||
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])
|
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])
|
||||||
|
|
|
@ -16,17 +16,25 @@ import { Col } from 'web/components/layout/col'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import Custom404 from 'web/pages/404'
|
import Custom404 from 'web/pages/404'
|
||||||
import { UserLink } from 'web/components/user-link'
|
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[] } }) {
|
export async function getStaticProps(props: { params: { slugs: string[] } }) {
|
||||||
const { slugs } = props.params
|
const { slugs } = props.params
|
||||||
|
|
||||||
const post = await getPostBySlug(slugs[0])
|
const post = await getPostBySlug(slugs[0])
|
||||||
const creator = post ? await getUser(post.creatorId) : null
|
const creator = post ? await getUser(post.creatorId) : null
|
||||||
|
const comments = post && (await listAllCommentsOnPost(post.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
post: post,
|
post: post,
|
||||||
creator: creator,
|
creator: creator,
|
||||||
|
comments: comments,
|
||||||
},
|
},
|
||||||
|
|
||||||
revalidate: 60, // regenerate after a minute
|
revalidate: 60, // regenerate after a minute
|
||||||
|
@ -37,28 +45,36 @@ export async function getStaticPaths() {
|
||||||
return { paths: [], fallback: 'blocking' }
|
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 [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 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className="mx-auto w-full max-w-3xl ">
|
<div className="mx-auto w-full max-w-3xl ">
|
||||||
<Spacer h={1} />
|
<Spacer h={1} />
|
||||||
<Title className="!mt-0" text={props.post.title} />
|
<Title className="!mt-0" text={post.title} />
|
||||||
<Row>
|
<Row>
|
||||||
<Col className="flex-1">
|
<Col className="flex-1">
|
||||||
<div className={'inline-flex'}>
|
<div className={'inline-flex'}>
|
||||||
<div className="mr-1 text-gray-500">Created by</div>
|
<div className="mr-1 text-gray-500">Created by</div>
|
||||||
<UserLink
|
<UserLink
|
||||||
className="text-neutral"
|
className="text-neutral"
|
||||||
name={props.creator.name}
|
name={creator.name}
|
||||||
username={props.creator.username}
|
username={creator.username}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -88,10 +104,55 @@ export default function PostPage(props: { post: Post; creator: User }) {
|
||||||
<Spacer h={2} />
|
<Spacer h={2} />
|
||||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||||
<div className="form-control w-full py-2">
|
<div className="form-control w-full py-2">
|
||||||
<Content content={props.post.content} />
|
<Content content={post.content} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Page>
|
</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
172
web/posts/post-comments.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user