From 5892ccee977decd3644c66cfda22db02defa21ca Mon Sep 17 00:00:00 2001
From: Sinclair Chen
Date: Sat, 6 Aug 2022 13:39:52 -0700
Subject: [PATCH] Rich text in comments, fixed (#719)
* Revert "Revert "Switch comments/chat to rich text editor (#703)""
This reverts commit 33906adfe489cbc3489bc014c4a3253d96660ec0.
* 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
---
common/comment.ts | 6 +-
functions/src/create-notification.ts | 19 +-
functions/src/emails.ts | 4 +-
.../src/on-create-comment-on-contract.ts | 11 +-
web/components/comments-list.tsx | 7 +-
.../contract/contract-leaderboard.tsx | 1 -
web/components/editor.tsx | 39 +--
web/components/editor/mention.tsx | 5 +-
.../feed/feed-answer-comment-group.tsx | 28 +-
web/components/feed/feed-comments.tsx | 246 +++++++-----------
web/components/groups/group-chat.tsx | 104 ++++----
web/lib/firebase/comments.ts | 9 +-
web/pages/[username]/[contractSlug].tsx | 1 -
13 files changed, 204 insertions(+), 276 deletions(-)
diff --git a/common/comment.ts b/common/comment.ts
index 0d0c4daf..a217b292 100644
--- a/common/comment.ts
+++ b/common/comment.ts
@@ -1,3 +1,5 @@
+import type { JSONContent } from '@tiptap/core'
+
// Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId.
export type Comment = {
@@ -9,7 +11,9 @@ export type Comment = {
replyToCommentId?: string
userId: string
- text: string
+ /** @deprecated - content now stored as JSON in content*/
+ text?: string
+ content: JSONContent
createdTime: number
// Denormalized, for rendering comments
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index e16920f7..51b884ad 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -7,7 +7,7 @@ import {
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
-import { getUserByUsername, getValues } from './utils'
+import { getValues } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
@@ -17,6 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge'
+import { richTextToString } from '../../common/util/parse'
const firestore = admin.firestore()
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 = (
userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[]
@@ -301,8 +291,7 @@ export const createNotification = async (
if (sourceType === 'comment') {
if (recipients?.[0] && relatedSourceType)
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
- if (sourceText)
- notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText))
+ if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
@@ -427,7 +416,7 @@ export const createGroupCommentNotification = async (
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
- sourceText: comment.text,
+ sourceText: richTextToString(comment.content),
sourceSlug,
sourceTitle: `${group.name}`,
isSeenOnHref: sourceSlug,
diff --git a/functions/src/emails.ts b/functions/src/emails.ts
index b7469e9f..a097393e 100644
--- a/functions/src/emails.ts
+++ b/functions/src/emails.ts
@@ -17,6 +17,7 @@ import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api'
+import { richTextToString } from '../../common/util/parse'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
@@ -291,7 +292,8 @@ export const sendNewCommentEmail = async (
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
- const { text } = comment
+ const { content } = comment
+ const text = richTextToString(content)
let betDescription = ''
if (bet) {
diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts
index 4719fd08..d7aa0c5e 100644
--- a/functions/src/on-create-comment-on-contract.ts
+++ b/functions/src/on-create-comment-on-contract.ts
@@ -1,13 +1,13 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
-import { uniq } from 'lodash'
-
+import { compact, uniq } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { createNotification } from './create-notification'
+import { parseMentions, richTextToString } from '../../common/util/parse'
const firestore = admin.firestore()
@@ -71,7 +71,10 @@ export const onCreateCommentOnContract = functions
const repliedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
- const recipients = repliedUserId ? [repliedUserId] : []
+
+ const recipients = uniq(
+ compact([...parseMentions(comment.content), repliedUserId])
+ )
await createNotification(
comment.id,
@@ -79,7 +82,7 @@ export const onCreateCommentOnContract = functions
'created',
commentCreator,
eventId,
- comment.text,
+ richTextToString(comment.content),
{ contract, relatedSourceType, recipients }
)
diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx
index f8e1d7e1..2a467f6d 100644
--- a/web/components/comments-list.tsx
+++ b/web/components/comments-list.tsx
@@ -8,8 +8,8 @@ import { RelativeTimestamp } from './relative-timestamp'
import { UserLink } from './user-page'
import { User } from 'common/user'
import { Col } from './layout/col'
-import { Linkify } from './linkify'
import { groupBy } from 'lodash'
+import { Content } from './editor'
export function UserCommentsList(props: {
user: User
@@ -50,7 +50,8 @@ export function UserCommentsList(props: {
function ProfileComment(props: { comment: Comment; className?: string }) {
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
return (
@@ -64,7 +65,7 @@ function ProfileComment(props: { comment: Comment; className?: string }) {
/>{' '}
-
+
)
diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx
index deb9b857..6f1a778d 100644
--- a/web/components/contract/contract-leaderboard.tsx
+++ b/web/components/contract/contract-leaderboard.tsx
@@ -107,7 +107,6 @@ export function ContractTopTrades(props: {
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
- truncate={false}
smallAvatar={false}
/>
diff --git a/web/components/editor.tsx b/web/components/editor.tsx
index 963cea7e..2371bbf8 100644
--- a/web/components/editor.tsx
+++ b/web/components/editor.tsx
@@ -41,14 +41,16 @@ export function useTextEditor(props: {
max?: number
defaultValue?: Content
disabled?: boolean
+ simple?: boolean
}) {
- const { placeholder, max, defaultValue = '', disabled } = props
+ const { placeholder, max, defaultValue = '', disabled, simple } = props
const users = useUsers()
const editorClass = clsx(
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(
@@ -56,7 +58,8 @@ export function useTextEditor(props: {
editorProps: { attributes: { class: editorClass } },
extensions: [
StarterKit.configure({
- heading: { levels: [1, 2, 3] },
+ heading: simple ? false : { levels: [1, 2, 3] },
+ horizontalRule: simple ? false : {},
}),
Placeholder.configure({
placeholder,
@@ -120,8 +123,9 @@ function isValidIframe(text: string) {
export function TextEditor(props: {
editor: Editor | null
upload: ReturnType
+ children?: React.ReactNode // additional toolbar buttons
}) {
- const { editor, upload } = props
+ const { editor, upload, children } = props
const [iframeOpen, setIframeOpen] = useState(false)
return (
@@ -133,30 +137,13 @@ export function TextEditor(props: {
editor={editor}
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
>
- Type *markdown*. Paste or{' '}
-
- upload
- {' '}
- images!
+ Type *markdown*
)}
-
+
- {/* Spacer element to match the height of the toolbar */}
-
- {/* Matches height of button in toolbar (1px border + 36px content height) */}
-
-
-
-
- {/* Toolbar, with buttons for image and embeds */}
-
-
+ {/* Toolbar, with buttons for images and embeds */}
+
Embed an iframe
+
+ {children}
diff --git a/web/components/editor/mention.tsx b/web/components/editor/mention.tsx
index 3ad5de39..5ccea6f5 100644
--- a/web/components/editor/mention.tsx
+++ b/web/components/editor/mention.tsx
@@ -11,7 +11,7 @@ const name = 'mention-component'
const MentionComponent = (props: any) => {
return (
-
+
)
@@ -25,5 +25,6 @@ const MentionComponent = (props: any) => {
export const DisplayMention = Mention.extend({
parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
- addNodeView: () => ReactNodeViewRenderer(MentionComponent),
+ addNodeView: () =>
+ ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
})
diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx
index aabb1081..edaf1fe5 100644
--- a/web/components/feed/feed-answer-comment-group.tsx
+++ b/web/components/feed/feed-answer-comment-group.tsx
@@ -31,9 +31,9 @@ export function FeedAnswerCommentGroup(props: {
const { answer, contract, comments, tips, bets, user } = props
const { username, avatarUrl, name, text } = answer
- const [replyToUsername, setReplyToUsername] = useState('')
+ const [replyToUser, setReplyToUser] =
+ useState>()
const [showReply, setShowReply] = useState(false)
- const [inputRef, setInputRef] = useState(null)
const [highlighted, setHighlighted] = useState(false)
const router = useRouter()
@@ -70,9 +70,14 @@ export function FeedAnswerCommentGroup(props: {
const scrollAndOpenReplyInput = useEvent(
(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)
- inputRef?.focus()
}
)
@@ -80,7 +85,7 @@ export function FeedAnswerCommentGroup(props: {
// Only show one comment input for a bet at a time
if (
betsByCurrentUser.length > 1 &&
- inputRef?.textContent?.length === 0 &&
+ // inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
?.outcome !== answer.number.toString()
)
@@ -89,10 +94,6 @@ export function FeedAnswerCommentGroup(props: {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [betsByCurrentUser.length, user, answer.number])
- useEffect(() => {
- if (showReply && inputRef) inputRef.focus()
- }, [inputRef, showReply])
-
useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
@@ -154,7 +155,6 @@ export function FeedAnswerCommentGroup(props: {
commentsList={commentsList}
betsByUserId={betsByUserId}
smallAvatar={true}
- truncate={false}
bets={bets}
tips={tips}
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
@@ -172,12 +172,8 @@ export function FeedAnswerCommentGroup(props: {
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}
parentAnswerOutcome={answer.number.toString()}
- replyToUsername={replyToUsername}
- setRef={setInputRef}
- onSubmitComment={() => {
- setShowReply(false)
- setReplyToUsername('')
- }}
+ replyToUser={replyToUser}
+ onSubmitComment={() => setShowReply(false)}
/>
)}
diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx
index f4c6eb74..8c84039e 100644
--- a/web/components/feed/feed-comments.tsx
+++ b/web/components/feed/feed-comments.tsx
@@ -13,25 +13,22 @@ import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { OutcomeLabel } from 'web/components/outcome-label'
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 {
createCommentOnContract,
MAX_COMMENT_LENGTH,
} 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 { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics'
-import { useEvent } from 'web/hooks/use-event'
import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { useWindowSize } from 'web/hooks/use-window-size'
+import { Content, TextEditor, useTextEditor } from '../editor'
+import { Editor } from '@tiptap/react'
export function FeedCommentThread(props: {
contract: Contract
@@ -39,20 +36,12 @@ export function FeedCommentThread(props: {
tips: CommentTipMap
parentComment: Comment
bets: Bet[]
- truncate?: boolean
smallAvatar?: boolean
}) {
- const {
- contract,
- comments,
- bets,
- tips,
- truncate,
- smallAvatar,
- parentComment,
- } = props
+ const { contract, comments, bets, tips, smallAvatar, parentComment } = props
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 user = useUser()
const commentsList = comments.filter(
@@ -60,15 +49,12 @@ export function FeedCommentThread(props: {
parentComment.id && comment.replyToCommentId === parentComment.id
)
commentsList.unshift(parentComment)
- const [inputRef, setInputRef] = useState(null)
+
function scrollAndOpenReplyInput(comment: Comment) {
- setReplyToUsername(comment.userUsername)
+ setReplyToUser({ id: comment.userId, username: comment.userUsername })
setShowReply(true)
- inputRef?.focus()
}
- useEffect(() => {
- if (showReply && inputRef) inputRef.focus()
- }, [inputRef, showReply])
+
return (
@@ -98,13 +83,9 @@ export function FeedCommentThread(props: {
(c) => c.userId === user?.id
)}
parentCommentId={parentComment.id}
- replyToUsername={replyToUsername}
+ replyToUser={replyToUser}
parentAnswerOutcome={comments[0].answerOutcome}
- setRef={setInputRef}
- onSubmitComment={() => {
- setShowReply(false)
- setReplyToUsername('')
- }}
+ onSubmitComment={() => setShowReply(false)}
/>
)}
@@ -121,14 +102,12 @@ export function CommentRepliesList(props: {
bets: Bet[]
treatFirstIndexEqually?: boolean
smallAvatar?: boolean
- truncate?: boolean
}) {
const {
contract,
commentsList,
betsByUserId,
tips,
- truncate,
smallAvatar,
bets,
scrollAndOpenReplyInput,
@@ -168,7 +147,6 @@ export function CommentRepliesList(props: {
: undefined
}
smallAvatar={smallAvatar}
- truncate={truncate}
/>
))}
@@ -182,7 +160,6 @@ export function FeedComment(props: {
tips: CommentTips
betsBySameUser: Bet[]
probAtCreatedTime?: number
- truncate?: boolean
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
}) {
@@ -192,10 +169,10 @@ export function FeedComment(props: {
tips,
betsBySameUser,
probAtCreatedTime,
- truncate,
onReplyClick,
} = props
- const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
+ const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
+ comment
let betOutcome: string | undefined,
bought: string | undefined,
money: string | undefined
@@ -276,11 +253,9 @@ export function FeedComment(props: {
elementId={comment.id}
/>
-
+
+
+
{onReplyClick && (
@@ -345,8 +320,7 @@ export function CommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: Comment[]
- replyToUsername?: string
- setRef?: (ref: HTMLTextAreaElement) => void
+ replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
@@ -359,12 +333,18 @@ export function CommentInput(props: {
commentsByCurrentUser,
parentAnswerOutcome,
parentCommentId,
- replyToUsername,
+ replyToUser,
onSubmitComment,
- setRef,
} = props
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 mostRecentCommentableBet = getMostRecentCommentableBet(
@@ -380,18 +360,17 @@ export function CommentInput(props: {
track('sign in to comment')
return await firebaseLogin()
}
- if (!comment || isSubmitting) return
+ if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract(
contract.id,
- comment,
+ editor.getJSON(),
user,
betId,
parentAnswerOutcome,
parentCommentId
)
onSubmitComment?.()
- setComment('')
setIsSubmitting(false)
}
@@ -446,14 +425,12 @@ export function CommentInput(props: {
)}
@@ -465,94 +442,93 @@ export function CommentInput(props: {
export function CommentInputTextArea(props: {
user: User | undefined | null
- isReply: boolean
- replyToUsername: string
- commentText: string
- setComment: (text: string) => void
+ replyToUser?: { id: string; username: string }
+ editor: Editor | null
+ upload: Parameters[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
- setRef?: (ref: HTMLTextAreaElement) => void
+ submitOnEnter?: boolean
presetId?: string
- enterToSubmitOnDesktop?: boolean
}) {
const {
- isReply,
- setRef,
user,
- commentText,
- setComment,
+ editor,
+ upload,
submitComment,
presetId,
isSubmitting,
- replyToUsername,
- enterToSubmitOnDesktop,
+ submitOnEnter,
+ replyToUser,
} = props
- const { width } = useWindowSize()
- const memoizedSetComment = useEvent(setComment)
+ const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
+
useEffect(() => {
- if (!replyToUsername || !user || replyToUsername === user.username) return
- const replacement = `@${replyToUsername} `
- memoizedSetComment(replacement + commentText.replace(replacement, ''))
+ editor?.setEditable(!isSubmitting)
+ }, [isSubmitting, editor])
+
+ const submit = () => {
+ submitComment(presetId)
+ editor?.commands?.clearContent()
+ }
+
+ useEffect(() => {
+ if (!editor) {
+ return
+ }
+ // submit on Enter key
+ editor.setOptions({
+ editorProps: {
+ handleKeyDown: (view, event) => {
+ if (
+ submitOnEnter &&
+ event.key === 'Enter' &&
+ !event.shiftKey &&
+ (!isMobile || event.ctrlKey || event.metaKey) &&
+ // mention list is closed
+ !(view.state as any).mention$.active
+ ) {
+ submit()
+ event.preventDefault()
+ return true
+ }
+ return false
+ },
+ },
+ })
+ // insert at mention and focus
+ if (replyToUser) {
+ editor
+ .chain()
+ .setContent({
+ type: 'mention',
+ attrs: { label: replyToUser.username, id: replyToUser.id },
+ })
+ .insertContent(' ')
+ .focus()
+ .run()
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [user, replyToUsername, memoizedSetComment])
+ }, [editor])
+
return (
<>
-
-