mirror of
https://github.com/LBRYFoundation/lbry-desktop.git
synced 2025-08-23 17:47:24 +00:00
Adds comment actions menu & implements their actions
This commit is contained in:
parent
c514a291e4
commit
b44d2f59e8
11 changed files with 200 additions and 43 deletions
|
@ -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))
|
||||||
|
|
|
@ -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);
|
|
||||||
|
|
|
@ -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,46 +38,144 @@ 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">
|
||||||
{!author ? (
|
<div className="comment__meta-information">
|
||||||
<span className="comment__author">{__('Anonymous')}</span>
|
{!author ? (
|
||||||
) : (
|
<span className="comment__author">{__('Anonymous')}</span>
|
||||||
<Button
|
) : (
|
||||||
className="button--uri-indicator truncated-text comment__author"
|
<Button
|
||||||
navigate={authorUri}
|
className="button--uri-indicator truncated-text comment__author"
|
||||||
label={author}
|
navigate={authorUri}
|
||||||
/>
|
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} />
|
||||||
|
|
|
@ -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);
|
|
||||||
|
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Add table
Reference in a new issue