lbry-desktop/ui/component/comment/view.jsx

458 lines
16 KiB
React
Raw Normal View History

2019-06-27 01:59:27 +02:00
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
2021-08-31 09:05:42 +02:00
import * as KEYCODES from 'constants/keycodes';
import { COMMENT_HIGHLIGHTED } from 'constants/classnames';
import { SORT_BY, COMMENT_PAGE_SIZE_REPLIES } 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';
2019-07-21 22:46:30 +02:00
import Button from 'component/button';
import Expandable from 'component/expandable';
2019-10-13 06:04:16 +02:00
import MarkdownPreview from 'component/common/markdown-preview';
import CommentBadge from 'component/common/comment-badge';
2019-10-23 09:04:40 +02:00
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';
2020-02-05 04:55:00 +01:00
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';
2020-10-07 21:14:52 +02:00
import CommentCreate from 'component/commentCreate';
import CommentMenuList from 'component/commentMenuList';
import UriIndicator from 'component/uriIndicator';
2021-04-23 21:59:48 +02:00
import CreditAmount from 'component/common/credit-amount';
import OptimizedImage from 'component/optimizedImage';
import { getChannelFromClaim } from 'util/claim';
import { parseSticker } from 'util/comments';
2022-02-01 21:31:39 +01:00
import { useIsMobile } from 'effects/use-screensize';
2019-06-27 01:59:27 +02:00
const AUTO_EXPAND_ALL_REPLIES = false;
2019-06-27 01:59:27 +02:00
type Props = {
clearPlayingUri: () => void,
2020-02-05 04:55:00 +01:00
uri: string,
claim: StreamClaim,
author: ?string, // LBRY Channel Name, e.g. @channel
authorUri: string, // full LBRY Channel URI: lbry://@channel#123...
commentId: string, // sha256 digest identifying the comment
message: string, // comment body
timePosted: number, // Comment timestamp
channelIsBlocked: boolean, // if the channel is blacklisted in the app
claimIsMine: boolean, // if you control the claim which this comment was posted on
commentIsMine: boolean, // if this comment was signed by an owned channel
updateComment: (string, string) => void,
fetchReplies: (string, string, number, number, number) => void,
totalReplyPages: number,
commentModBlock: (string) => void,
linkedCommentId?: string,
linkedCommentAncestors: { [string]: Array<string> },
hasChannels: boolean,
commentingEnabled: boolean,
doToast: ({ message: string }) => void,
isTopLevel?: boolean,
2020-10-07 21:14:52 +02:00
threadDepth: number,
hideActions?: boolean,
isPinned: boolean,
othersReacts: ?{
like: number,
dislike: number,
},
2020-10-20 05:20:38 +02:00
commentIdentityChannel: any,
activeChannelClaim: ?ChannelClaim,
playingUri: ?PlayingUri,
stakedLevel: number,
2021-04-23 21:59:48 +02:00
supportAmount: number,
numDirectReplies: number,
isModerator: boolean,
isGlobalMod: boolean,
isFiat: boolean,
2021-08-27 12:29:58 +02:00
supportDisabled: boolean,
setQuickReply: (any) => void,
quickReply: any,
2019-06-27 01:59:27 +02:00
};
const LENGTH_TO_COLLAPSE = 300;
2019-06-27 01:59:27 +02:00
function Comment(props: Props) {
2019-10-23 09:04:40 +02:00
const {
clearPlayingUri,
claim,
2020-02-05 04:55:00 +01:00
uri,
2019-10-23 09:04:40 +02:00
author,
authorUri,
timePosted,
message,
channelIsBlocked,
commentIsMine,
commentId,
updateComment,
fetchReplies,
totalReplyPages,
linkedCommentId,
linkedCommentAncestors,
commentingEnabled,
hasChannels,
doToast,
isTopLevel,
2020-10-07 21:14:52 +02:00
threadDepth,
hideActions,
isPinned,
othersReacts,
playingUri,
stakedLevel,
2021-04-23 21:59:48 +02:00
supportAmount,
numDirectReplies,
isModerator,
isGlobalMod,
isFiat,
2021-08-27 12:29:58 +02:00
supportDisabled,
setQuickReply,
quickReply,
2019-10-23 09:04:40 +02:00
} = props;
2022-02-01 21:31:39 +01:00
const isMobile = useIsMobile();
const {
push,
replace,
location: { pathname, search },
} = useHistory();
const isLinkedComment = linkedCommentId && linkedCommentId === commentId;
const isInLinkedCommentChain =
linkedCommentId &&
linkedCommentAncestors[linkedCommentId] &&
linkedCommentAncestors[linkedCommentId].includes(commentId);
const showRepliesOnMount = 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);
2020-08-24 19:35:21 +02:00
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);
let channelOwnerOfContent;
try {
const { channelName } = parseURI(uri);
if (channelName) {
channelOwnerOfContent = channelName;
}
} catch (e) {}
2019-12-04 19:07:40 +01:00
2019-10-23 09:04:40 +02:00
useEffect(() => {
if (isEditing) {
setCharCount(editedMessage.length);
// a user will try and press the escape key to cancel editing their comment
const handleEscape = (event) => {
2021-08-31 09:05:42 +02:00
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);
}
2021-01-26 20:50:44 +01:00
function handleEditComment() {
if (playingUri && playingUri.source === 'comment') {
clearPlayingUri();
}
2021-01-26 20:50:44 +01:00
setEditing(true);
}
function handleSubmit() {
updateComment(commentId, editedMessage);
2021-08-27 12:29:58 +02:00
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 {
2020-10-07 21:14:52 +02:00
setReplying(!isReplying);
}
}
function handleTimeClick() {
const urlParams = new URLSearchParams(search);
urlParams.delete('lc');
urlParams.append('lc', commentId);
replace(`${pathname}?${urlParams.toString()}`);
}
const linkedCommentRef = React.useCallback(
(node) => {
if (node !== null && window.pendingLinkedCommentScroll) {
const ROUGH_HEADER_HEIGHT = 125; // @see: --header-height
delete window.pendingLinkedCommentScroll;
2022-02-04 22:39:48 +01:00
const mobileChatElem = document.querySelector('.MuiPaper-root .card--enable-overflow');
const drawerElem = document.querySelector('.MuiDrawer-root');
const elem = (isMobile && mobileChatElem) || window;
if (elem) {
// $FlowFixMe
elem.scrollTo({
2022-02-04 22:39:48 +01:00
top:
node.getBoundingClientRect().top +
// $FlowFixMe
(mobileChatElem && drawerElem ? drawerElem.getBoundingClientRect().top * -1 : elem.scrollY) -
ROUGH_HEADER_HEIGHT,
left: 0,
behavior: 'smooth',
});
}
}
},
[isMobile]
);
2019-06-27 01:59:27 +02:00
return (
2020-02-05 04:55:00 +01:00
<li
2020-08-24 19:35:21 +02:00
className={classnames('comment', {
2020-10-07 21:14:52 +02:00
'comment--top-level': isTopLevel,
'comment--reply': !isTopLevel,
2021-04-23 21:59:48 +02:00
'comment--superchat': supportAmount > 0,
2020-08-24 19:35:21 +02:00
})}
id={commentId}
2020-02-05 04:55:00 +01:00
>
<div
ref={isLinkedComment ? linkedCommentRef : undefined}
className={classnames('comment__content', {
[COMMENT_HIGHLIGHTED]: isLinkedComment,
'comment--slimed': slimedToDeath && !displayDeadComment,
})}
>
2021-04-23 21:59:48 +02:00
<div className="comment__thumbnail-wrapper">
{authorUri ? (
ChannelThumbnail improvements - [x] (6332) The IntersectionObserver method of lazy-loading loads cached images visibly late on slower devices. Previously, it was also showing the "broken image" icon briefly, which we mended by placing a dummy transparent image as the initial src. - Reverted that ugly transparent image fix. - Use the browser's built-in `loading="lazy"` instead. Sorry, Safari. - [x] Size-optimization did not take "device pixel ratio" into account. - When resizing an image through the CDN, we can't just take the dimensions of the tag in pixels directly -- we need to take zooming into account, otherwise the image ends up blurry. - Previously, we quickly disabled optimization for the channel avatar in the Channel Page because of this. Now that we know the root-cause, the change was reverted and we now go through the CDN with appropriate sizes. This also improves our Web Vital scores. - [x] Size-optimization wasn't really implemented for all ChannelThumbnail instances. - The CDN-optimized size was hardcoded to the largest instance, so small images like sidebar thumbnails are still loading images that are unnecessarily larger. - There's a little-bit of hardcoding of values from CSS here, but I think it's a ok compromise (not something we change often). It also doesn't need to be exact -- the "device pixel ratio" calculate will ensure it's slightly larger than what we need. - [x] Set `width` and `height` of `<img>` to improve CLS. - Addresses Ligthhouse complaints, although technically the shifting was addressed at the `ClaimPreviewTile` level (sub-container dimensions are well defined). - Notes: the values don't need to be the final CSS-adjusted sizes. It just needs to be in the right aspect ratio to help the browser pre-allocate space to avoid shifts. - [x] Add option to disable lazy-load Channel Thumbnails - The guidelines mentioned that items that are already in the viewport should not enable `loading="lazy"`. - We have a few areas where it doesn't make sense to lazy-load (e.g. thumbnail in Header, channel selector dropdown, publish preview, etc.).
2021-07-05 07:20:40 +02:00
<ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} xsmall className="comment__author-thumbnail" />
2021-04-23 21:59:48 +02:00
) : (
ChannelThumbnail improvements - [x] (6332) The IntersectionObserver method of lazy-loading loads cached images visibly late on slower devices. Previously, it was also showing the "broken image" icon briefly, which we mended by placing a dummy transparent image as the initial src. - Reverted that ugly transparent image fix. - Use the browser's built-in `loading="lazy"` instead. Sorry, Safari. - [x] Size-optimization did not take "device pixel ratio" into account. - When resizing an image through the CDN, we can't just take the dimensions of the tag in pixels directly -- we need to take zooming into account, otherwise the image ends up blurry. - Previously, we quickly disabled optimization for the channel avatar in the Channel Page because of this. Now that we know the root-cause, the change was reverted and we now go through the CDN with appropriate sizes. This also improves our Web Vital scores. - [x] Size-optimization wasn't really implemented for all ChannelThumbnail instances. - The CDN-optimized size was hardcoded to the largest instance, so small images like sidebar thumbnails are still loading images that are unnecessarily larger. - There's a little-bit of hardcoding of values from CSS here, but I think it's a ok compromise (not something we change often). It also doesn't need to be exact -- the "device pixel ratio" calculate will ensure it's slightly larger than what we need. - [x] Set `width` and `height` of `<img>` to improve CLS. - Addresses Ligthhouse complaints, although technically the shifting was addressed at the `ClaimPreviewTile` level (sub-container dimensions are well defined). - Notes: the values don't need to be the final CSS-adjusted sizes. It just needs to be in the right aspect ratio to help the browser pre-allocate space to avoid shifts. - [x] Add option to disable lazy-load Channel Thumbnails - The guidelines mentioned that items that are already in the viewport should not enable `loading="lazy"`. - We have a few areas where it doesn't make sense to lazy-load (e.g. thumbnail in Header, channel selector dropdown, publish preview, etc.).
2021-07-05 07:20:40 +02:00
<ChannelThumbnail xsmall className="comment__author-thumbnail" />
2021-04-23 21:59:48 +02:00
)}
</div>
2019-10-24 19:24:53 +02:00
2021-04-23 21:59:48 +02:00
<div className="comment__body-container">
<div className="comment__meta">
<div className="comment__meta-information">
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} />}
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
{!author ? (
<span className="comment__author">{__('Anonymous')}</span>
) : (
<UriIndicator
className={classnames('comment__author', {
'comment__author--creator': commentByOwnerOfContent,
})}
link
uri={authorUri}
/>
)}
2021-04-23 21:59:48 +02:00
<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">
2020-10-20 05:20:38 +02:00
<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}
2021-07-19 23:22:39 +02:00
supportAmount={supportAmount}
2021-08-27 12:29:58 +02:00
setQuickReply={setQuickReply}
/>
</Menu>
</div>
</div>
<div>
{isEditing ? (
<Form onSubmit={handleSubmit}>
<FormField
2021-04-23 21:59:48 +02:00
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}
/>
2021-04-23 21:59:48 +02:00
<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">
2020-10-09 17:38:50 +02:00
{__('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>
) : editedMessage.length >= LENGTH_TO_COLLAPSE ? (
<Expandable>
<MarkdownPreview
content={message}
promptLinks
parentCommentId={commentId}
stakedLevel={stakedLevel}
/>
</Expandable>
) : (
<MarkdownPreview
content={message}
promptLinks
parentCommentId={commentId}
stakedLevel={stakedLevel}
/>
)}
</div>
{!hideActions && (
<div className="comment__actions">
{threadDepth !== 0 && (
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Log in to reply')}
className="comment__action"
onClick={handleCommentReply}
icon={ICONS.REPLY}
2022-02-01 21:31:39 +01:00
iconSize={isMobile && 12}
/>
)}
{ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />}
</div>
)}
2020-10-07 21:14:52 +02:00
{numDirectReplies > 0 && !showReplies && (
<div className="comment__actions">
<Button
label={
numDirectReplies < 2
? __('Show reply')
: __('Show %count% replies', { count: numDirectReplies })
}
button="link"
onClick={() => {
setShowReplies(true);
if (page === 0) {
setPage(1);
}
}}
2021-08-23 09:19:23 +02:00
icon={ICONS.DOWN}
/>
</div>
)}
{numDirectReplies > 0 && showReplies && (
<div className="comment__actions">
2021-08-23 09:19:23 +02:00
<Button
label={__('Hide replies')}
button="link"
onClick={() => setShowReplies(false)}
icon={ICONS.UP}
/>
</div>
)}
2020-10-07 21:14:52 +02:00
{isReplying && (
<CommentCreate
isReply
uri={uri}
parentId={commentId}
onDoneReplying={() => {
setShowReplies(true);
setReplying(false);
}}
onCancelReplying={() => {
setReplying(false);
}}
2021-08-27 12:29:58 +02:00
supportDisabled={supportDisabled}
2020-10-07 21:14:52 +02:00
/>
)}
</>
)}
</div>
2019-10-24 19:24:53 +02:00
</div>
</div>
{showReplies && (
<CommentsReplies
threadDepth={threadDepth - 1}
uri={uri}
parentId={commentId}
linkedCommentId={linkedCommentId}
numDirectReplies={numDirectReplies}
onShowMore={() => setPage(page + 1)}
hasMore={page < totalReplyPages}
/>
)}
2019-06-27 01:59:27 +02:00
</li>
);
}
export default Comment;