Adds comment actions menu & implements their actions

This commit is contained in:
Oleg Silkin 2020-01-29 20:02:21 -05:00 committed by Sean Yesmunt
parent c514a291e4
commit b44d2f59e8
11 changed files with 200 additions and 43 deletions

View file

@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added ### Added
- Adds the ability for users to update & delete comments ([#3453](https://github.com/lbryio/lbry-desktop/pull/3453))
- New homepage design ([#3508](https://github.com/lbryio/lbry-desktop/pull/3508)) - New homepage design ([#3508](https://github.com/lbryio/lbry-desktop/pull/3508))
- Revamped invites system ([#3462](https://github.com/lbryio/lbry-desktop/pull/3462)) - Revamped invites system ([#3462](https://github.com/lbryio/lbry-desktop/pull/3462))
- Appimage support ([#3497](https://github.com/lbryio/lbry-desktop/pull/3497)) - Appimage support ([#3497](https://github.com/lbryio/lbry-desktop/pull/3497))

View file

@ -6,13 +6,14 @@ import {
makeSelectThumbnailForUri, makeSelectThumbnailForUri,
makeSelectIsUriResolving, makeSelectIsUriResolving,
selectChannelIsBlocked, selectChannelIsBlocked,
doCommentUpdate, // doEditComment would be a more fitting name
doCommentAbandon,
} from 'lbry-redux'; } from 'lbry-redux';
import Comment from './view'; import Comment from './view';
const select = (state, props) => ({ const select = (state, props) => ({
pending: props.authorUri && makeSelectClaimIsPending(props.authorUri)(state), pending: props.authorUri && makeSelectClaimIsPending(props.authorUri)(state),
claim: props.authorUri && makeSelectClaimForUri(props.authorUri)(state), channel: props.authorUri && makeSelectClaimForUri(props.authorUri)(state),
isResolvingUri: props.authorUri && makeSelectIsUriResolving(props.authorUri)(state), isResolvingUri: props.authorUri && makeSelectIsUriResolving(props.authorUri)(state),
thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state), thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state),
channelIsBlocked: props.authorUri && selectChannelIsBlocked(props.authorUri)(state), channelIsBlocked: props.authorUri && selectChannelIsBlocked(props.authorUri)(state),
@ -20,9 +21,8 @@ const select = (state, props) => ({
const perform = dispatch => ({ const perform = dispatch => ({
resolveUri: uri => dispatch(doResolveUri(uri)), resolveUri: uri => dispatch(doResolveUri(uri)),
updateComment: (commentId, comment) => dispatch(doCommentUpdate(commentId, comment)),
deleteComment: commentId => dispatch(doCommentAbandon(commentId)),
}); });
export default connect( export default connect(select, perform)(Comment);
select,
perform
)(Comment);

View file

@ -1,25 +1,35 @@
// @flow // @flow
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { isEmpty } from 'util/object'; import { isEmpty } from 'util/object';
import relativeDate from 'tiny-relative-date'; import relativeDate from 'tiny-relative-date';
import Button from 'component/button'; import Button from 'component/button';
import Expandable from 'component/expandable'; import Expandable from 'component/expandable';
import MarkdownPreview from 'component/common/markdown-preview'; import MarkdownPreview from 'component/common/markdown-preview';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
import { FormField, Form } from 'component/common/form';
type Props = { type Props = {
author: string, author: ?string, // LBRY Channel Name, e.g. @channel
authorUri: string, authorUri: string, // full LBRY Channel URI: lbry://@channel#123...
message: string, commentId: string, // sha256 digest identifying the comment
timePosted: number, message: string, // comment body
claim: ?Claim, timePosted: number, // Comment timestamp
channel: ?Claim, // Channel Claim, retrieved to obtain thumbnail
pending?: boolean, pending?: boolean,
resolveUri: string => void, resolveUri: string => void, // resolves the URI
isResolvingUri: boolean, isResolvingUri: boolean, // if the URI is currently being resolved
channelIsBlocked: boolean, 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,
}; };
const LENGTH_TO_COLLAPSE = 300; const LENGTH_TO_COLLAPSE = 300;
const ESCAPE_KEY = 27;
function Comment(props: Props) { function Comment(props: Props) {
const { const {
@ -28,30 +38,87 @@ function Comment(props: Props) {
timePosted, timePosted,
message, message,
pending, pending,
claim, channel,
isResolvingUri, isResolvingUri,
resolveUri, resolveUri,
channelIsBlocked, channelIsBlocked,
commentIsMine,
commentId,
updateComment,
deleteComment,
} = props; } = props;
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);
// to debounce subsequent requests // to debounce subsequent requests
const shouldFetch = const shouldFetch =
claim === undefined || (claim !== null && claim.value_type === 'channel' && isEmpty(claim.meta) && !pending); channel === undefined ||
(channel !== null && channel.value_type === 'channel' && isEmpty(channel.meta) && !pending);
useEffect(() => { useEffect(() => {
// If author was extracted from the URI, then it must be valid. // If author was extracted from the URI, then it must be valid.
if (authorUri && author && !isResolvingUri && shouldFetch) { if (authorUri && author && !isResolvingUri && shouldFetch) {
resolveUri(authorUri); resolveUri(authorUri);
} }
}, [isResolvingUri, shouldFetch, author, authorUri, resolveUri]);
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 handleSetEditing() {
setEditing(true);
}
function handleEditMessageChanged(event) {
setCommentValue(event.target.value);
}
function handleSubmit() {
updateComment(commentId, editedMessage);
setEditing(false);
}
function handleDeleteComment() {
deleteComment(commentId);
}
function handleMouseOver() {
setMouseHover(true);
}
function handleMouseOut() {
setMouseHover(false);
}
return ( return (
<li className="comment"> <li className="comment" onMouseOver={handleMouseOver} onMouseOut={handleMouseOut}>
<div className="comment__author-thumbnail"> <div className="comment__author-thumbnail">
{authorUri ? <ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} small /> : <ChannelThumbnail small />} {authorUri ? <ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} small /> : <ChannelThumbnail small />}
</div> </div>
<div className="comment__body_container"> <div className="comment__body_container">
<span className="comment__meta"> <div className="comment__meta">
<div className="comment__meta-information">
{!author ? ( {!author ? (
<span className="comment__author">{__('Anonymous')}</span> <span className="comment__author">{__('Anonymous')}</span>
) : ( ) : (
@ -61,13 +128,54 @@ function Comment(props: Props) {
label={author} label={author}
/> />
)} )}
<time className="comment__time" dateTime={timePosted}> <time className="comment__time" dateTime={timePosted}>
{relativeDate(timePosted)} {relativeDate(timePosted)}
</time> </time>
</span> </div>
<div className="comment__menu">
{commentIsMine && (
<Menu>
<MenuButton>
<Icon
size={18}
className={mouseIsHovering ? 'comment__menu-icon--hovering' : 'comment__menu-icon'}
icon={ICONS.MORE_VERTICAL}
/>
</MenuButton>
<MenuList className="comment__menu-list">
<MenuItem className="comment__menu-option" onSelect={handleSetEditing}>
{__('Edit')}
</MenuItem>
<MenuItem className="comment__menu-option" onSelect={handleDeleteComment}>
{__('Delete')}
</MenuItem>
</MenuList>
</Menu>
)}
</div>
</div>
<div> <div>
{message.length >= LENGTH_TO_COLLAPSE ? ( {isEditing ? (
<Form onSubmit={handleSubmit}>
<FormField
type="textarea"
name="editing_comment"
value={editedMessage}
charCount={charCount}
onChange={handleEditMessageChanged}
/>
<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>
) : editedMessage.length >= LENGTH_TO_COLLAPSE ? (
<div className="comment__message"> <div className="comment__message">
<Expandable> <Expandable>
<MarkdownPreview content={message} /> <MarkdownPreview content={message} />

View file

@ -1,16 +1,15 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeSelectCommentsForUri, doCommentList } from 'lbry-redux'; import { makeSelectCommentsForUri, doCommentList, makeSelectClaimIsMine, selectMyChannelClaims } from 'lbry-redux';
import CommentsList from './view'; import CommentsList from './view';
const select = (state, props) => ({ const select = (state, props) => ({
myChannels: selectMyChannelClaims(state),
comments: makeSelectCommentsForUri(props.uri)(state), comments: makeSelectCommentsForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
}); });
const perform = dispatch => ({ const perform = dispatch => ({
fetchComments: uri => dispatch(doCommentList(uri)), fetchComments: uri => dispatch(doCommentList(uri)),
}); });
export default connect( export default connect(select, perform)(CommentsList);
select,
perform
)(CommentsList);

View file

@ -6,10 +6,25 @@ type Props = {
comments: Array<any>, comments: Array<any>,
fetchComments: string => void, fetchComments: string => void,
uri: string, uri: string,
claimIsMine: boolean,
myChannels: ?Array<ChannelClaim>,
}; };
function CommentList(props: Props) { function CommentList(props: Props) {
const { fetchComments, uri, comments } = props; const { fetchComments, uri, comments, claimIsMine, myChannels } = props;
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
const isMyComment = (channelId: string) => {
if (myChannels != null && channelId != null) {
for (let i = 0; i < myChannels.length; i++) {
if (myChannels[i].claim_id === channelId) {
return true;
}
}
}
return false;
};
useEffect(() => { useEffect(() => {
fetchComments(uri); fetchComments(uri);
}, [fetchComments, uri]); }, [fetchComments, uri]);
@ -22,12 +37,14 @@ function CommentList(props: Props) {
<Comment <Comment
authorUri={comment.channel_url} authorUri={comment.channel_url}
author={comment.channel_name} author={comment.channel_name}
claimId={comment.channel_id} claimId={comment.claim_id}
commentId={comment.comment_id} commentId={comment.comment_id}
key={comment.channel_id + comment.comment_id} key={comment.channel_id + comment.comment_id}
message={comment.comment} message={comment.comment}
parentId={comment.parent_id || null} parentId={comment.parent_id || null}
timePosted={comment.timestamp * 1000} timePosted={comment.timestamp * 1000}
claimIsMine={claimIsMine}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
/> />
); );
})} })}

View file

@ -398,6 +398,11 @@ export const icons = {
<path d="M3 11V9a4 4 0 0 1 4-4h14" /> <path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" /> <polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" /> <path d="M21 13v2a4 4 0 0 1-4 4H3" />
[ICONS.MORE_VERTICAL]: buildIcon(
<g>
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
</g> </g>
), ),
}; };

View file

@ -46,7 +46,7 @@ class IconComponent extends React.PureComponent<Props> {
case 'blue': case 'blue':
return BLUE_COLOR; return BLUE_COLOR;
default: default:
return undefined; return color;
} }
}; };

View file

@ -81,6 +81,7 @@ export const SIGN_IN = 'SignIn';
export const TRENDING = 'Trending'; export const TRENDING = 'Trending';
export const TOP = 'Top'; export const TOP = 'Top';
export const NEW = 'New'; export const NEW = 'New';
export const MORE_VERTICAL = 'MoreVertical';
export const IMAGE = 'Image'; export const IMAGE = 'Image';
export const AUDIO = 'HeadPhones'; export const AUDIO = 'HeadPhones';
export const VIDEO = 'Video'; export const VIDEO = 'Video';

View file

@ -21,9 +21,12 @@
.comment__body_container { .comment__body_container {
padding-right: var(--spacing-small); padding-right: var(--spacing-small);
flex: 1;
} }
.comment__meta { .comment__meta {
display: flex;
justify-content: space-between;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-bottom: var(--spacing-small); margin-bottom: var(--spacing-small);
} }
@ -43,7 +46,26 @@
white-space: nowrap; white-space: nowrap;
} }
.comment__menu {
align-self: flex-end;
}
.comment__char-count { .comment__char-count {
align-self: flex-end; align-self: flex-end;
font-size: var(--font-small); font-size: var(--font-small);
} }
.comment__menu-option {
display: flex;
align-items: center;
padding: var(--spacing-small);
font-size: var(--font-small);
}
.comment__menu-icon--hovering {
stroke: var(--color-comment-menu-hovering);
}
.comment__menu-icon {
stroke: var(--color-comment-menu);
}

View file

@ -36,6 +36,8 @@
--color-tabs-background: #434b53; --color-tabs-background: #434b53;
--color-tab-divider: var(--color-white); --color-tab-divider: var(--color-white);
--color-modal-background: var(--color-header-background); --color-modal-background: var(--color-header-background);
--color-comment-menu: #6a6a6a;
--color-comment-menu-hovering: #e0e0e0;
// Text // Text
--color-text: #eeeeee; --color-text: #eeeeee;

View file

@ -12,6 +12,8 @@
--color-background-overlay: #21252980; --color-background-overlay: #21252980;
--color-nag: #f26522; --color-nag: #f26522;
--color-error: #fcafca; --color-error: #fcafca;
--color-comment-menu: #e0e0e0;
--color-comment-menu-hovering: #6a6a6a;
// Text // Text
--color-text-selection-bg: var(--color-secondary-alt); --color-text-selection-bg: var(--color-secondary-alt);