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 */} - - - {/* 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 ( <> - -