Rich text in comments, fixed (#719)
* Revert "Revert "Switch comments/chat to rich text editor (#703)""
This reverts commit 33906adfe4
.
* Fix typing after being weird on Android
Issue: character from mention gets deleted. Most weird on Swiftkey:
mention gets duplicated instead of deleting.
See https://github.com/ProseMirror/prosemirror/issues/565
https://bugs.chromium.org/p/chromium/issues/detail?id=612446
The keyboard still closes unexpectedly when you delete :(
* On reply, put space instead of \n after mention
* Remove image upload from placeholder text
- Redundant with image upload button
- Too long on mobile
* fix dependency
This commit is contained in:
parent
d43b9e1836
commit
5892ccee97
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 (
|
||||||
|
@ -133,30 +137,13 @@ export function TextEditor(props: {
|
||||||
editor={editor}
|
editor={editor}
|
||||||
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
|
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
|
||||||
>
|
>
|
||||||
Type <em>*markdown*</em>. Paste or{' '}
|
Type <em>*markdown*</em>
|
||||||
<FileUploadButton
|
|
||||||
className="link text-blue-300"
|
|
||||||
onFiles={upload.mutate}
|
|
||||||
>
|
|
||||||
upload
|
|
||||||
</FileUploadButton>{' '}
|
|
||||||
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 +168,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>
|
||||||
|
|
|
@ -11,7 +11,7 @@ const name = 'mention-component'
|
||||||
|
|
||||||
const MentionComponent = (props: any) => {
|
const MentionComponent = (props: any) => {
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
|
<NodeViewWrapper className={clsx(name, 'not-prose text-indigo-700')}>
|
||||||
<Linkify text={'@' + props.node.attrs.label} />
|
<Linkify text={'@' + props.node.attrs.label} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
|
@ -25,5 +25,6 @@ const MentionComponent = (props: any) => {
|
||||||
export const DisplayMention = Mention.extend({
|
export const DisplayMention = Mention.extend({
|
||||||
parseHTML: () => [{ tag: name }],
|
parseHTML: () => [{ tag: name }],
|
||||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||||
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
|
addNodeView: () =>
|
||||||
|
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
|
||||||
})
|
})
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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,93 @@ 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 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
|
// 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 +543,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,
|
||||||
|
|
|
@ -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 { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
import { ChevronDownIcon, UsersIcon } 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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user