/** * Comment component. * * Notes: * - Filtration is not done at this component level. Comments are filtered * in the selector through `filterComments()`. This saves the need to handle * it from the render loop, but also means we cannot render it differently * (e.g. displaying as "Comment has been blocked") since the component doesn't * see it. */ // @flow import * as ICONS from 'constants/icons'; import * as PAGES from 'constants/pages'; import * as KEYCODES from 'constants/keycodes'; import { COMMENT_HIGHLIGHTED } from 'constants/classnames'; import { SORT_BY, COMMENT_PAGE_SIZE_REPLIES, LINKED_COMMENT_QUERY_PARAM, THREAD_COMMENT_QUERY_PARAM, } from 'constants/comment'; import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field'; import { SITE_NAME, SIMPLE_SITE, ENABLE_COMMENT_REACTIONS } from 'config'; import React, { useEffect, useState } from 'react'; import { parseURI } from 'util/lbryURI'; import DateTime from 'component/dateTime'; import Button from 'component/button'; import Expandable from 'component/expandable'; import MarkdownPreview from 'component/common/markdown-preview'; import CommentBadge from 'component/common/comment-badge'; import ChannelThumbnail from 'component/channelThumbnail'; import { Menu, MenuButton } from '@reach/menu-button'; import Icon from 'component/common/icon'; import { FormField, Form } from 'component/common/form'; import classnames from 'classnames'; import usePersistedState from 'effects/use-persisted-state'; import CommentReactions from 'component/commentReactions'; import CommentsReplies from 'component/commentsReplies'; import { useHistory } from 'react-router'; import CommentCreate from 'component/commentCreate'; import CommentMenuList from 'component/commentMenuList'; import UriIndicator from 'component/uriIndicator'; import CreditAmount from 'component/common/credit-amount'; import OptimizedImage from 'component/optimizedImage'; import { getChannelFromClaim } from 'util/claim'; import { parseSticker } from 'util/comments'; import { useIsMobile } from 'effects/use-screensize'; import PremiumBadge from 'component/common/premium-badge'; import Spinner from 'component/spinner'; const AUTO_EXPAND_ALL_REPLIES = false; type Props = { comment: Comment, myChannelIds: ?Array<string>, clearPlayingUri: () => void, uri: string, claim: StreamClaim, claimIsMine: boolean, // if you control the claim which this comment was posted on updateComment: (string, string) => void, fetchReplies: (string, string, number, number, number) => void, totalReplyPages: number, commentModBlock: (string) => void, linkedCommentId?: string, threadCommentId?: string, linkedCommentAncestors: { [string]: Array<string> }, hasChannels: boolean, commentingEnabled: boolean, doToast: ({ message: string }) => void, isTopLevel?: boolean, hideActions?: boolean, othersReacts: ?{ like: number, dislike: number, }, commentIdentityChannel: any, activeChannelClaim: ?ChannelClaim, playingUri: PlayingUri, stakedLevel: number, supportDisabled: boolean, setQuickReply: (any) => void, quickReply: any, commenterMembership: ?string, fetchedReplies: Array<Comment>, repliesFetching: boolean, threadLevel?: number, threadDepthLevel?: number, }; const LENGTH_TO_COLLAPSE = 300; function CommentView(props: Props) { const { comment, myChannelIds, clearPlayingUri, claim, uri, updateComment, fetchReplies, totalReplyPages, linkedCommentId, threadCommentId, linkedCommentAncestors, commentingEnabled, hasChannels, doToast, isTopLevel, hideActions, othersReacts, playingUri, stakedLevel, supportDisabled, setQuickReply, quickReply, commenterMembership, fetchedReplies, repliesFetching, threadLevel = 0, threadDepthLevel = 0, } = props; const { channel_url: authorUri, channel_name: author, channel_id: channelId, comment_id: commentId, comment: message, is_fiat: isFiat, is_global_mod: isGlobalMod, is_moderator: isModerator, is_pinned: isPinned, support_amount: supportAmount, replies: numDirectReplies, timestamp, } = comment; const timePosted = timestamp * 1000; const commentIsMine = channelId && myChannelIds && myChannelIds.includes(channelId); const isMobile = useIsMobile(); const ROUGH_HEADER_HEIGHT = isMobile ? 56 : 60; // @see: --header-height const lastThreadLevel = threadDepthLevel - 1; // Mobile: 0, 1, 2 -> new thread....., so each 3 comments const openNewThread = threadLevel > 0 && threadLevel % lastThreadLevel === 0; const { push, replace, location: { pathname, search }, } = useHistory(); const urlParams = new URLSearchParams(search); const isLinkedComment = linkedCommentId && linkedCommentId === commentId; const isThreadComment = threadCommentId && threadCommentId === commentId; const isInLinkedCommentChain = linkedCommentId && linkedCommentAncestors[linkedCommentId] && linkedCommentAncestors[linkedCommentId].includes(commentId); const showRepliesOnMount = isThreadComment || isInLinkedCommentChain || AUTO_EXPAND_ALL_REPLIES; const [isReplying, setReplying] = React.useState(false); const [isEditing, setEditing] = useState(false); const [editedMessage, setCommentValue] = useState(message); const [charCount, setCharCount] = useState(editedMessage.length); const [showReplies, setShowReplies] = useState(showRepliesOnMount); const [page, setPage] = useState(showRepliesOnMount ? 1 : 0); const [advancedEditor] = usePersistedState('comment-editor-mode', false); const [displayDeadComment, setDisplayDeadComment] = React.useState(false); const likesCount = (othersReacts && othersReacts.like) || 0; const dislikesCount = (othersReacts && othersReacts.dislike) || 0; const totalLikesAndDislikes = likesCount + dislikesCount; const slimedToDeath = totalLikesAndDislikes >= 5 && dislikesCount / totalLikesAndDislikes > 0.8; const contentChannelClaim = getChannelFromClaim(claim); const commentByOwnerOfContent = contentChannelClaim && contentChannelClaim.permanent_url === authorUri; const stickerFromMessage = parseSticker(message); const isExpandable = editedMessage.length >= LENGTH_TO_COLLAPSE; let channelOwnerOfContent; try { const { channelName } = parseURI(uri); if (channelName) { channelOwnerOfContent = channelName; } } catch (e) {} useEffect(() => { if (isEditing) { setCharCount(editedMessage.length); // a user will try and press the escape key to cancel editing their comment const handleEscape = (event) => { if (event.keyCode === KEYCODES.ESCAPE) { setEditing(false); } }; window.addEventListener('keydown', handleEscape); // removes the listener so it doesn't cause problems elsewhere in the app return () => { window.removeEventListener('keydown', handleEscape); }; } }, [author, authorUri, editedMessage, isEditing, setEditing]); useEffect(() => { if (page > 0) { fetchReplies(uri, commentId, page, COMMENT_PAGE_SIZE_REPLIES, SORT_BY.OLDEST); } }, [page, uri, commentId, fetchReplies]); function handleEditMessageChanged(event) { setCommentValue(!SIMPLE_SITE && advancedEditor ? event : event.target.value); } function handleEditComment() { if (playingUri.source === 'comment') { clearPlayingUri(); } setEditing(true); } function handleSubmit() { updateComment(commentId, editedMessage); if (setQuickReply) setQuickReply({ ...quickReply, comment_id: commentId, comment: editedMessage }); setEditing(false); } function handleCommentReply() { if (!hasChannels) { push(`/$/${PAGES.CHANNEL_NEW}?redirect=${pathname}`); doToast({ message: __('A channel is required to comment on %SITE_NAME%', { SITE_NAME }) }); } else { setReplying(!isReplying); } } function handleTimeClick() { urlParams.set(LINKED_COMMENT_QUERY_PARAM, commentId); replace(`${pathname}?${urlParams.toString()}`); } function handleOpenNewThread() { urlParams.set(LINKED_COMMENT_QUERY_PARAM, commentId); urlParams.set(THREAD_COMMENT_QUERY_PARAM, commentId); push({ pathname, search: urlParams.toString() }); } const linkedCommentRef = React.useCallback( (node) => { if (node !== null && window.pendingLinkedCommentScroll) { delete window.pendingLinkedCommentScroll; const mobileChatElem = document.querySelector('.MuiPaper-root .card--enable-overflow'); const elem = (isMobile && mobileChatElem) || window; if (elem) { // $FlowFixMe elem.scrollTo({ // $FlowFixMe top: node.getBoundingClientRect().top + (mobileChatElem ? 0 : elem.scrollY) - ROUGH_HEADER_HEIGHT, left: 0, behavior: 'smooth', }); } } }, [ROUGH_HEADER_HEIGHT, isMobile] ); return ( <li className={classnames('comment', { 'comment--top-level': isTopLevel, 'comment--reply': !isTopLevel, 'comment--superchat': supportAmount > 0, })} id={commentId} > <div className="comment__thumbnail-wrapper"> {authorUri ? ( <ChannelThumbnail uri={authorUri} xsmall className="comment__author-thumbnail" checkMembership={false} /> ) : ( <ChannelThumbnail xsmall className="comment__author-thumbnail" checkMembership={false} /> )} {numDirectReplies > 0 && showReplies && ( <Button className="comment__threadline" aria-label="Hide Replies" onClick={() => setShowReplies(false)} /> )} </div> <div className="comment__content" ref={isLinkedComment || isThreadComment ? linkedCommentRef : undefined}> <div className={classnames('comment__body-container', { [COMMENT_HIGHLIGHTED]: isLinkedComment || (isThreadComment && !linkedCommentId), 'comment--slimed': slimedToDeath && !displayDeadComment, })} > <div className="comment__meta"> <div className="comment__meta-information"> {!author ? ( <span className="comment__author">{__('Anonymous')}</span> ) : ( <UriIndicator className={classnames('comment__author', { 'comment__author--creator': commentByOwnerOfContent, })} link uri={authorUri} comment showAtSign /> )} {isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_ADMIN} />} {isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />} <PremiumBadge membership={commenterMembership} linkPage /> <Button className="comment__time" onClick={handleTimeClick} label={<DateTime date={timePosted} timeAgo />} /> {supportAmount > 0 && <CreditAmount isFiat={isFiat} amount={supportAmount} superChatLight size={12} />} {isPinned && ( <span className="comment__pin"> <Icon icon={ICONS.PIN} size={14} /> {channelOwnerOfContent ? __('Pinned by @%channel%', { channel: channelOwnerOfContent }) : __('Pinned by creator')} </span> )} </div> <div className="comment__menu"> <Menu> <MenuButton className="menu__button"> <Icon size={18} icon={ICONS.MORE_VERTICAL} /> </MenuButton> <CommentMenuList uri={uri} isTopLevel={isTopLevel} isPinned={isPinned} commentId={commentId} authorUri={authorUri} commentIsMine={commentIsMine} handleEditComment={handleEditComment} supportAmount={supportAmount} setQuickReply={setQuickReply} /> </Menu> </div> </div> <div> {isEditing ? ( <Form onSubmit={handleSubmit}> <FormField className="comment__edit-input" type={!SIMPLE_SITE && advancedEditor ? 'markdown' : 'textarea'} name="editing_comment" value={editedMessage} charCount={charCount} onChange={handleEditMessageChanged} textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT} handleSubmit={handleSubmit} /> <div className="section__actions section__actions--no-margin"> <Button button="primary" type="submit" label={__('Done')} requiresAuth={IS_WEB} disabled={message === editedMessage} /> <Button button="link" label={__('Cancel')} onClick={() => setEditing(false)} /> </div> </Form> ) : ( <> <div className="comment__message"> {slimedToDeath && !displayDeadComment ? ( <div onClick={() => setDisplayDeadComment(true)} className="comment__dead"> {__('This comment was slimed to death.')} <Icon icon={ICONS.SLIME_ACTIVE} /> </div> ) : stickerFromMessage ? ( <div className="sticker__comment"> <OptimizedImage src={stickerFromMessage.url} waitLoad loading="lazy" /> </div> ) : isExpandable ? ( <Expandable beginCollapsed> <MarkdownPreview content={message} promptLinks parentCommentId={commentId} stakedLevel={stakedLevel} hasMembership={Boolean(commenterMembership)} /> </Expandable> ) : ( <MarkdownPreview content={message} promptLinks parentCommentId={commentId} stakedLevel={stakedLevel} hasMembership={Boolean(commenterMembership)} /> )} </div> {!hideActions && ( <div className="comment__actions"> <Button requiresAuth={IS_WEB} label={commentingEnabled ? __('Reply') : __('Log in to reply')} className="comment__action" onClick={handleCommentReply} icon={ICONS.REPLY} iconSize={isMobile && 12} /> {ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />} </div> )} {repliesFetching && (!fetchedReplies || fetchedReplies.length === 0) ? ( <span className="comment__actions comment__replies-loading"> <Spinner text={numDirectReplies > 1 ? __('Loading Replies') : __('Loading Reply')} type="small" /> </span> ) : ( numDirectReplies > 0 && ( <div className="comment__actions"> {!showReplies ? ( openNewThread ? ( <Button label={__('Continue Thread')} button="link" onClick={handleOpenNewThread} iconRight={ICONS.ARROW_RIGHT} /> ) : ( <Button label={ numDirectReplies < 2 ? __('Show reply') : __('Show %count% replies', { count: numDirectReplies }) } button="link" onClick={() => { setShowReplies(true); if (page === 0) { setPage(1); } }} iconRight={ICONS.DOWN} /> ) ) : ( <Button label={__('Hide replies')} button="link" onClick={() => setShowReplies(false)} iconRight={ICONS.UP} /> )} </div> ) )} {isReplying && ( <CommentCreate isReply uri={uri} parentId={commentId} onDoneReplying={() => { if (openNewThread) { handleOpenNewThread(); } else { setShowReplies(true); } setReplying(false); }} onCancelReplying={() => { setReplying(false); }} supportDisabled={supportDisabled} /> )} </> )} </div> </div> {showReplies && ( <CommentsReplies threadLevel={threadLevel} uri={uri} parentId={commentId} linkedCommentId={linkedCommentId} threadCommentId={threadCommentId} numDirectReplies={numDirectReplies} onShowMore={() => setPage(page + 1)} hasMore={page < totalReplyPages} threadDepthLevel={threadDepthLevel} /> )} </div> </li> ); } export default CommentView;