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

314 lines
11 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';
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 'lbry-redux';
2019-10-23 09:04:40 +02:00
import { isEmpty } from 'util/object';
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';
2019-10-23 09:04:40 +02:00
import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuList, MenuButton, MenuItem } 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';
2019-06-27 01:59:27 +02:00
type Props = {
2020-02-05 04:55:00 +01:00
uri: string,
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
channel: ?Claim, // Channel Claim, retrieved to obtain thumbnail
2019-10-23 09:04:40 +02:00
pending?: boolean,
resolveUri: string => void, // resolves the URI
isResolvingUri: boolean, // if the URI is currently being resolved
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,
deleteComment: string => void,
2020-07-15 18:36:40 +02:00
blockChannel: string => void,
2020-08-24 19:35:21 +02:00
linkedComment?: any,
myChannels: ?Array<ChannelClaim>,
commentingEnabled: boolean,
doToast: ({ message: string }) => void,
isTopLevel?: boolean,
2020-10-07 21:14:52 +02:00
threadDepth: number,
isPinned: boolean,
othersReacts: ?{
like: number,
dislike: number,
},
2019-06-27 01:59:27 +02:00
};
const LENGTH_TO_COLLAPSE = 300;
const ESCAPE_KEY = 27;
2019-06-27 01:59:27 +02:00
function Comment(props: Props) {
2019-10-23 09:04:40 +02:00
const {
2020-02-05 04:55:00 +01:00
uri,
2019-10-23 09:04:40 +02:00
author,
authorUri,
timePosted,
message,
pending,
channel,
2019-10-23 09:04:40 +02:00
isResolvingUri,
resolveUri,
channelIsBlocked,
commentIsMine,
commentId,
updateComment,
deleteComment,
2020-07-15 18:36:40 +02:00
blockChannel,
2020-08-24 19:35:21 +02:00
linkedComment,
commentingEnabled,
myChannels,
doToast,
isTopLevel,
2020-10-07 21:14:52 +02:00
threadDepth,
isPinned,
othersReacts,
2019-10-23 09:04:40 +02:00
} = props;
const {
push,
replace,
location: { pathname, search },
} = useHistory();
const [isReplying, setReplying] = React.useState(false);
const [isEditing, setEditing] = useState(false);
const [editedMessage, setCommentValue] = useState(message);
const [charCount, setCharCount] = useState(editedMessage.length);
// used for controlling the visibility of the menu icon
const [mouseIsHovering, setMouseHover] = useState(false);
2020-08-24 19:35:21 +02:00
const [advancedEditor] = usePersistedState('comment-editor-mode', false);
const [displayDeadComment, setDisplayDeadComment] = React.useState(false);
const hasChannels = myChannels && myChannels.length > 0;
const likesCount = (othersReacts && othersReacts.like) || 0;
const dislikesCount = (othersReacts && othersReacts.dislike) || 0;
const totalLikesAndDislikes = likesCount + dislikesCount;
const slimedToDeath = totalLikesAndDislikes > 5 && dislikesCount / totalLikesAndDislikes > 0.8;
2019-10-23 09:04:40 +02:00
// to debounce subsequent requests
const shouldFetch =
channel === undefined ||
(channel !== null && channel.value_type === 'channel' && isEmpty(channel.meta) && !pending);
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 author was extracted from the URI, then it must be valid.
if (authorUri && author && !isResolvingUri && shouldFetch) {
resolveUri(authorUri);
}
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 === ESCAPE_KEY) {
setEditing(false);
}
};
window.addEventListener('keydown', handleEscape);
// removes the listener so it doesn't cause problems elsewhere in the app
return () => {
window.removeEventListener('keydown', handleEscape);
};
}
}, [isResolvingUri, shouldFetch, author, authorUri, resolveUri, editedMessage, isEditing, setEditing]);
function handleEditMessageChanged(event) {
setCommentValue(!SIMPLE_SITE && advancedEditor ? event : event.target.value);
}
function handleSubmit() {
updateComment(commentId, 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()}`);
}
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,
2020-08-24 19:35:21 +02:00
})}
id={commentId}
2020-07-15 18:36:40 +02:00
onMouseOver={() => setMouseHover(true)}
onMouseOut={() => setMouseHover(false)}
2020-02-05 04:55:00 +01:00
>
<div
className={classnames('comment__content', {
'comment--highlighted': linkedComment && linkedComment.comment_id === commentId,
'comment--slimed': slimedToDeath && !displayDeadComment,
})}
>
<div className="comment__author-thumbnail">
{authorUri ? (
<ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} small />
) : (
<ChannelThumbnail small />
)}
</div>
2019-10-24 19:24:53 +02:00
<div className="comment__body_container">
<div className="comment__meta">
<div className="comment__meta-information">
{!author ? (
<span className="comment__author">{__('Anonymous')}</span>
) : (
<Button
className="button--uri-indicator truncated-text comment__author"
navigate={authorUri}
label={author}
/>
)}
<Button
className="comment__time"
onClick={handleTimeClick}
label={<DateTime date={timePosted} timeAgo />}
/>
{isPinned && (
<span className="comment__pin">
<Icon icon={ICONS.PIN} />
{channelOwnerOfContent
? __('Pinned by @%channel%', { channel: channelOwnerOfContent })
: __('Pinned by creator')}
</span>
)}
</div>
<div className="comment__menu">
<Menu>
<MenuButton>
<Icon
size={18}
className={mouseIsHovering ? 'comment__menu-icon--hovering' : 'comment__menu-icon'}
icon={ICONS.MORE_VERTICAL}
/>
</MenuButton>
<MenuList className="menu__list--comments">
{commentIsMine ? (
<>
<MenuItem className="comment__menu-option" onSelect={() => setEditing(true)}>
{__('Edit')}
</MenuItem>
<MenuItem className="comment__menu-option" onSelect={() => deleteComment(commentId)}>
{__('Delete')}
</MenuItem>
</>
) : (
<MenuItem className="comment__menu-option" onSelect={() => blockChannel(authorUri)}>
{__('Block Channel')}
2020-07-15 18:36:40 +02:00
</MenuItem>
)}
</MenuList>
</Menu>
</div>
</div>
<div>
{isEditing ? (
<Form onSubmit={handleSubmit}>
<FormField
type={!SIMPLE_SITE && advancedEditor ? 'markdown' : 'textarea'}
name="editing_comment"
value={editedMessage}
charCount={charCount}
onChange={handleEditMessageChanged}
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT}
/>
<div className="section__actions">
<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>
) : editedMessage.length >= LENGTH_TO_COLLAPSE ? (
<Expandable>
<MarkdownPreview content={message} promptLinks />
</Expandable>
) : (
<MarkdownPreview content={message} promptLinks />
)}
</div>
<div className="comment__actions">
2020-10-07 21:14:52 +02:00
{threadDepth !== 0 && (
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Log in to reply')}
className="comment__action"
onClick={handleCommentReply}
icon={ICONS.REPLY}
/>
)}
{ENABLE_COMMENT_REACTIONS && <CommentReactions commentId={commentId} />}
</div>
2020-10-07 21:14:52 +02:00
{isReplying && (
<CommentCreate
isReply
uri={uri}
parentId={commentId}
onDoneReplying={() => setReplying(false)}
onCancelReplying={() => setReplying(false)}
/>
)}
</>
)}
</div>
2019-10-24 19:24:53 +02:00
</div>
</div>
2020-10-07 21:14:52 +02:00
<CommentsReplies threadDepth={threadDepth - 1} uri={uri} parentId={commentId} linkedComment={linkedComment} />
2019-06-27 01:59:27 +02:00
</li>
);
}
export default Comment;