de6c6f9bfd
* Add option to pass in url-search params. Impetus: allow linked comment ID and setting the discussion tab when clicking on the `ClaimPreview`. * comment.list: fix typos and renamed variables - Switch from 'author' to 'creator' to disambiguate between comment author and content author. For comment author, we'll use 'commenter' from now on. - Corrected 'commenterClaimId' to 'creatorClaimId' (just a typo, no functional change). * doCommentReset: change param from uri to claimId This reduces one lookup as clients will always have the claimID ready, but might not have the full URI. It was using URI previously just to match the other APIs. * Add doCommentListOwn -- command to fetch own comments Since the redux slice is set up based on content or channel ID (for Channel Discussion page), re-use the channel ID for the case of "own comments". We always clear each ID when fetching page-0, so no worries of conflict when actually browsing the Channel Discussion page. * Comment: add option to hide the actions section * Implement own-comments page * Use new param to remove sort-pins-first. comment.List currently always pushes pins to the top to support pagination. This new param removes this behavior.
435 lines
15 KiB
JavaScript
435 lines
15 KiB
JavaScript
// @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 } 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 'lbry-redux';
|
|
import DateTime from 'component/dateTime';
|
|
import Button from 'component/button';
|
|
import Expandable from 'component/expandable';
|
|
import MarkdownPreview from 'component/common/markdown-preview';
|
|
import Tooltip from 'component/common/tooltip';
|
|
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';
|
|
|
|
const AUTO_EXPAND_ALL_REPLIES = false;
|
|
|
|
type Props = {
|
|
clearPlayingUri: () => void,
|
|
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> },
|
|
myChannels: ?Array<ChannelClaim>,
|
|
commentingEnabled: boolean,
|
|
doToast: ({ message: string }) => void,
|
|
isTopLevel?: boolean,
|
|
threadDepth: number,
|
|
hideActions?: boolean,
|
|
isPinned: boolean,
|
|
othersReacts: ?{
|
|
like: number,
|
|
dislike: number,
|
|
},
|
|
commentIdentityChannel: any,
|
|
activeChannelClaim: ?ChannelClaim,
|
|
playingUri: ?PlayingUri,
|
|
stakedLevel: number,
|
|
supportAmount: number,
|
|
numDirectReplies: number,
|
|
isModerator: boolean,
|
|
isGlobalMod: boolean,
|
|
isFiat: boolean,
|
|
supportDisabled: boolean,
|
|
setQuickReply: (any) => void,
|
|
quickReply: any,
|
|
};
|
|
|
|
const LENGTH_TO_COLLAPSE = 300;
|
|
|
|
function Comment(props: Props) {
|
|
const {
|
|
clearPlayingUri,
|
|
claim,
|
|
uri,
|
|
author,
|
|
authorUri,
|
|
timePosted,
|
|
message,
|
|
channelIsBlocked,
|
|
commentIsMine,
|
|
commentId,
|
|
updateComment,
|
|
fetchReplies,
|
|
totalReplyPages,
|
|
linkedCommentId,
|
|
linkedCommentAncestors,
|
|
commentingEnabled,
|
|
myChannels,
|
|
doToast,
|
|
isTopLevel,
|
|
threadDepth,
|
|
hideActions,
|
|
isPinned,
|
|
othersReacts,
|
|
playingUri,
|
|
stakedLevel,
|
|
supportAmount,
|
|
numDirectReplies,
|
|
isModerator,
|
|
isGlobalMod,
|
|
isFiat,
|
|
supportDisabled,
|
|
setQuickReply,
|
|
quickReply,
|
|
} = 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);
|
|
const [showReplies, setShowReplies] = useState(false);
|
|
const [page, setPage] = useState(0);
|
|
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;
|
|
const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
|
|
|
|
let channelOwnerOfContent;
|
|
try {
|
|
const { channelName } = parseURI(uri);
|
|
if (channelName) {
|
|
channelOwnerOfContent = channelName;
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Auto-expand (limited to linked-comments for now, but can be for all)
|
|
useEffect(() => {
|
|
const isInLinkedCommentChain =
|
|
linkedCommentId &&
|
|
linkedCommentAncestors[linkedCommentId] &&
|
|
linkedCommentAncestors[linkedCommentId].includes(commentId);
|
|
|
|
if (isInLinkedCommentChain || AUTO_EXPAND_ALL_REPLIES) {
|
|
setShowReplies(true);
|
|
setPage(1);
|
|
}
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
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 && 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() {
|
|
const urlParams = new URLSearchParams(search);
|
|
urlParams.delete('lc');
|
|
urlParams.append('lc', commentId);
|
|
replace(`${pathname}?${urlParams.toString()}`);
|
|
}
|
|
|
|
return (
|
|
<li
|
|
className={classnames('comment', {
|
|
'comment--top-level': isTopLevel,
|
|
'comment--reply': !isTopLevel,
|
|
'comment--superchat': supportAmount > 0,
|
|
})}
|
|
id={commentId}
|
|
>
|
|
<div
|
|
className={classnames('comment__content', {
|
|
[COMMENT_HIGHLIGHTED]: linkedCommentId && linkedCommentId === commentId,
|
|
'comment--slimed': slimedToDeath && !displayDeadComment,
|
|
})}
|
|
>
|
|
<div className="comment__thumbnail-wrapper">
|
|
{authorUri ? (
|
|
<ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} xsmall className="comment__author-thumbnail" />
|
|
) : (
|
|
<ChannelThumbnail xsmall className="comment__author-thumbnail" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="comment__body-container">
|
|
<div className="comment__meta">
|
|
<div className="comment__meta-information">
|
|
{isGlobalMod && (
|
|
<Tooltip label={__('Admin')}>
|
|
<span className="comment__badge comment__badge--global-mod">
|
|
<Icon icon={ICONS.BADGE_MOD} size={20} />
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{isModerator && (
|
|
<Tooltip label={__('Moderator')}>
|
|
<span className="comment__badge comment__badge--mod">
|
|
<Icon icon={ICONS.BADGE_MOD} size={20} />
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{!author ? (
|
|
<span className="comment__author">{__('Anonymous')}</span>
|
|
) : (
|
|
<UriIndicator
|
|
className={classnames('comment__author', {
|
|
'comment__author--creator': commentByOwnerOfContent,
|
|
})}
|
|
link
|
|
uri={authorUri}
|
|
/>
|
|
)}
|
|
<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}
|
|
/>
|
|
<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>
|
|
) : 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}
|
|
/>
|
|
)}
|
|
{ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />}
|
|
</div>
|
|
)}
|
|
|
|
{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);
|
|
}
|
|
}}
|
|
icon={ICONS.DOWN}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{numDirectReplies > 0 && showReplies && (
|
|
<div className="comment__actions">
|
|
<Button
|
|
label={__('Hide replies')}
|
|
button="link"
|
|
onClick={() => setShowReplies(false)}
|
|
icon={ICONS.UP}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isReplying && (
|
|
<CommentCreate
|
|
isReply
|
|
uri={uri}
|
|
parentId={commentId}
|
|
onDoneReplying={() => {
|
|
setShowReplies(true);
|
|
setReplying(false);
|
|
}}
|
|
onCancelReplying={() => {
|
|
setReplying(false);
|
|
}}
|
|
supportDisabled={supportDisabled}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showReplies && (
|
|
<CommentsReplies
|
|
threadDepth={threadDepth - 1}
|
|
uri={uri}
|
|
parentId={commentId}
|
|
linkedCommentId={linkedCommentId}
|
|
numDirectReplies={numDirectReplies}
|
|
onShowMore={() => setPage(page + 1)}
|
|
hasMore={page < totalReplyPages}
|
|
/>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export default Comment;
|