Switch comments/chat to rich text editor (#703)

* Switch comments/chat to rich text editor

* Remove TruncatedComment

* Re-add submit on enter

* Insert at mention on reply

* Update editor style for send button

* only submit on enter in chat

* code review: refactor

* use more specific type for upload

* fix ESlint and errors from merge

* fix trigger on every render eslint warning

* Notify people mentioned in comment

* fix type errors
This commit is contained in:
Sinclair Chen 2022-08-04 16:34:04 -07:00 committed by GitHub
parent edae709f5f
commit f52da72115
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 196 additions and 266 deletions

View File

@ -1,3 +1,5 @@
import type { JSONContent } from '@tiptap/core'
// 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.
export type Comment = { export type Comment = {
@ -9,7 +11,9 @@ export type Comment = {
replyToCommentId?: string replyToCommentId?: string
userId: string userId: string
text: string /** @deprecated - content now stored as JSON in content*/
text?: string
content: JSONContent
createdTime: number createdTime: number
// Denormalized, for rendering comments // Denormalized, for rendering comments

View File

@ -7,7 +7,7 @@ import {
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getUserByUsername, getValues } from './utils' import { getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
@ -17,6 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn' import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge' import { Challenge } from '../../common/challenge'
import { richTextToString } from 'common/util/parse'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type user_to_reason_texts = {
@ -155,17 +156,6 @@ export const createNotification = async (
} }
} }
/** @deprecated parse from rich text instead */
const parseMentions = async (source: string) => {
const mentions = source.match(/@\w+/g)
if (!mentions) return []
return Promise.all(
mentions.map(
async (username) => (await getUserByUsername(username.slice(1)))?.id
)
)
}
const notifyTaggedUsers = ( const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[] userIds: (string | undefined)[]
@ -301,8 +291,7 @@ export const createNotification = async (
if (sourceType === 'comment') { if (sourceType === 'comment') {
if (recipients?.[0] && relatedSourceType) if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
if (sourceText) if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText))
} }
await notifyContractCreator(userToReasonTexts, sourceContract) await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
@ -427,7 +416,7 @@ export const createGroupCommentNotification = async (
sourceUserName: fromUser.name, sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username, sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl, sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: comment.text, sourceText: richTextToString(comment.content),
sourceSlug, sourceSlug,
sourceTitle: `${group.name}`, sourceTitle: `${group.name}`,
isSeenOnHref: sourceSlug, isSeenOnHref: sourceSlug,

View File

@ -17,6 +17,7 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email' import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api' import { getFunctionUrl } from '../../common/api'
import { richTextToString } from 'common/util/parse'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
@ -291,7 +292,8 @@ export const sendNewCommentEmail = async (
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { text } = comment const { content } = comment
const text = richTextToString(content)
let betDescription = '' let betDescription = ''
if (bet) { if (bet) {

View File

@ -1,13 +1,13 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { uniq } from 'lodash' import { compact, uniq } from 'lodash'
import { getContract, getUser, getValues } from './utils' import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { createNotification } from './create-notification' import { createNotification } from './create-notification'
import { parseMentions, richTextToString } from 'common/util/parse'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -71,7 +71,10 @@ export const onCreateCommentOnContract = functions
const repliedUserId = comment.replyToCommentId const repliedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId : answer?.userId
const recipients = repliedUserId ? [repliedUserId] : []
const recipients = uniq(
compact([...parseMentions(comment.content), repliedUserId])
)
await createNotification( await createNotification(
comment.id, comment.id,
@ -79,7 +82,7 @@ export const onCreateCommentOnContract = functions
'created', 'created',
commentCreator, commentCreator,
eventId, eventId,
comment.text, richTextToString(comment.content),
{ contract, relatedSourceType, recipients } { contract, relatedSourceType, recipients }
) )

View File

@ -8,8 +8,8 @@ import { RelativeTimestamp } from './relative-timestamp'
import { UserLink } from './user-page' import { UserLink } from './user-page'
import { User } from 'common/user' import { User } from 'common/user'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Linkify } from './linkify'
import { groupBy } from 'lodash' import { groupBy } from 'lodash'
import { Content } from './editor'
export function UserCommentsList(props: { export function UserCommentsList(props: {
user: User user: User
@ -50,7 +50,8 @@ export function UserCommentsList(props: {
function ProfileComment(props: { comment: Comment; className?: string }) { function ProfileComment(props: { comment: Comment; className?: string }) {
const { comment, className } = props const { comment, className } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
// TODO: find and attach relevant bets by comment betId at some point // TODO: find and attach relevant bets by comment betId at some point
return ( return (
<Row className={className}> <Row className={className}>
@ -64,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) {
/>{' '} />{' '}
<RelativeTimestamp time={createdTime} /> <RelativeTimestamp time={createdTime} />
</p> </p>
<Linkify text={text} /> <Content content={content || text} />
</div> </div>
</Row> </Row>
) )

View File

@ -107,7 +107,6 @@ export function ContractTopTrades(props: {
comment={commentsById[topCommentId]} comment={commentsById[topCommentId]}
tips={tips[topCommentId]} tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]} betsBySameUser={[betsById[topCommentId]]}
truncate={false}
smallAvatar={false} smallAvatar={false}
/> />
</div> </div>

View File

@ -41,14 +41,16 @@ export function useTextEditor(props: {
max?: number max?: number
defaultValue?: Content defaultValue?: Content
disabled?: boolean disabled?: boolean
simple?: boolean
}) { }) {
const { placeholder, max, defaultValue = '', disabled } = props const { placeholder, max, defaultValue = '', disabled, simple } = props
const users = useUsers() const users = useUsers()
const editorClass = clsx( const editorClass = clsx(
proseClass, proseClass,
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' !simple && 'min-h-[6em]',
'outline-none pt-2 px-4'
) )
const editor = useEditor( const editor = useEditor(
@ -56,7 +58,8 @@ export function useTextEditor(props: {
editorProps: { attributes: { class: editorClass } }, editorProps: { attributes: { class: editorClass } },
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
heading: { levels: [1, 2, 3] }, heading: simple ? false : { levels: [1, 2, 3] },
horizontalRule: simple ? false : {},
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder, placeholder,
@ -120,8 +123,9 @@ function isValidIframe(text: string) {
export function TextEditor(props: { export function TextEditor(props: {
editor: Editor | null editor: Editor | null
upload: ReturnType<typeof useUploadMutation> upload: ReturnType<typeof useUploadMutation>
children?: React.ReactNode // additional toolbar buttons
}) { }) {
const { editor, upload } = props const { editor, upload, children } = props
const [iframeOpen, setIframeOpen] = useState(false) const [iframeOpen, setIframeOpen] = useState(false)
return ( return (
@ -143,20 +147,10 @@ export function TextEditor(props: {
images! images!
</FloatingMenu> </FloatingMenu>
)} )}
<div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> <div className="rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{/* Spacer element to match the height of the toolbar */} {/* Toolbar, with buttons for images and embeds */}
<div className="py-2" aria-hidden="true"> <div className="flex h-9 items-center gap-5 pl-4 pr-1">
{/* Matches height of button in toolbar (1px border + 36px content height) */}
<div className="py-px">
<div className="h-9" />
</div>
</div>
</div>
{/* Toolbar, with buttons for image and embeds */}
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
<div className="flex items-center space-x-5">
<div className="flex items-center"> <div className="flex items-center">
<FileUploadButton <FileUploadButton
onFiles={upload.mutate} onFiles={upload.mutate}
@ -181,6 +175,8 @@ export function TextEditor(props: {
<span className="sr-only">Embed an iframe</span> <span className="sr-only">Embed an iframe</span>
</button> </button>
</div> </div>
<div className="ml-auto" />
{children}
</div> </div>
</div> </div>
</div> </div>

View File

@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: {
const { answer, contract, comments, tips, bets, user } = props const { answer, contract, comments, tips, bets, user } = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyToUsername, setReplyToUsername] = useState('') const [replyToUser, setReplyToUser] =
useState<Pick<User, 'id' | 'username'>>()
const [showReply, setShowReply] = useState(false) const [showReply, setShowReply] = useState(false)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [highlighted, setHighlighted] = useState(false) const [highlighted, setHighlighted] = useState(false)
const router = useRouter() const router = useRouter()
@ -70,9 +70,14 @@ export function FeedAnswerCommentGroup(props: {
const scrollAndOpenReplyInput = useEvent( const scrollAndOpenReplyInput = useEvent(
(comment?: Comment, answer?: Answer) => { (comment?: Comment, answer?: Answer) => {
setReplyToUsername(comment?.userUsername ?? answer?.username ?? '') setReplyToUser(
comment
? { id: comment.userId, username: comment.userUsername }
: answer
? { id: answer.userId, username: answer.username }
: undefined
)
setShowReply(true) setShowReply(true)
inputRef?.focus()
} }
) )
@ -80,7 +85,7 @@ export function FeedAnswerCommentGroup(props: {
// Only show one comment input for a bet at a time // Only show one comment input for a bet at a time
if ( if (
betsByCurrentUser.length > 1 && betsByCurrentUser.length > 1 &&
inputRef?.textContent?.length === 0 && // inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0] betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
?.outcome !== answer.number.toString() ?.outcome !== answer.number.toString()
) )
@ -89,10 +94,6 @@ export function FeedAnswerCommentGroup(props: {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [betsByCurrentUser.length, user, answer.number]) }, [betsByCurrentUser.length, user, answer.number])
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
useEffect(() => { useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) { if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true) setHighlighted(true)
@ -154,7 +155,6 @@ export function FeedAnswerCommentGroup(props: {
commentsList={commentsList} commentsList={commentsList}
betsByUserId={betsByUserId} betsByUserId={betsByUserId}
smallAvatar={true} smallAvatar={true}
truncate={false}
bets={bets} bets={bets}
tips={tips} tips={tips}
scrollAndOpenReplyInput={scrollAndOpenReplyInput} scrollAndOpenReplyInput={scrollAndOpenReplyInput}
@ -172,12 +172,8 @@ export function FeedAnswerCommentGroup(props: {
betsByCurrentUser={betsByCurrentUser} betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser}
parentAnswerOutcome={answer.number.toString()} parentAnswerOutcome={answer.number.toString()}
replyToUsername={replyToUsername} replyToUser={replyToUser}
setRef={setInputRef} onSubmitComment={() => setShowReply(false)}
onSubmitComment={() => {
setShowReply(false)
setReplyToUsername('')
}}
/> />
</div> </div>
)} )}

View File

@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
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 { contractPath } from 'web/lib/firebase/contracts'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { import {
createCommentOnContract, createCommentOnContract,
MAX_COMMENT_LENGTH, MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments' } from 'web/lib/firebase/comments'
import Textarea from 'react-expanding-textarea'
import { Linkify } from 'web/components/linkify'
import { SiteLink } from 'web/components/site-link'
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 { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline' import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { useEvent } from 'web/hooks/use-event'
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 { useWindowSize } from 'web/hooks/use-window-size'
import { Content, TextEditor, useTextEditor } from '../editor'
import { Editor } from '@tiptap/react'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
contract: Contract contract: Contract
@ -39,20 +36,12 @@ export function FeedCommentThread(props: {
tips: CommentTipMap tips: CommentTipMap
parentComment: Comment parentComment: Comment
bets: Bet[] bets: Bet[]
truncate?: boolean
smallAvatar?: boolean smallAvatar?: boolean
}) { }) {
const { const { contract, comments, bets, tips, smallAvatar, parentComment } = props
contract,
comments,
bets,
tips,
truncate,
smallAvatar,
parentComment,
} = props
const [showReply, setShowReply] = useState(false) const [showReply, setShowReply] = useState(false)
const [replyToUsername, setReplyToUsername] = useState('') const [replyToUser, setReplyToUser] =
useState<{ id: string; username: string }>()
const betsByUserId = groupBy(bets, (bet) => bet.userId) const betsByUserId = groupBy(bets, (bet) => bet.userId)
const user = useUser() const user = useUser()
const commentsList = comments.filter( const commentsList = comments.filter(
@ -60,15 +49,12 @@ export function FeedCommentThread(props: {
parentComment.id && comment.replyToCommentId === parentComment.id parentComment.id && comment.replyToCommentId === parentComment.id
) )
commentsList.unshift(parentComment) commentsList.unshift(parentComment)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
function scrollAndOpenReplyInput(comment: Comment) { function scrollAndOpenReplyInput(comment: Comment) {
setReplyToUsername(comment.userUsername) setReplyToUser({ id: comment.userId, username: comment.userUsername })
setShowReply(true) setShowReply(true)
inputRef?.focus()
} }
useEffect(() => {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return ( return (
<Col className={'w-full gap-3 pr-1'}> <Col className={'w-full gap-3 pr-1'}>
<span <span
@ -81,7 +67,6 @@ export function FeedCommentThread(props: {
betsByUserId={betsByUserId} betsByUserId={betsByUserId}
tips={tips} tips={tips}
smallAvatar={smallAvatar} smallAvatar={smallAvatar}
truncate={truncate}
bets={bets} bets={bets}
scrollAndOpenReplyInput={scrollAndOpenReplyInput} scrollAndOpenReplyInput={scrollAndOpenReplyInput}
/> />
@ -98,13 +83,9 @@ export function FeedCommentThread(props: {
(c) => c.userId === user?.id (c) => c.userId === user?.id
)} )}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUsername={replyToUsername} replyToUser={replyToUser}
parentAnswerOutcome={comments[0].answerOutcome} parentAnswerOutcome={comments[0].answerOutcome}
setRef={setInputRef} onSubmitComment={() => setShowReply(false)}
onSubmitComment={() => {
setShowReply(false)
setReplyToUsername('')
}}
/> />
</Col> </Col>
)} )}
@ -121,14 +102,12 @@ export function CommentRepliesList(props: {
bets: Bet[] bets: Bet[]
treatFirstIndexEqually?: boolean treatFirstIndexEqually?: boolean
smallAvatar?: boolean smallAvatar?: boolean
truncate?: boolean
}) { }) {
const { const {
contract, contract,
commentsList, commentsList,
betsByUserId, betsByUserId,
tips, tips,
truncate,
smallAvatar, smallAvatar,
bets, bets,
scrollAndOpenReplyInput, scrollAndOpenReplyInput,
@ -168,7 +147,6 @@ export function CommentRepliesList(props: {
: undefined : undefined
} }
smallAvatar={smallAvatar} smallAvatar={smallAvatar}
truncate={truncate}
/> />
</div> </div>
))} ))}
@ -182,7 +160,6 @@ export function FeedComment(props: {
tips: CommentTips tips: CommentTips
betsBySameUser: Bet[] betsBySameUser: Bet[]
probAtCreatedTime?: number probAtCreatedTime?: number
truncate?: boolean
smallAvatar?: boolean smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void onReplyClick?: (comment: Comment) => void
}) { }) {
@ -192,10 +169,10 @@ export function FeedComment(props: {
tips, tips,
betsBySameUser, betsBySameUser,
probAtCreatedTime, probAtCreatedTime,
truncate,
onReplyClick, onReplyClick,
} = props } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
let betOutcome: string | undefined, let betOutcome: string | undefined,
bought: string | undefined, bought: string | undefined,
money: string | undefined money: string | undefined
@ -276,11 +253,9 @@ export function FeedComment(props: {
elementId={comment.id} elementId={comment.id}
/> />
</div> </div>
<TruncatedComment <div className="mt-2 text-[15px] text-gray-700">
comment={text} <Content content={content || text} />
moreHref={contractPath(contract)} </div>
shouldTruncate={truncate}
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} /> <Tipper comment={comment} tips={tips ?? {}} />
{onReplyClick && ( {onReplyClick && (
@ -345,8 +320,7 @@ export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[] commentsByCurrentUser: Comment[]
replyToUsername?: string replyToUser?: { id: string; username: string }
setRef?: (ref: HTMLTextAreaElement) => void
// Reply to a free response answer // Reply to a free response answer
parentAnswerOutcome?: string parentAnswerOutcome?: string
// Reply to another comment // Reply to another comment
@ -359,12 +333,18 @@ export function CommentInput(props: {
commentsByCurrentUser, commentsByCurrentUser,
parentAnswerOutcome, parentAnswerOutcome,
parentCommentId, parentCommentId,
replyToUsername, replyToUser,
onSubmitComment, onSubmitComment,
setRef,
} = props } = props
const user = useUser() const user = useUser()
const [comment, setComment] = useState('') 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 [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet( const mostRecentCommentableBet = getMostRecentCommentableBet(
@ -380,18 +360,17 @@ export function CommentInput(props: {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
if (!comment || isSubmitting) return if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true) setIsSubmitting(true)
await createCommentOnContract( await createCommentOnContract(
contract.id, contract.id,
comment, editor.getJSON(),
user, user,
betId, betId,
parentAnswerOutcome, parentAnswerOutcome,
parentCommentId parentCommentId
) )
onSubmitComment?.() onSubmitComment?.()
setComment('')
setIsSubmitting(false) setIsSubmitting(false)
} }
@ -446,14 +425,12 @@ export function CommentInput(props: {
)} )}
</div> </div>
<CommentInputTextArea <CommentInputTextArea
commentText={comment} editor={editor}
setComment={setComment} upload={upload}
isReply={!!parentCommentId || !!parentAnswerOutcome} replyToUser={replyToUser}
replyToUsername={replyToUsername ?? ''}
user={user} user={user}
submitComment={submitComment} submitComment={submitComment}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
setRef={setRef}
presetId={id} presetId={id}
/> />
</div> </div>
@ -465,94 +442,89 @@ export function CommentInput(props: {
export function CommentInputTextArea(props: { export function CommentInputTextArea(props: {
user: User | undefined | null user: User | undefined | null
isReply: boolean replyToUser?: { id: string; username: string }
replyToUsername: string editor: Editor | null
commentText: string upload: Parameters<typeof TextEditor>[0]['upload']
setComment: (text: string) => void
submitComment: (id?: string) => void submitComment: (id?: string) => void
isSubmitting: boolean isSubmitting: boolean
setRef?: (ref: HTMLTextAreaElement) => void submitOnEnter?: boolean
presetId?: string presetId?: string
enterToSubmitOnDesktop?: boolean
}) { }) {
const { const {
isReply,
setRef,
user, user,
commentText, editor,
setComment, upload,
submitComment, submitComment,
presetId, presetId,
isSubmitting, isSubmitting,
replyToUsername, submitOnEnter,
enterToSubmitOnDesktop, replyToUser,
} = props } = props
const { width } = useWindowSize() const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
const memoizedSetComment = useEvent(setComment)
useEffect(() => { useEffect(() => {
if (!replyToUsername || !user || replyToUsername === user.username) return editor?.setEditable(!isSubmitting)
const replacement = `@${replyToUsername} ` }, [isSubmitting, editor])
memoizedSetComment(replacement + commentText.replace(replacement, ''))
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
if (replyToUser) {
editor.commands.insertContentAt(0, {
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
editor.commands.focus()
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, replyToUsername, memoizedSetComment]) }, [editor])
return ( return (
<> <>
<Row className="gap-1.5 text-gray-700"> <div>
<Textarea <TextEditor editor={editor} upload={upload}>
ref={setRef}
value={commentText}
onChange={(e) => setComment(e.target.value)}
className={clsx('textarea textarea-bordered w-full resize-none')}
// Make room for floating submit button.
style={{ paddingRight: 48 }}
placeholder={
isReply
? 'Write a reply... '
: enterToSubmitOnDesktop
? 'Send a message'
: 'Write a comment...'
}
autoFocus={false}
maxLength={MAX_COMMENT_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (
(enterToSubmitOnDesktop &&
e.key === 'Enter' &&
!e.shiftKey &&
width &&
width > 768) ||
(e.key === 'Enter' && (e.ctrlKey || e.metaKey))
) {
e.preventDefault()
submitComment(presetId)
e.currentTarget.blur()
}
}}
/>
<Col className={clsx('relative justify-end')}>
{user && !isSubmitting && ( {user && !isSubmitting && (
<button <button
className={clsx( className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize', disabled={!editor || editor.isEmpty}
!commentText && 'pointer-events-none text-gray-500' onClick={submit}
)}
onClick={() => {
submitComment(presetId)
}}
> >
<PaperAirplaneIcon <PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
className={'m-0 min-w-[22px] rotate-90 p-0 '}
height={25}
/>
</button> </button>
)} )}
{isSubmitting && ( {isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} /> <LoadingIndicator spinnerClassName={'border-gray-500'} />
)} )}
</Col> </TextEditor>
</Row> </div>
<Row> <Row>
{!user && ( {!user && (
<button <button
@ -567,38 +539,6 @@ export function CommentInputTextArea(props: {
) )
} }
export function TruncatedComment(props: {
comment: string
moreHref: string
shouldTruncate?: boolean
}) {
const { comment, moreHref, shouldTruncate } = props
let truncated = comment
// Keep descriptions to at most 400 characters
const MAX_CHARS = 400
if (shouldTruncate && truncated.length > MAX_CHARS) {
truncated = truncated.slice(0, MAX_CHARS)
// Make sure to end on a space
const i = truncated.lastIndexOf(' ')
truncated = truncated.slice(0, i)
}
return (
<div
className="mt-2 whitespace-pre-line break-words text-gray-700"
style={{ fontSize: 15 }}
>
<Linkify text={truncated} />
{truncated != comment && (
<SiteLink href={moreHref} className="text-indigo-700">
... (show more)
</SiteLink>
)}
</div>
)
}
function getBettorsLargestPositionBeforeTime( function getBettorsLargestPositionBeforeTime(
contract: Contract, contract: Contract,
createdTime: number, createdTime: number,

View File

@ -5,24 +5,19 @@ import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group' import { Group } from 'common/group'
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments' import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
import { import { CommentInputTextArea } from 'web/components/feed/feed-comments'
CommentInputTextArea,
TruncatedComment,
} from 'web/components/feed/feed-comments'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import clsx from 'clsx' import clsx from 'clsx'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { groupPath } from 'web/lib/firebase/groups'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { Tipper } from 'web/components/tipper' import { Tipper } from 'web/components/tipper'
import { sum } from 'lodash' import { sum } from 'lodash'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, useTextEditor } from 'web/components/editor'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline' import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline'
import { setNotificationsAsSeen } from 'web/pages/notifications' import { setNotificationsAsSeen } from 'web/pages/notifications'
@ -34,16 +29,18 @@ export function GroupChat(props: {
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { messages, user, group, tips } = props const { messages, user, group, tips } = props
const [messageText, setMessageText] = useState('') const { editor, upload } = useTextEditor({
simple: true,
placeholder: 'Send a message',
})
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [scrollToBottomRef, setScrollToBottomRef] = const [scrollToBottomRef, setScrollToBottomRef] =
useState<HTMLDivElement | null>(null) useState<HTMLDivElement | null>(null)
const [scrollToMessageId, setScrollToMessageId] = useState('') const [scrollToMessageId, setScrollToMessageId] = useState('')
const [scrollToMessageRef, setScrollToMessageRef] = const [scrollToMessageRef, setScrollToMessageRef] =
useState<HTMLDivElement | null>(null) useState<HTMLDivElement | null>(null)
const [replyToUsername, setReplyToUsername] = useState('') const [replyToUser, setReplyToUser] = useState<any>()
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [groupedMessages, setGroupedMessages] = useState<Comment[]>([])
const router = useRouter() const router = useRouter()
const isMember = user && group.memberIds.includes(user?.id) const isMember = user && group.memberIds.includes(user?.id)
@ -54,25 +51,26 @@ export function GroupChat(props: {
const remainingHeight = const remainingHeight =
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
useMemo(() => { // array of groups, where each group is an array of messages that are displayed as one
const groupedMessages = useMemo(() => {
// Group messages with createdTime within 2 minutes of each other. // Group messages with createdTime within 2 minutes of each other.
const tempMessages = [] const tempGrouped: Comment[][] = []
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
const message = messages[i] const message = messages[i]
if (i === 0) tempMessages.push({ ...message }) if (i === 0) tempGrouped.push([message])
else { else {
const prevMessage = messages[i - 1] const prevMessage = messages[i - 1]
const diff = message.createdTime - prevMessage.createdTime const diff = message.createdTime - prevMessage.createdTime
const creatorsMatch = message.userId === prevMessage.userId const creatorsMatch = message.userId === prevMessage.userId
if (diff < 2 * 60 * 1000 && creatorsMatch) { if (diff < 2 * 60 * 1000 && creatorsMatch) {
tempMessages[tempMessages.length - 1].text += `\n${message.text}` tempGrouped.at(-1)?.push(message)
} else { } else {
tempMessages.push({ ...message }) tempGrouped.push([message])
} }
} }
} }
setGroupedMessages(tempMessages) return tempGrouped
}, [messages]) }, [messages])
useEffect(() => { useEffect(() => {
@ -94,11 +92,12 @@ export function GroupChat(props: {
useEffect(() => { useEffect(() => {
// is mobile? // is mobile?
if (inputRef && width && width > 720) inputRef.focus() if (width && width > 720) focusInput()
}, [inputRef, width]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [width])
function onReplyClick(comment: Comment) { function onReplyClick(comment: Comment) {
setReplyToUsername(comment.userUsername) setReplyToUser({ id: comment.userId, username: comment.userUsername })
} }
async function submitMessage() { async function submitMessage() {
@ -106,13 +105,16 @@ export function GroupChat(props: {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
if (!messageText || isSubmitting) return if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true) setIsSubmitting(true)
await createCommentOnGroup(group.id, messageText, user) await createCommentOnGroup(group.id, editor.getJSON(), user)
setMessageText('') editor.commands.clearContent()
setIsSubmitting(false) setIsSubmitting(false)
setReplyToUsername('') setReplyToUser(undefined)
inputRef?.focus() focusInput()
}
function focusInput() {
editor?.commands.focus()
} }
return ( return (
@ -123,20 +125,20 @@ export function GroupChat(props: {
} }
ref={setScrollToBottomRef} ref={setScrollToBottomRef}
> >
{groupedMessages.map((message) => ( {groupedMessages.map((messages) => (
<GroupMessage <GroupMessage
user={user} user={user}
key={message.id} key={`group ${messages[0].id}`}
comment={message} comments={messages}
group={group} group={group}
onReplyClick={onReplyClick} onReplyClick={onReplyClick}
highlight={message.id === scrollToMessageId} highlight={messages[0].id === scrollToMessageId}
setRef={ setRef={
scrollToMessageId === message.id scrollToMessageId === messages[0].id
? setScrollToMessageRef ? setScrollToMessageRef
: undefined : undefined
} }
tips={tips[message.id] ?? {}} tips={tips[messages[0].id] ?? {}}
/> />
))} ))}
{messages.length === 0 && ( {messages.length === 0 && (
@ -144,7 +146,7 @@ export function GroupChat(props: {
No messages yet. Why not{isMember ? ` ` : ' join and '} No messages yet. Why not{isMember ? ` ` : ' join and '}
<button <button
className={'cursor-pointer font-bold text-gray-700'} className={'cursor-pointer font-bold text-gray-700'}
onClick={() => inputRef?.focus()} onClick={focusInput}
> >
add one? add one?
</button> </button>
@ -162,15 +164,13 @@ export function GroupChat(props: {
</div> </div>
<div className={'flex-1'}> <div className={'flex-1'}>
<CommentInputTextArea <CommentInputTextArea
commentText={messageText} editor={editor}
setComment={setMessageText} upload={upload}
isReply={false}
user={user} user={user}
replyToUsername={replyToUsername} replyToUser={replyToUser}
submitComment={submitMessage} submitComment={submitMessage}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
enterToSubmitOnDesktop={true} submitOnEnter
setRef={setInputRef}
/> />
</div> </div>
</div> </div>
@ -292,16 +292,18 @@ function GroupChatNotificationsIcon(props: {
const GroupMessage = memo(function GroupMessage_(props: { const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined user: User | null | undefined
comment: Comment comments: Comment[]
group: Group group: Group
onReplyClick?: (comment: Comment) => void onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void setRef?: (ref: HTMLDivElement) => void
highlight?: boolean highlight?: boolean
tips: CommentTips tips: CommentTips
}) { }) {
const { comment, onReplyClick, group, setRef, highlight, user, tips } = props const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const first = comments[0]
const isCreatorsComment = user && comment.userId === user.id const { id, userUsername, userName, userAvatarUrl, createdTime } = first
const isCreatorsComment = user && first.userId === user.id
return ( return (
<Col <Col
ref={setRef} ref={setRef}
@ -331,23 +333,21 @@ const GroupMessage = memo(function GroupMessage_(props: {
prefix={'group'} prefix={'group'}
slug={group.slug} slug={group.slug}
createdTime={createdTime} createdTime={createdTime}
elementId={comment.id} elementId={id}
/>
</Row>
<Row className={'text-black'}>
<TruncatedComment
comment={text}
moreHref={groupPath(group.slug)}
shouldTruncate={false}
/> />
</Row> </Row>
<div className="mt-2 text-black">
{comments.map((comment) => (
<Content content={comment.content || comment.text} />
))}
</div>
<Row> <Row>
{!isCreatorsComment && onReplyClick && ( {!isCreatorsComment && onReplyClick && (
<button <button
className={ className={
'self-start py-1 text-xs font-bold text-gray-500 hover:underline' 'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
} }
onClick={() => onReplyClick(comment)} onClick={() => onReplyClick(first)}
> >
Reply Reply
</button> </button>
@ -357,7 +357,7 @@ const GroupMessage = memo(function GroupMessage_(props: {
{formatMoney(sum(Object.values(tips)))} {formatMoney(sum(Object.values(tips)))}
</span> </span>
)} )}
{!isCreatorsComment && <Tipper comment={comment} tips={tips} />} {!isCreatorsComment && <Tipper comment={first} tips={tips} />}
</Row> </Row>
</Col> </Col>
) )

View File

@ -14,6 +14,7 @@ import { User } from 'common/user'
import { Comment } from 'common/comment' import { Comment } 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'
export type { Comment } export type { Comment }
@ -21,7 +22,7 @@ export const MAX_COMMENT_LENGTH = 10000
export async function createCommentOnContract( export async function createCommentOnContract(
contractId: string, contractId: string,
text: string, content: JSONContent,
commenter: User, commenter: User,
betId?: string, betId?: string,
answerOutcome?: string, answerOutcome?: string,
@ -34,7 +35,7 @@ export async function createCommentOnContract(
id: ref.id, id: ref.id,
contractId, contractId,
userId: commenter.id, userId: commenter.id,
text: text.slice(0, MAX_COMMENT_LENGTH), content: content,
createdTime: Date.now(), createdTime: Date.now(),
userName: commenter.name, userName: commenter.name,
userUsername: commenter.username, userUsername: commenter.username,
@ -53,7 +54,7 @@ export async function createCommentOnContract(
} }
export async function createCommentOnGroup( export async function createCommentOnGroup(
groupId: string, groupId: string,
text: string, content: JSONContent,
user: User, user: User,
replyToCommentId?: string replyToCommentId?: string
) { ) {
@ -62,7 +63,7 @@ export async function createCommentOnGroup(
id: ref.id, id: ref.id,
groupId, groupId,
userId: user.id, userId: user.id,
text: text.slice(0, MAX_COMMENT_LENGTH), content: content,
createdTime: Date.now(), createdTime: Date.now(),
userName: user.name, userName: user.name,
userUsername: user.username, userUsername: user.username,

View File

@ -354,7 +354,6 @@ function ContractTopTrades(props: {
comment={commentsById[topCommentId]} comment={commentsById[topCommentId]}
tips={tips[topCommentId]} tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]} betsBySameUser={[betsById[topCommentId]]}
truncate={false}
smallAvatar={false} smallAvatar={false}
/> />
</div> </div>