diff --git a/common/comment.ts b/common/comment.ts index 3a4bd9ac..7ecbb6d4 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. @@ -20,7 +20,7 @@ export type Comment = { 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 export type GroupComment = Comment +export type PostComment = Comment diff --git a/firestore.rules b/firestore.rules index 15b60d0f..30bf0ec9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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) ; + } } } } diff --git a/web/components/comment-input.tsx b/web/components/comment-input.tsx new file mode 100644 index 00000000..1d0b3cc1 --- /dev/null +++ b/web/components/comment-input.tsx @@ -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 ( + + +
+ +
+
+ ) +} + +export function CommentInputTextArea(props: { + user: User | undefined | null + replyToUser?: { id: string; username: string } + editor: Editor | null + upload: Parameters[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 ( + <> + + {user && !isSubmitting && ( + + )} + + {isSubmitting && ( + + )} + + + {!user && ( + + )} + + + ) +} diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 0878e570..55b8a958 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -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 ( <> -