From 33906adfe489cbc3489bc014c4a3253d96660ec0 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 4 Aug 2022 16:49:59 -0700 Subject: [PATCH] Revert "Switch comments/chat to rich text editor (#703)" This reverts commit f52da72115bfacb0af5a4d54c137a936b33d9eee. --- 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 | 30 ++- .../feed/feed-answer-comment-group.tsx | 28 +- web/components/feed/feed-comments.tsx | 242 +++++++++++------- web/components/groups/group-chat.tsx | 104 ++++---- web/lib/firebase/comments.ts | 9 +- web/pages/[username]/[contractSlug].tsx | 1 + 12 files changed, 266 insertions(+), 196 deletions(-) diff --git a/common/comment.ts b/common/comment.ts index a217b292..0d0c4daf 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,5 +1,3 @@ -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 = { @@ -11,9 +9,7 @@ export type Comment = { replyToCommentId?: string userId: string - /** @deprecated - content now stored as JSON in content*/ - text?: string - content: JSONContent + text: string createdTime: number // Denormalized, for rendering comments diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 6e312906..e16920f7 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 { getValues } from './utils' +import { getUserByUsername, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -17,7 +17,6 @@ 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 = { @@ -156,6 +155,17 @@ 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)[] @@ -291,7 +301,8 @@ export const createNotification = async ( if (sourceType === 'comment') { if (recipients?.[0] && relatedSourceType) notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) + if (sourceText) + notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -416,7 +427,7 @@ export const createGroupCommentNotification = async ( sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: richTextToString(comment.content), + sourceText: comment.text, sourceSlug, sourceTitle: `${group.name}`, isSeenOnHref: sourceSlug, diff --git a/functions/src/emails.ts b/functions/src/emails.ts index d594ae65..b7469e9f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -17,7 +17,6 @@ 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') @@ -292,8 +291,7 @@ export const sendNewCommentEmail = async ( const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { content } = comment - const text = richTextToString(content) + const { text } = comment 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 a8bc567e..4719fd08 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 { compact, uniq } from 'lodash' +import { 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,10 +71,7 @@ export const onCreateCommentOnContract = functions const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId - - const recipients = uniq( - compact([...parseMentions(comment.content), repliedUserId]) - ) + const recipients = repliedUserId ? [repliedUserId] : [] await createNotification( comment.id, @@ -82,7 +79,7 @@ export const onCreateCommentOnContract = functions 'created', commentCreator, eventId, - richTextToString(comment.content), + comment.text, { contract, relatedSourceType, recipients } ) diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 2a467f6d..f8e1d7e1 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,8 +50,7 @@ export function UserCommentsList(props: { function ProfileComment(props: { comment: Comment; className?: string }) { const { comment, className } = props - const { text, content, userUsername, userName, userAvatarUrl, createdTime } = - comment + const { text, userUsername, userName, userAvatarUrl, createdTime } = comment // TODO: find and attach relevant bets by comment betId at some point return ( @@ -65,7 +64,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 6f1a778d..deb9b857 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -107,6 +107,7 @@ 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 f71e8589..963cea7e 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -41,16 +41,14 @@ export function useTextEditor(props: { max?: number defaultValue?: Content disabled?: boolean - simple?: boolean }) { - const { placeholder, max, defaultValue = '', disabled, simple } = props + const { placeholder, max, defaultValue = '', disabled } = props const users = useUsers() const editorClass = clsx( proseClass, - !simple && 'min-h-[6em]', - 'outline-none pt-2 px-4' + 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' ) const editor = useEditor( @@ -58,8 +56,7 @@ export function useTextEditor(props: { editorProps: { attributes: { class: editorClass } }, extensions: [ StarterKit.configure({ - heading: simple ? false : { levels: [1, 2, 3] }, - horizontalRule: simple ? false : {}, + heading: { levels: [1, 2, 3] }, }), Placeholder.configure({ placeholder, @@ -123,9 +120,8 @@ function isValidIframe(text: string) { export function TextEditor(props: { editor: Editor | null upload: ReturnType - children?: React.ReactNode // additional toolbar buttons }) { - const { editor, upload, children } = props + const { editor, upload } = props const [iframeOpen, setIframeOpen] = useState(false) return ( @@ -147,10 +143,20 @@ export function TextEditor(props: { images! )} -
+
- {/* Toolbar, with buttons for images and embeds */} -
+ {/* Spacer element to match the height of the toolbar */} + + + {/* Toolbar, with buttons for image and embeds */} +
+
Embed an iframe
-
- {children}
diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index edaf1fe5..aabb1081 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 [replyToUser, setReplyToUser] = - useState>() + const [replyToUsername, setReplyToUsername] = useState('') const [showReply, setShowReply] = useState(false) + const [inputRef, setInputRef] = useState(null) const [highlighted, setHighlighted] = useState(false) const router = useRouter() @@ -70,14 +70,9 @@ export function FeedAnswerCommentGroup(props: { const scrollAndOpenReplyInput = useEvent( (comment?: Comment, answer?: Answer) => { - setReplyToUser( - comment - ? { id: comment.userId, username: comment.userUsername } - : answer - ? { id: answer.userId, username: answer.username } - : undefined - ) + setReplyToUsername(comment?.userUsername ?? answer?.username ?? '') setShowReply(true) + inputRef?.focus() } ) @@ -85,7 +80,7 @@ export function FeedAnswerCommentGroup(props: { // Only show one comment input for a bet at a time if ( betsByCurrentUser.length > 1 && - // inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty + inputRef?.textContent?.length === 0 && betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0] ?.outcome !== answer.number.toString() ) @@ -94,6 +89,10 @@ 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) @@ -155,6 +154,7 @@ export function FeedAnswerCommentGroup(props: { commentsList={commentsList} betsByUserId={betsByUserId} smallAvatar={true} + truncate={false} bets={bets} tips={tips} scrollAndOpenReplyInput={scrollAndOpenReplyInput} @@ -172,8 +172,12 @@ export function FeedAnswerCommentGroup(props: { betsByCurrentUser={betsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser} parentAnswerOutcome={answer.number.toString()} - replyToUser={replyToUser} - onSubmitComment={() => setShowReply(false)} + replyToUsername={replyToUsername} + setRef={setInputRef} + onSubmitComment={() => { + setShowReply(false) + setReplyToUsername('') + }} />
)} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index fd2dbde2..f4c6eb74 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -13,22 +13,25 @@ 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 @@ -36,12 +39,20 @@ export function FeedCommentThread(props: { tips: CommentTipMap parentComment: Comment bets: Bet[] + truncate?: boolean smallAvatar?: boolean }) { - const { contract, comments, bets, tips, smallAvatar, parentComment } = props + const { + contract, + comments, + bets, + tips, + truncate, + smallAvatar, + parentComment, + } = props const [showReply, setShowReply] = useState(false) - const [replyToUser, setReplyToUser] = - useState<{ id: string; username: string }>() + const [replyToUsername, setReplyToUsername] = useState('') const betsByUserId = groupBy(bets, (bet) => bet.userId) const user = useUser() const commentsList = comments.filter( @@ -49,12 +60,15 @@ export function FeedCommentThread(props: { parentComment.id && comment.replyToCommentId === parentComment.id ) commentsList.unshift(parentComment) - + const [inputRef, setInputRef] = useState(null) function scrollAndOpenReplyInput(comment: Comment) { - setReplyToUser({ id: comment.userId, username: comment.userUsername }) + setReplyToUsername(comment.userUsername) setShowReply(true) + inputRef?.focus() } - + useEffect(() => { + if (showReply && inputRef) inputRef.focus() + }, [inputRef, showReply]) return ( @@ -83,9 +98,13 @@ export function FeedCommentThread(props: { (c) => c.userId === user?.id )} parentCommentId={parentComment.id} - replyToUser={replyToUser} + replyToUsername={replyToUsername} parentAnswerOutcome={comments[0].answerOutcome} - onSubmitComment={() => setShowReply(false)} + setRef={setInputRef} + onSubmitComment={() => { + setShowReply(false) + setReplyToUsername('') + }} /> )} @@ -102,12 +121,14 @@ export function CommentRepliesList(props: { bets: Bet[] treatFirstIndexEqually?: boolean smallAvatar?: boolean + truncate?: boolean }) { const { contract, commentsList, betsByUserId, tips, + truncate, smallAvatar, bets, scrollAndOpenReplyInput, @@ -147,6 +168,7 @@ export function CommentRepliesList(props: { : undefined } smallAvatar={smallAvatar} + truncate={truncate} />
))} @@ -160,6 +182,7 @@ export function FeedComment(props: { tips: CommentTips betsBySameUser: Bet[] probAtCreatedTime?: number + truncate?: boolean smallAvatar?: boolean onReplyClick?: (comment: Comment) => void }) { @@ -169,10 +192,10 @@ export function FeedComment(props: { tips, betsBySameUser, probAtCreatedTime, + truncate, onReplyClick, } = props - const { text, content, userUsername, userName, userAvatarUrl, createdTime } = - comment + const { text, userUsername, userName, userAvatarUrl, createdTime } = comment let betOutcome: string | undefined, bought: string | undefined, money: string | undefined @@ -253,9 +276,11 @@ export function FeedComment(props: { elementId={comment.id} />
-
- -
+ {onReplyClick && ( @@ -320,7 +345,8 @@ export function CommentInput(props: { contract: Contract betsByCurrentUser: Bet[] commentsByCurrentUser: Comment[] - replyToUser?: { id: string; username: string } + replyToUsername?: string + setRef?: (ref: HTMLTextAreaElement) => void // Reply to a free response answer parentAnswerOutcome?: string // Reply to another comment @@ -333,18 +359,12 @@ export function CommentInput(props: { commentsByCurrentUser, parentAnswerOutcome, parentCommentId, - replyToUser, + replyToUsername, onSubmitComment, + setRef, } = props const user = useUser() - const { editor, upload } = useTextEditor({ - simple: true, - max: MAX_COMMENT_LENGTH, - placeholder: - !!parentCommentId || !!parentAnswerOutcome - ? 'Write a reply...' - : 'Write a comment...', - }) + const [comment, setComment] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const mostRecentCommentableBet = getMostRecentCommentableBet( @@ -360,17 +380,18 @@ export function CommentInput(props: { track('sign in to comment') return await firebaseLogin() } - if (!editor || editor.isEmpty || isSubmitting) return + if (!comment || isSubmitting) return setIsSubmitting(true) await createCommentOnContract( contract.id, - editor.getJSON(), + comment, user, betId, parentAnswerOutcome, parentCommentId ) onSubmitComment?.() + setComment('') setIsSubmitting(false) } @@ -425,12 +446,14 @@ export function CommentInput(props: { )} @@ -442,89 +465,94 @@ export function CommentInput(props: { export function CommentInputTextArea(props: { user: User | undefined | null - replyToUser?: { id: string; username: string } - editor: Editor | null - upload: Parameters[0]['upload'] + isReply: boolean + replyToUsername: string + commentText: string + setComment: (text: string) => void submitComment: (id?: string) => void isSubmitting: boolean - submitOnEnter?: boolean + setRef?: (ref: HTMLTextAreaElement) => void presetId?: string + enterToSubmitOnDesktop?: boolean }) { const { + isReply, + setRef, user, - editor, - upload, + commentText, + setComment, submitComment, presetId, isSubmitting, - submitOnEnter, - replyToUser, + replyToUsername, + enterToSubmitOnDesktop, } = props - const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch) - + const { width } = useWindowSize() + const memoizedSetComment = useEvent(setComment) useEffect(() => { - editor?.setEditable(!isSubmitting) - }, [isSubmitting, editor]) - - const submit = () => { - submitComment(presetId) - editor?.commands?.clearContent() - } - - useEffect(() => { - if (!editor) { - return - } - // submit on Enter key - editor.setOptions({ - editorProps: { - handleKeyDown: (view, event) => { - if ( - submitOnEnter && - event.key === 'Enter' && - !event.shiftKey && - (!isMobile || event.ctrlKey || event.metaKey) && - // mention list is closed - !(view.state as any).mention$.active - ) { - submit() - event.preventDefault() - return true - } - return false - }, - }, - }) - // insert at mention - if (replyToUser) { - editor.commands.insertContentAt(0, { - type: 'mention', - attrs: { label: replyToUser.username, id: replyToUser.id }, - }) - editor.commands.focus() - } + if (!replyToUsername || !user || replyToUsername === user.username) return + const replacement = `@${replyToUsername} ` + memoizedSetComment(replacement + commentText.replace(replacement, '')) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor]) - + }, [user, replyToUsername, memoizedSetComment]) return ( <> -
- + +