From b44d2f59e838c6e01bbf0f8712c17d3172307cc3 Mon Sep 17 00:00:00 2001 From: Oleg Silkin Date: Wed, 29 Jan 2020 20:02:21 -0500 Subject: [PATCH] Adds comment actions menu & implements their actions --- CHANGELOG.md | 1 + ui/component/comment/index.js | 12 +- ui/component/comment/view.jsx | 166 +++++++++++++++++++++++----- ui/component/commentsList/index.js | 9 +- ui/component/commentsList/view.jsx | 21 +++- ui/component/common/icon-custom.jsx | 5 + ui/component/common/icon.jsx | 2 +- ui/constants/icons.js | 1 + ui/scss/component/_comments.scss | 22 ++++ ui/scss/themes/dark.scss | 2 + ui/scss/themes/light.scss | 2 + 11 files changed, 200 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4f69c63..8e9283056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 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)) - Revamped invites system ([#3462](https://github.com/lbryio/lbry-desktop/pull/3462)) - Appimage support ([#3497](https://github.com/lbryio/lbry-desktop/pull/3497)) diff --git a/ui/component/comment/index.js b/ui/component/comment/index.js index f3141821a..5fccbab35 100644 --- a/ui/component/comment/index.js +++ b/ui/component/comment/index.js @@ -6,13 +6,14 @@ import { makeSelectThumbnailForUri, makeSelectIsUriResolving, selectChannelIsBlocked, + doCommentUpdate, // doEditComment would be a more fitting name + doCommentAbandon, } from 'lbry-redux'; - import Comment from './view'; const select = (state, props) => ({ 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), thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state), channelIsBlocked: props.authorUri && selectChannelIsBlocked(props.authorUri)(state), @@ -20,9 +21,8 @@ const select = (state, props) => ({ const perform = dispatch => ({ resolveUri: uri => dispatch(doResolveUri(uri)), + updateComment: (commentId, comment) => dispatch(doCommentUpdate(commentId, comment)), + deleteComment: commentId => dispatch(doCommentAbandon(commentId)), }); -export default connect( - select, - perform -)(Comment); +export default connect(select, perform)(Comment); diff --git a/ui/component/comment/view.jsx b/ui/component/comment/view.jsx index 22e0ae2f3..d964df212 100644 --- a/ui/component/comment/view.jsx +++ b/ui/component/comment/view.jsx @@ -1,25 +1,35 @@ // @flow -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { isEmpty } from 'util/object'; import relativeDate from 'tiny-relative-date'; import Button from 'component/button'; import Expandable from 'component/expandable'; import MarkdownPreview from 'component/common/markdown-preview'; 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 = { - author: string, - authorUri: string, - message: string, - timePosted: number, - claim: ?Claim, + 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 pending?: boolean, - resolveUri: string => void, - isResolvingUri: boolean, - channelIsBlocked: 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, }; const LENGTH_TO_COLLAPSE = 300; +const ESCAPE_KEY = 27; function Comment(props: Props) { const { @@ -28,46 +38,144 @@ function Comment(props: Props) { timePosted, message, pending, - claim, + channel, isResolvingUri, resolveUri, channelIsBlocked, + commentIsMine, + commentId, + updateComment, + deleteComment, } = 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 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(() => { // If author was extracted from the URI, then it must be valid. if (authorUri && author && !isResolvingUri && shouldFetch) { 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 ( -
  • +
  • {authorUri ? : }
    - - {!author ? ( - {__('Anonymous')} - ) : ( -
    +
    + {commentIsMine && ( + + + + + + + {__('Edit')} + + + {__('Delete')} + + + + )} +
    +
    - {message.length >= LENGTH_TO_COLLAPSE ? ( + {isEditing ? ( +
    + +
    +
    + + ) : editedMessage.length >= LENGTH_TO_COLLAPSE ? (
    diff --git a/ui/component/commentsList/index.js b/ui/component/commentsList/index.js index 324fe767b..17107f8e0 100644 --- a/ui/component/commentsList/index.js +++ b/ui/component/commentsList/index.js @@ -1,16 +1,15 @@ import { connect } from 'react-redux'; -import { makeSelectCommentsForUri, doCommentList } from 'lbry-redux'; +import { makeSelectCommentsForUri, doCommentList, makeSelectClaimIsMine, selectMyChannelClaims } from 'lbry-redux'; import CommentsList from './view'; const select = (state, props) => ({ + myChannels: selectMyChannelClaims(state), comments: makeSelectCommentsForUri(props.uri)(state), + claimIsMine: makeSelectClaimIsMine(props.uri)(state), }); const perform = dispatch => ({ fetchComments: uri => dispatch(doCommentList(uri)), }); -export default connect( - select, - perform -)(CommentsList); +export default connect(select, perform)(CommentsList); diff --git a/ui/component/commentsList/view.jsx b/ui/component/commentsList/view.jsx index d01aa133e..558fe2ad0 100644 --- a/ui/component/commentsList/view.jsx +++ b/ui/component/commentsList/view.jsx @@ -6,10 +6,25 @@ type Props = { comments: Array, fetchComments: string => void, uri: string, + claimIsMine: boolean, + myChannels: ?Array, }; 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(() => { fetchComments(uri); }, [fetchComments, uri]); @@ -22,12 +37,14 @@ function CommentList(props: Props) { ); })} diff --git a/ui/component/common/icon-custom.jsx b/ui/component/common/icon-custom.jsx index e66a3edf6..6955eb243 100644 --- a/ui/component/common/icon-custom.jsx +++ b/ui/component/common/icon-custom.jsx @@ -398,6 +398,11 @@ export const icons = { + [ICONS.MORE_VERTICAL]: buildIcon( + + + + ), }; diff --git a/ui/component/common/icon.jsx b/ui/component/common/icon.jsx index 2b70126d9..7a7eee3f7 100644 --- a/ui/component/common/icon.jsx +++ b/ui/component/common/icon.jsx @@ -46,7 +46,7 @@ class IconComponent extends React.PureComponent { case 'blue': return BLUE_COLOR; default: - return undefined; + return color; } }; diff --git a/ui/constants/icons.js b/ui/constants/icons.js index c1af14f52..c0209aedd 100644 --- a/ui/constants/icons.js +++ b/ui/constants/icons.js @@ -81,6 +81,7 @@ export const SIGN_IN = 'SignIn'; export const TRENDING = 'Trending'; export const TOP = 'Top'; export const NEW = 'New'; +export const MORE_VERTICAL = 'MoreVertical'; export const IMAGE = 'Image'; export const AUDIO = 'HeadPhones'; export const VIDEO = 'Video'; diff --git a/ui/scss/component/_comments.scss b/ui/scss/component/_comments.scss index abe5edeed..1053c426b 100644 --- a/ui/scss/component/_comments.scss +++ b/ui/scss/component/_comments.scss @@ -21,9 +21,12 @@ .comment__body_container { padding-right: var(--spacing-small); + flex: 1; } .comment__meta { + display: flex; + justify-content: space-between; text-overflow: ellipsis; margin-bottom: var(--spacing-small); } @@ -43,7 +46,26 @@ white-space: nowrap; } +.comment__menu { + align-self: flex-end; +} + .comment__char-count { align-self: flex-end; 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); +} diff --git a/ui/scss/themes/dark.scss b/ui/scss/themes/dark.scss index cd20beb50..e6a38127c 100644 --- a/ui/scss/themes/dark.scss +++ b/ui/scss/themes/dark.scss @@ -36,6 +36,8 @@ --color-tabs-background: #434b53; --color-tab-divider: var(--color-white); --color-modal-background: var(--color-header-background); + --color-comment-menu: #6a6a6a; + --color-comment-menu-hovering: #e0e0e0; // Text --color-text: #eeeeee; diff --git a/ui/scss/themes/light.scss b/ui/scss/themes/light.scss index ca512f47b..9b78f2cd9 100644 --- a/ui/scss/themes/light.scss +++ b/ui/scss/themes/light.scss @@ -12,6 +12,8 @@ --color-background-overlay: #21252980; --color-nag: #f26522; --color-error: #fcafca; + --color-comment-menu: #e0e0e0; + --color-comment-menu-hovering: #6a6a6a; // Text --color-text-selection-bg: var(--color-secondary-alt);