Adds the ability for users to update & delete comments #3453
11 changed files with 202 additions and 43 deletions
|
@ -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))
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
done done
|
||||
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 (
|
||||
<li className="comment">
|
||||
<li className="comment" onMouseOver={handleMouseOver} onMouseOut={handleMouseOut}>
|
||||
<div className="comment__author-thumbnail">
|
||||
{authorUri ? <ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} small /> : <ChannelThumbnail small />}
|
||||
</div>
|
||||
|
||||
<div className="comment__body_container">
|
||||
<span className="comment__meta">
|
||||
{!author ? (
|
||||
<span className="comment__author">{__('Anonymous')}</span>
|
||||
) : (
|
||||
<Button
|
||||
className="button--uri-indicator truncated-text comment__author"
|
||||
navigate={authorUri}
|
||||
label={author}
|
||||
/>
|
||||
)}
|
||||
|
||||
<time className="comment__time" dateTime={timePosted}>
|
||||
{relativeDate(timePosted)}
|
||||
</time>
|
||||
</span>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<time className="comment__time" dateTime={timePosted}>
|
||||
{relativeDate(timePosted)}
|
||||
</time>
|
||||
</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>
|
||||
This can just delete the comment, we don't need a modal for it. This can just delete the comment, we don't need a modal for it.
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</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">
|
||||
<Expandable>
|
||||
<MarkdownPreview content={message} />
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -6,10 +6,25 @@ type Props = {
|
|||
comments: Array<any>,
|
||||
fetchComments: string => void,
|
||||
uri: string,
|
||||
claimIsMine: boolean,
|
||||
myChannels: ?Array<ChannelClaim>,
|
||||
};
|
||||
|
||||
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) {
|
||||
This was causing an error when I refreshed on a page with comments
This was causing an error when I refreshed on a page with comments
```
"Cannot read property 'length' of undefined
at isMyComment"
```
Was using Was using `myChannels !== null` instead of `myChannels != 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) {
|
|||
<Comment
|
||||
authorUri={comment.channel_url}
|
||||
author={comment.channel_name}
|
||||
claimId={comment.channel_id}
|
||||
claimId={comment.claim_id}
|
||||
commentId={comment.comment_id}
|
||||
key={comment.channel_id + comment.comment_id}
|
||||
message={comment.comment}
|
||||
parentId={comment.parent_id || null}
|
||||
timePosted={comment.timestamp * 1000}
|
||||
claimIsMine={claimIsMine}
|
||||
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -400,4 +400,11 @@ export const icons = {
|
|||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</g>
|
||||
),
|
||||
[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>
|
||||
),
|
||||
};
|
||||
|
|
|
@ -46,7 +46,7 @@ class IconComponent extends React.PureComponent<Props> {
|
|||
case 'blue':
|
||||
return BLUE_COLOR;
|
||||
default:
|
||||
return undefined;
|
||||
return color;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -81,6 +81,7 @@ export const SIGN_IN = 'SignIn';
|
|||
export const TRENDING = 'Trending';
|
||||
remove this one since we aren't using it remove this one since we aren't using it
|
||||
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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue
You are already wrapping this in
commentIsMine
so these aren't neededset a classname based on if it's hovering, then we can use css variables. Currently in dark mode, the dots get darker when you hover.
The space in between
{
andcommentIsMine
is because you disabled the precommit hook which formats the code.these ^ comments aren't needed