new comments

This commit is contained in:
Sean Yesmunt 2020-08-24 13:35:21 -04:00
parent b4106b1a65
commit 248e578422
25 changed files with 750 additions and 194 deletions

25
flow-typed/Comment.js vendored Normal file
View file

@ -0,0 +1,25 @@
declare type Comment = {
comment: string, // comment body
comment_id: string, // sha256 digest
claim_id: string, // id linking to the claim this comment
timestamp: number, // integer representing unix-time
is_hidden: boolean, // claim owner may enable/disable this
channel_id?: string, // claimId of channel signing this comment
channel_name?: string, // name of channel claim
channel_url?: string, // full lbry url to signing channel
signature?: string, // signature of comment by originating channel
signing_ts?: string, // timestamp used when signing this comment
is_channel_signature_valid?: boolean, // whether or not the signature could be validated
parent_id?: number, // comment_id of comment this is in reply to
};
// todo: relate individual comments to their commentId
declare type CommentsState = {
commentsByUri: { [string]: string },
byId: { [string]: Array<string> },
repliesByParentId: { [string]: Array<string> }, // ParentCommentID -> list of reply comments
topLevelCommentsById: { [string]: Array<string> }, // ClaimID -> list of top level comments
commentById: { [string]: Comment },
isLoading: boolean,
myComments: ?Set<string>,
};

View file

@ -10,6 +10,8 @@ import { doCommentAbandon, doCommentUpdate } from 'redux/actions/comments';
import { doToggleBlockChannel } from 'redux/actions/blocked';
import { selectChannelIsBlocked } from 'redux/selectors/blocked';
import Comment from './view';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectIsFetchingComments } from 'redux/selectors/comments';
const select = (state, props) => ({
pending: props.authorUri && makeSelectClaimIsPending(props.authorUri)(state),
@ -17,6 +19,8 @@ const select = (state, props) => ({
isResolvingUri: props.authorUri && makeSelectIsUriResolving(props.authorUri)(state),
thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state),
channelIsBlocked: props.authorUri && selectChannelIsBlocked(props.authorUri)(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
isFetchingComments: selectIsFetchingComments(state),
});
const perform = dispatch => ({

View file

@ -6,13 +6,12 @@ import React, { useEffect, useState } from 'react';
import { isEmpty } from 'util/object';
import DateTime from 'component/dateTime';
import Button from 'component/button';
import Expandable from 'component/expandable';
// 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 { FormField, Form } from 'component/common/form';
import CommentCreate from 'component/commentCreate';
import classnames from 'classnames';
import usePersistedState from 'effects/use-persisted-state';
@ -34,6 +33,7 @@ type Props = {
updateComment: (string, string) => void,
deleteComment: string => void,
blockChannel: string => void,
linkedComment?: any,
};
const LENGTH_TO_COLLAPSE = 300;
@ -57,6 +57,7 @@ function Comment(props: Props) {
updateComment,
deleteComment,
blockChannel,
linkedComment,
} = props;
const [isEditing, setEditing] = useState(false);
@ -65,11 +66,7 @@ function Comment(props: Props) {
// used for controlling the visibility of the menu icon
const [mouseIsHovering, setMouseHover] = useState(false);
// used for controlling visibility of reply comment component
const [isReplying, setReplying] = useState(false);
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
const [advancedEditor] = usePersistedState('comment-editor-mode', false);
// to debounce subsequent requests
const shouldFetch =
@ -108,12 +105,15 @@ function Comment(props: Props) {
function handleSubmit() {
updateComment(commentId, editedMessage);
setEditing(false);
setReplying(false);
}
return (
<li
className={classnames('comment', { comment__reply: parentId !== null })}
className={classnames('comment', {
comment__reply: parentId !== null,
comment__highlighted: linkedComment && linkedComment.comment_id === commentId,
})}
id={commentId}
onMouseOver={() => setMouseHover(true)}
onMouseOut={() => setMouseHover(false)}
>
@ -133,9 +133,16 @@ function Comment(props: Props) {
label={author}
/>
)}
<time className="comment__time" dateTime={timePosted}>
{DateTime.getTimeAgoStr(timePosted)}
</time>
{/* // link here */}
<Button
navigate={`${uri}?lc=${commentId}`}
label={
<time className="comment__time" dateTime={timePosted}>
{DateTime.getTimeAgoStr(timePosted)}
</time>
}
className="button--uri-indicator"
/>
</div>
<div className="comment__menu">
<Menu>
@ -174,8 +181,6 @@ function Comment(props: Props) {
value={editedMessage}
charCount={charCount}
onChange={handleEditMessageChanged}
quickActionLabel={!SIMPLE_SITE && (advancedEditor ? __('Simple Editor') : __('Advanced Editor'))}
quickActionHandler={() => setAdvancedEditor(!advancedEditor)}
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT}
/>
<div className="section__actions">
@ -191,9 +196,9 @@ function Comment(props: Props) {
</Form>
) : editedMessage.length >= LENGTH_TO_COLLAPSE ? (
<div className="comment__message">
<Expandable>
<MarkdownPreview content={message} />
</Expandable>
{/* <Expandable> */}
<MarkdownPreview content={message} />
{/* </Expandable> */}
</div>
) : (
<div className="comment__message">
@ -201,27 +206,6 @@ function Comment(props: Props) {
</div>
)}
</div>
{!parentId && !isEditing && (
<Button
button="link"
requiresAuth={IS_WEB}
className="comment__reply-button"
onClick={() => setReplying(true)}
label={__('Reply')}
/>
)}
<div>
{isReplying ? (
<CommentCreate
uri={uri}
parentId={commentId}
onDoneReplying={() => setReplying(false)}
onCancelReplying={() => setReplying(false)}
/>
) : (
''
)}
</div>
</div>
</li>
);

View file

@ -21,6 +21,7 @@ type Props = {
parentId?: string,
onDoneReplying?: () => void,
onCancelReplying?: () => void,
isNested: boolean,
};
export function CommentCreate(props: Props) {
@ -33,6 +34,7 @@ export function CommentCreate(props: Props) {
parentId,
onDoneReplying,
onCancelReplying,
isNested,
} = props;
const { claim_id: claimId } = claim;
const isReply = !!parentId;
@ -53,9 +55,9 @@ export function CommentCreate(props: Props) {
useEffect(() => {
// set default channel
if ((channel === '' || channel === 'anonymous') && topChannel) {
handleChannelChange(topChannel.name);
setChannel(topChannel.name);
}
}, [channel, topChannel]);
}, [channel, topChannel, setChannel]);
function handleCommentChange(event) {
let commentValue;
@ -68,10 +70,6 @@ export function CommentCreate(props: Props) {
setCommentValue(commentValue);
}
function handleChannelChange(channel) {
setChannel(channel);
}
function handleCommentAck() {
setCommentAck(true);
}
@ -107,17 +105,27 @@ export function CommentCreate(props: Props) {
}
return (
<Form onSubmit={handleSubmit} className={classnames('comment__create', { 'comment__create--reply': isReply })}>
{!isReply && <ChannelSelection channel={channel} hideAnon onChannelChange={handleChannelChange} />}
<Form
onSubmit={handleSubmit}
className={classnames('comment__create', {
'comment__create--reply': isReply,
'comment__create--nested-reply': isNested,
})}
>
<FormField
disabled={channel === CHANNEL_NEW}
type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'}
name={isReply ? 'content_reply' : 'content_description'}
label={isReply ? __('Replying as %reply_channel%', { reply_channel: channel }) : __('Comment')}
label={
<span className="comment-new__label-wrapper">
<div className="comment-new__label">{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}</div>
<ChannelSelection channel={channel} hideAnon tiny hideNew onChannelChange={setChannel} />
</span>
}
quickActionLabel={
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
}
quickActionHandler={!SIMPLE_SITE && (isReply ? undefined : toggleEditorMode)}
quickActionHandler={!SIMPLE_SITE && toggleEditorMode}
onFocus={onTextareaFocus}
placeholder={__('Say something about this...')}
value={commentValue}
@ -126,7 +134,7 @@ export function CommentCreate(props: Props) {
autoFocus={isReply}
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT}
/>
<div className="section__actions">
<div className="section__actions section__actions--no-margin">
<Button
button="primary"
disabled={channel === CHANNEL_NEW || !commentValue.length}

View file

@ -1,14 +1,16 @@
import { connect } from 'react-redux';
import { makeSelectClaimIsMine, selectMyChannelClaims } from 'lbry-redux';
import { makeSelectCommentsForUri, selectIsFetchingComments } from 'redux/selectors/comments';
import { makeSelectTopLevelCommentsForUri, selectIsFetchingComments } from 'redux/selectors/comments';
import { doCommentList } from 'redux/actions/comments';
import CommentsList from './view';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
const select = (state, props) => ({
myChannels: selectMyChannelClaims(state),
comments: makeSelectCommentsForUri(props.uri)(state),
comments: makeSelectTopLevelCommentsForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
isFetchingComments: selectIsFetchingComments(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
});
const perform = dispatch => ({

View file

@ -2,6 +2,10 @@
import React, { useEffect } from 'react';
import Comment from 'component/comment';
import Spinner from 'component/spinner';
import Button from 'component/button';
import Card from 'component/common/card';
import CommentCreate from 'component/commentCreate';
import CommentsReplies from 'component/commentsReplies';
type Props = {
comments: Array<any>,
@ -10,11 +14,28 @@ type Props = {
claimIsMine: boolean,
myChannels: ?Array<ChannelClaim>,
isFetchingComments: boolean,
linkedComment: any,
commentingEnabled: boolean,
};
function CommentList(props: Props) {
const { fetchComments, uri, comments, claimIsMine, myChannels, isFetchingComments } = props;
const {
fetchComments,
uri,
comments,
claimIsMine,
myChannels,
isFetchingComments,
linkedComment,
commentingEnabled,
} = props;
const linkedCommentId = linkedComment && linkedComment.comment_id;
const [start] = React.useState(0);
const [end, setEnd] = React.useState(9);
const totalComments = comments && comments.length;
const moreBelow = totalComments - end > 0;
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
const isMyComment = (channelId: string) => {
if (myChannels != null && channelId != null) {
@ -27,56 +48,95 @@ function CommentList(props: Props) {
return false;
};
const handleMoreBelow = () => {
if (moreBelow) {
setEnd(end + 10);
}
};
const commentRef = React.useRef();
useEffect(() => {
fetchComments(uri);
}, [fetchComments, uri]);
function sortByParent(arrayOfComments) {
let parentComments = arrayOfComments.filter(comment => comment.parent_id === undefined);
let childComments = arrayOfComments.filter(comment => comment.parent_id !== undefined);
let sortedArray = [];
useEffect(() => {
if (linkedCommentId && commentRef && commentRef.current) {
commentRef.current.scrollIntoView({ block: 'start' });
window.scrollBy(0, -100);
}
}, [linkedCommentId]);
parentComments.forEach(parentComment => {
sortedArray.push(parentComment);
function prepareComments(arrayOfComments, linkedComment) {
let orderedComments = [];
childComments
.reverse()
.filter(childComment => childComment.parent_id === parentComment.comment_id)
.forEach(childComment => {
sortedArray.push(childComment);
});
});
return sortedArray;
if (linkedComment) {
if (!linkedComment.parent_id) {
orderedComments = arrayOfComments.filter(c => c.comment_id !== linkedComment.comment_id);
orderedComments.unshift(linkedComment);
} else {
const parentComment = arrayOfComments.find(c => c.comment_id === linkedComment.parent_id);
orderedComments = arrayOfComments.filter(c => c.comment_id !== linkedComment.parent_id);
orderedComments.unshift(parentComment);
}
} else {
orderedComments = arrayOfComments;
}
return orderedComments;
}
const displayedComments = prepareComments(comments, linkedComment).slice(start, end);
return (
<ul className="comments">
{isFetchingComments && (
<div className="main--empty">
<Spinner />
</div>
)}
{!isFetchingComments &&
comments &&
sortByParent(comments).map(comment => {
return (
<Comment
uri={uri}
authorUri={comment.channel_url}
author={comment.channel_name}
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)}
/>
);
})}
</ul>
<Card
title={<span>{__('Comments')}</span>}
actions={
<>
{commentingEnabled && <CommentCreate uri={uri} />}
<ul className="comments" ref={commentRef}>
{isFetchingComments && (
<div className="main--empty">
<Spinner />
</div>
)}
{!isFetchingComments &&
comments &&
displayedComments &&
displayedComments.map(comment => {
return (
<>
<Comment
uri={uri}
authorUri={comment.channel_url}
author={comment.channel_name}
claimId={comment.claim_id}
commentId={comment.comment_id}
key={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)}
linkedComment={linkedComment}
/>
<CommentsReplies
uri={uri}
parentId={comment.comment_id}
linkedComment={linkedComment}
key={comment.comment_id + 'replies'}
/>
</>
);
})}
</ul>
{moreBelow && (
<div className="comment__actions">
<Button button="link" className="comment__more-below" onClick={handleMoreBelow} label={__('Show More')} />
</div>
)}
</>
}
/>
);
}

View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { makeSelectClaimIsMine, selectMyChannelClaims } from 'lbry-redux';
import { makeSelectRepliesForParentId } from 'redux/selectors/comments';
import CommentsReplies from './view';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
const select = (state, props) => ({
myChannels: selectMyChannelClaims(state),
comments: makeSelectRepliesForParentId(props.parentId)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
});
export default connect(select, null)(CommentsReplies);

View file

@ -0,0 +1,155 @@
// @flow
import React from 'react';
import Comment from 'component/comment';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
import CommentCreate from 'component/commentCreate';
type Props = {
comments: Array<any>,
uri: string,
claimIsMine: boolean,
myChannels: ?Array<ChannelClaim>,
linkedComment?: Comment,
parentId: string,
commentingEnabled: boolean,
};
function CommentsReplies(props: Props) {
const { uri, comments, claimIsMine, myChannels, linkedComment, parentId, commentingEnabled } = props;
const [isReplying, setReplying] = React.useState(false);
const [isExpanded, setExpanded] = React.useState(false);
const [start, setStart] = React.useState(0);
const [end, setEnd] = React.useState(9);
const sortedComments = comments ? [...comments].reverse() : [];
const numberOfComments = comments ? comments.length : 0;
const showMore = () => {
if (start > 0) {
setStart(0);
} else {
setEnd(numberOfComments);
}
};
const linkedCommentId = linkedComment ? linkedComment.comment_id : '';
const commentsIndexOfLInked = comments && sortedComments.findIndex(e => e.comment_id === linkedCommentId);
// 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;
};
const handleCommentDone = () => {
if (!isExpanded) {
setExpanded(true);
setStart(numberOfComments || 0);
}
setEnd(numberOfComments + 1);
setReplying(false);
};
React.useEffect(() => {
if (
setStart &&
setEnd &&
setExpanded &&
linkedCommentId &&
Number.isInteger(commentsIndexOfLInked) &&
commentsIndexOfLInked > -1
) {
setStart(commentsIndexOfLInked);
setEnd(commentsIndexOfLInked + 1);
setExpanded(true);
}
}, [setStart, setEnd, setExpanded, linkedCommentId, commentsIndexOfLInked]);
const displayedComments = sortedComments.slice(start, end);
return (
<li className={'comment__replies-container'}>
<div className="comment__actions">
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Sign in to reply')}
className="comment__action"
onClick={() => setReplying(!isReplying)}
icon={ICONS.REPLY}
/>
{!isExpanded && Boolean(numberOfComments) && (
<Button
className="comment__action"
label={__('Show %number% Replies', { number: numberOfComments })}
onClick={() => setExpanded(true)}
icon={ICONS.DOWN}
/>
)}
</div>
{comments && displayedComments && isExpanded && (
<div>
<div className="comment__replies">
<Button className="comment__threadline" aria-label="Hide Replies" onClick={() => setExpanded(false)} />
<ul className="comments--replies">
{displayedComments.map(comment => {
return (
<Comment
uri={uri}
authorUri={comment.channel_url}
author={comment.channel_name}
claimId={comment.claim_id}
commentId={comment.comment_id}
key={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)}
linkedComment={linkedComment}
/>
);
})}
</ul>
</div>
{!isReplying && (
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Sign in to reply')}
className="comment__action--nested"
onClick={() => setReplying(!isReplying)}
icon={ICONS.REPLY}
/>
)}
</div>
)}
{isExpanded && comments && (end < numberOfComments || start > 0) && (
<div className="comment__actions">
<Button button="link" label={__('Show more')} onClick={showMore} className="button--uri-indicator" />
</div>
)}
{isReplying ? (
<CommentCreate
isNested={isExpanded}
key={parentId}
uri={uri}
parentId={parentId}
onDoneReplying={() => handleCommentDone()}
onCancelReplying={() => setReplying(false)}
/>
) : (
''
)}
</li>
);
}
export default CommentsReplies;

View file

@ -131,6 +131,17 @@ export class FormField extends React.PureComponent<Props> {
</select>
</fieldset-section>
);
} else if (type === 'select-tiny') {
input = (
<fieldset-section class="select--slim">
{(label || errorMessage) && (
<label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
)}
<select id={name} {...inputProps}>
{children}
</select>
</fieldset-section>
);
} else if (type === 'markdown') {
const handleEvents = {
contextmenu: openEditorMenu,

View file

@ -721,4 +721,9 @@ export const icons = {
<line x1="9" y1="21" x2="9" y2="9" />
</g>
),
[ICONS.REPLY]: buildIcon(
<g>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</g>
),
};

View file

@ -7,6 +7,7 @@ const DEBOUNCE_SCROLL_HANDLER_MS = 300;
type Props = {
children: any,
lastUpdateDate?: any,
skipWait?: boolean,
};
export default function WaitUntilOnPage(props: Props) {
@ -45,5 +46,5 @@ export default function WaitUntilOnPage(props: Props) {
}
}, [ref, setShouldRender, shouldRender]);
return <div ref={ref}>{shouldRender && props.children}</div>;
return <div ref={ref}>{(props.skipWait || shouldRender) && props.children}</div>;
}

View file

@ -17,6 +17,7 @@ type Props = {
label?: string,
injected?: Array<string>,
emailVerified: boolean,
tiny: boolean,
};
type State = {
@ -91,7 +92,7 @@ class ChannelSelection extends React.PureComponent<Props, State> {
render() {
const channel = this.state.addingChannel ? CHANNEL_NEW : this.props.channel;
const { fetchingChannels, channels = [], hideAnon, hideNew, label, injected = [] } = this.props;
const { fetchingChannels, channels = [], hideAnon, hideNew, label, injected = [], tiny } = this.props;
const { addingChannel } = this.state;
return (
@ -99,8 +100,9 @@ class ChannelSelection extends React.PureComponent<Props, State> {
<FormField
id={ID_FF_SELECT_CHANNEL}
name="channel"
label={label || __('Channel')}
type="select"
label={!tiny && (label || __('Channel'))}
labelOnLeft={tiny}
type={tiny ? 'select-tiny' : 'select'}
onChange={this.handleChannelChange}
value={channel}
>

View file

@ -113,3 +113,4 @@ export const OPEN_LOG_FOLDER = 'Folder';
export const LBRY_STATUS = 'BarChart';
export const NOTIFICATION = 'Bell';
export const LAYOUT = 'Layout';
export const REPLY = 'Reply';

View file

@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import { doRemoveUnreadSubscription } from 'redux/actions/subscriptions';
import { doSetContentHistoryItem } from 'redux/actions/content';
import { withRouter } from 'react-router';
import {
doFetchFileInfo,
makeSelectFileInfoForUri,
@ -13,17 +14,25 @@ import { selectShowMatureContent } from 'redux/selectors/settings';
import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions';
import { makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import FilePage from './view';
import { makeSelectCommentForCommentId } from 'redux/selectors/comments';
const select = (state, props) => ({
costInfo: makeSelectCostInfoForUri(props.uri)(state),
metadata: makeSelectMetadataForUri(props.uri)(state),
obscureNsfw: !selectShowMatureContent(state),
isMature: makeSelectClaimIsNsfw(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
isSubscribed: makeSelectIsSubscribed(props.uri)(state),
channelUri: makeSelectChannelForClaimUri(props.uri, true)(state),
renderMode: makeSelectFileRenderModeForUri(props.uri)(state),
});
const select = (state, props) => {
const { search } = props.location;
const urlParams = new URLSearchParams(search);
const linkedCommentId = urlParams.get('lc');
return {
linkedComment: makeSelectCommentForCommentId(linkedCommentId)(state),
costInfo: makeSelectCostInfoForUri(props.uri)(state),
metadata: makeSelectMetadataForUri(props.uri)(state),
obscureNsfw: !selectShowMatureContent(state),
isMature: makeSelectClaimIsNsfw(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
isSubscribed: makeSelectIsSubscribed(props.uri)(state),
channelUri: makeSelectChannelForClaimUri(props.uri, true)(state),
renderMode: makeSelectFileRenderModeForUri(props.uri)(state),
};
};
const perform = dispatch => ({
fetchFileInfo: uri => dispatch(doFetchFileInfo(uri)),
@ -32,4 +41,4 @@ const perform = dispatch => ({
markSubscriptionRead: (channel, uri) => dispatch(doRemoveUnreadSubscription(channel, uri)),
});
export default connect(select, perform)(FilePage);
export default withRouter(connect(select, perform)(FilePage));

View file

@ -7,14 +7,12 @@ import FileTitle from 'component/fileTitle';
import FileRenderInitiator from 'component/fileRenderInitiator';
import FileRenderInline from 'component/fileRenderInline';
import FileRenderDownload from 'component/fileRenderDownload';
import Card from 'component/common/card';
import FileDetails from 'component/fileDetails';
import FileValues from 'component/fileValues';
import FileDescription from 'component/fileDescription';
import WaitUntilOnPage from 'component/common/wait-until-on-page';
// import WaitUntilOnPage from 'component/common/wait-until-on-page';
import RecommendedContent from 'component/recommendedContent';
import CommentsList from 'component/commentsList';
import CommentCreate from 'component/commentCreate';
export const FILE_WRAPPER_CLASS = 'file-page__video-container';
@ -31,6 +29,7 @@ type Props = {
markSubscriptionRead: (string, string) => void,
obscureNsfw: boolean,
isMature: boolean,
linkedComment: any,
};
class FilePage extends React.Component<Props> {
@ -136,7 +135,7 @@ class FilePage extends React.Component<Props> {
lastReset: ?any;
render() {
const { uri, renderMode, costInfo, obscureNsfw, isMature } = this.props;
const { uri, renderMode, costInfo, obscureNsfw, isMature, linkedComment } = this.props;
if (obscureNsfw && isMature) {
return this.renderBlockedPage();
@ -149,17 +148,9 @@ class FilePage extends React.Component<Props> {
<FileDescription uri={uri} />
<FileValues uri={uri} />
<FileDetails uri={uri} />
<Card
title={__('Leave a Comment')}
actions={
<div>
<CommentCreate uri={uri} />
<WaitUntilOnPage lastUpdateDate={this.lastReset}>
<CommentsList uri={uri} />
</WaitUntilOnPage>
</div>
}
/>
{/* <WaitUntilOnPage lastUpdateDate={this.lastReset} skipWait={Boolean(linkedComment)}> */}
<CommentsList uri={uri} linkedComment={linkedComment} />
{/* </WaitUntilOnPage> */}
</div>
<RecommendedContent uri={uri} />

View file

@ -5,8 +5,11 @@ import { handleActions } from 'util/redux-utils';
const defaultState: CommentsState = {
commentById: {}, // commentId -> Comment
byId: {}, // ClaimID -> list of comments
repliesByParentId: {}, // ParentCommentID -> list of reply comments
topLevelCommentsById: {}, // ClaimID -> list of top level comments
commentsByUri: {}, // URI -> claimId
isLoading: false,
isCommenting: false,
myComments: undefined,
};
@ -14,18 +17,20 @@ export default handleActions(
{
[ACTIONS.COMMENT_CREATE_STARTED]: (state: CommentsState, action: any): CommentsState => ({
...state,
isLoading: true,
isCommenting: true,
}),
[ACTIONS.COMMENT_CREATE_FAILED]: (state: CommentsState, action: any) => ({
...state,
isLoading: false,
isCommenting: false,
}),
[ACTIONS.COMMENT_CREATE_COMPLETED]: (state: CommentsState, action: any): CommentsState => {
const { comment, claimId, uri }: { comment: Comment, claimId: string, uri: string } = action.data;
const commentById = Object.assign({}, state.commentById);
const byId = Object.assign({}, state.byId);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]}
const repliesByParentId = Object.assign({}, state.repliesByParentId); // {ParentCommentID -> [commentIds...] } list of reply comments
const comments = byId[claimId] || [];
const newCommentIds = comments.slice();
const commentsByUri = Object.assign({}, state.commentsByUri);
@ -37,16 +42,30 @@ export default handleActions(
newCommentIds.unshift(comment.comment_id);
byId[claimId] = newCommentIds;
if (!commentsByUri[uri]) {
commentsByUri[uri] = claimId;
if (comment['parent_id']) {
if (!repliesByParentId[comment.parent_id]) {
repliesByParentId[comment.parent_id] = [comment.comment_id];
} else {
repliesByParentId[comment.parent_id].unshift(comment.comment_id);
}
} else {
if (!topLevelCommentsById[claimId]) {
commentsByUri[uri] = claimId;
topLevelCommentsById[claimId] = [comment.comment_id];
} else {
topLevelCommentsById[claimId].unshift(comment.comment_id);
}
}
return {
...state,
topLevelCommentsById,
repliesByParentId,
commentById,
byId,
commentsByUri,
isLoading: false,
isCommenting: false,
};
},
@ -57,8 +76,11 @@ export default handleActions(
const commentById = Object.assign({}, state.commentById);
const byId = Object.assign({}, state.byId);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]}
const commentsByUri = Object.assign({}, state.commentsByUri);
const tempRepliesByParent = {};
const topLevelComments = [];
if (comments) {
// we use an Array to preserve order of listing
// in reality this doesn't matter and we can just
@ -67,15 +89,32 @@ export default handleActions(
// map the comment_ids to the new comments
for (let i = 0; i < comments.length; i++) {
const comment = comments[i];
if (comment['parent_id']) {
if (!tempRepliesByParent[comment.parent_id]) {
tempRepliesByParent[comment.parent_id] = [comment.comment_id];
} else {
tempRepliesByParent[comment.parent_id].push(comment.comment_id);
}
} else {
commentById[comment.comment_id] = comment;
topLevelComments.push(comment.comment_id);
}
commentIds[i] = comments[i].comment_id;
commentById[commentIds[i]] = comments[i];
}
topLevelCommentsById[claimId] = topLevelComments;
byId[claimId] = commentIds;
commentsByUri[uri] = claimId;
}
const repliesByParentId = Object.assign({}, state.repliesByParentId, tempRepliesByParent); // {ParentCommentID -> [commentIds...] } list of reply comments
return {
...state,
topLevelCommentsById,
repliesByParentId,
byId,
commentById,
commentsByUri,
@ -116,12 +155,12 @@ export default handleActions(
// do nothing
[ACTIONS.COMMENT_ABANDON_FAILED]: (state: CommentsState, action: any) => ({
...state,
isLoading: false,
isCommenting: false,
}),
// do nothing
[ACTIONS.COMMENT_UPDATE_STARTED]: (state: CommentsState, action: any) => ({
...state,
isLoading: true,
isCommenting: true,
}),
// replace existing comment with comment returned here under its comment_id
[ACTIONS.COMMENT_UPDATE_COMPLETED]: (state: CommentsState, action: any) => {
@ -132,13 +171,13 @@ export default handleActions(
return {
...state,
commentById,
isLoading: false,
isCommenting: false,
};
},
// nothing can be done here
[ACTIONS.COMMENT_UPDATE_FAILED]: (state: CommentsState, action: any) => ({
...state,
isLoading: false,
isCmmenting: false,
}),
// nothing can really be done here
[ACTIONS.COMMENT_HIDE_STARTED]: (state: CommentsState, action: any) => ({

View file

@ -9,6 +9,16 @@ export const selectBlockedChannels = createSelector(selectState, (state: Blockli
export const selectBlockedChannelsCount = createSelector(selectBlockedChannels, (state: Array<string>) => state.length);
export const selectBlockedChannelsObj = createSelector(selectState, (state: BlocklistState) => {
return state.blockedChannels.reduce((acc: any, val: any) => {
const outpoint = `${val.txid}:${String(val.nout)}`;
return {
...acc,
[outpoint]: 1,
};
}, {});
});
export const selectChannelIsBlocked = (uri: string) =>
createSelector(selectBlockedChannels, (state: Array<string>) => {
return state.includes(uri);

View file

@ -3,7 +3,7 @@ import * as SETTINGS from 'constants/settings';
import { createSelector } from 'reselect';
import { selectBlockedChannels } from 'redux/selectors/blocked';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc';
import { selectBlacklistedOutpointMap, selectFilteredOutpointMap } from 'lbryinc';
import { selectClaimsById, isClaimNsfw, selectMyActiveClaims } from 'lbry-redux';
const selectState = state => state.comments || {};
@ -12,10 +12,29 @@ export const selectCommentsById = createSelector(selectState, state => state.com
export const selectIsFetchingComments = createSelector(selectState, state => state.isLoading);
export const selectIsPostingComment = createSelector(selectState, state => state.isCommenting);
export const selectCommentsByClaimId = createSelector(selectState, selectCommentsById, (state, byId) => {
const byClaimId = state.byId || {};
const comments = {};
// replace every comment_id in the list with the actual comment object
Object.keys(byClaimId).forEach((claimId: string) => {
const commentIds = byClaimId[claimId];
comments[claimId] = Array(commentIds === null ? 0 : commentIds.length);
for (let i = 0; i < commentIds.length; i++) {
comments[claimId][i] = byId[commentIds[i]];
}
});
return comments;
});
export const selectTopLevelCommentsByClaimId = createSelector(selectState, selectCommentsById, (state, byId) => {
const byClaimId = state.topLevelCommentsById || {};
const comments = {};
// replace every comment_id in the list with the actual comment object
Object.keys(byClaimId).forEach(claimId => {
const commentIds = byClaimId[claimId];
@ -29,6 +48,26 @@ export const selectCommentsByClaimId = createSelector(selectState, selectComment
return comments;
});
export const makeSelectCommentForCommentId = (commentId: string) =>
createSelector(selectCommentsById, comments => comments[commentId]);
export const selectRepliesByParentId = createSelector(selectState, selectCommentsById, (state, byId) => {
const byParentId = state.repliesByParentId || {};
const comments = {};
// replace every comment_id in the list with the actual comment object
Object.keys(byParentId).forEach(id => {
const commentIds = byParentId[id];
comments[id] = Array(commentIds === null ? 0 : commentIds.length);
for (let i = 0; i < commentIds.length; i++) {
comments[id][i] = byId[commentIds[i]];
}
});
return comments;
});
// previously this used a mapping from claimId -> Array<Comments>
/* export const selectCommentsById = createSelector(
selectState,
@ -56,39 +95,112 @@ export const makeSelectCommentsForUri = (uri: string) =>
selectClaimsById,
selectMyActiveClaims,
selectBlockedChannels,
selectBlackListedOutpoints,
selectFilteredOutpoints,
selectBlacklistedOutpointMap,
selectFilteredOutpointMap,
makeSelectClientSetting(SETTINGS.SHOW_MATURE),
(
byClaimId,
byUri,
claimsById,
myClaims,
blockedChannels,
blacklistedOutpoints,
filteredOutpoints,
showMatureContent
) => {
(byClaimId, byUri, claimsById, myClaims, blockedChannels, blacklistedMap, filteredMap, showMatureContent) => {
const claimId = byUri[uri];
const comments = byClaimId && byClaimId[claimId];
const blacklistedMap = blacklistedOutpoints
? blacklistedOutpoints.reduce((acc, val) => {
const outpoint = `${val.txid}:${val.nout}`;
return {
...acc,
[outpoint]: 1,
};
}, {})
: {};
const filteredMap = filteredOutpoints
? filteredOutpoints.reduce((acc, val) => {
const outpoint = `${val.txid}:${val.nout}`;
return {
...acc,
[outpoint]: 1,
};
}, {})
: {};
return comments
? comments.filter(comment => {
const channelClaim = claimsById[comment.channel_id];
// Return comment if `channelClaim` doesn't exist so the component knows to resolve the author
if (channelClaim) {
if (myClaims && myClaims.size > 0) {
const claimIsMine = channelClaim.is_my_output || myClaims.has(channelClaim.claim_id);
if (claimIsMine) {
return true;
}
}
const outpoint = `${channelClaim.txid}:${channelClaim.nout}`;
if (blacklistedMap[outpoint] || filteredMap[outpoint]) {
return false;
}
if (!showMatureContent) {
const claimIsMature = isClaimNsfw(channelClaim);
if (claimIsMature) {
return false;
}
}
}
return !blockedChannels.includes(comment.channel_url);
})
: [];
}
);
export const makeSelectTopLevelCommentsForUri = (uri: string) =>
createSelector(
selectTopLevelCommentsByClaimId,
selectCommentsByUri,
selectClaimsById,
selectMyActiveClaims,
selectBlockedChannels,
selectBlacklistedOutpointMap,
selectFilteredOutpointMap,
makeSelectClientSetting(SETTINGS.SHOW_MATURE),
(byClaimId, byUri, claimsById, myClaims, blockedChannels, blacklistedMap, filteredMap, showMatureContent) => {
const claimId = byUri[uri];
const comments = byClaimId && byClaimId[claimId];
return comments
? comments.filter(comment => {
const channelClaim = claimsById[comment.channel_id];
// Return comment if `channelClaim` doesn't exist so the component knows to resolve the author
if (channelClaim) {
if (myClaims && myClaims.size > 0) {
const claimIsMine = channelClaim.is_my_output || myClaims.has(channelClaim.claim_id);
if (claimIsMine) {
return true;
}
}
const outpoint = `${channelClaim.txid}:${channelClaim.nout}`;
if (blacklistedMap[outpoint] || filteredMap[outpoint]) {
return false;
}
if (!showMatureContent) {
const claimIsMature = isClaimNsfw(channelClaim);
if (claimIsMature) {
return false;
}
}
}
return !blockedChannels.includes(comment.channel_url);
})
: [];
}
);
export const makeSelectRepliesForParentId = (id: string) =>
createSelector(
selectState, // no selectRepliesByParentId
selectCommentsById,
selectClaimsById,
selectMyActiveClaims,
selectBlockedChannels,
selectBlacklistedOutpointMap,
selectFilteredOutpointMap,
makeSelectClientSetting(SETTINGS.SHOW_MATURE),
(state, commentsById, claimsById, myClaims, blockedChannels, blacklistedMap, filteredMap, showMatureContent) => {
// const claimId = byUri[uri]; // just parentId (id)
const replyIdsByParentId = state.repliesByParentId;
const replyIdsForParent = replyIdsByParentId[id] || [];
if (!replyIdsForParent.length) return null;
const comments = [];
replyIdsForParent.forEach(cid => {
comments.push(commentsById[cid]);
});
// const comments = byParentId && byParentId[id];
return comments
? comments.filter(comment => {

View file

@ -1,75 +1,145 @@
$thumbnailWidth: 3rem;
$thumbnailWidthSmall: 2rem;
.comments {
padding-top: var(--spacing-l);
list-style-type: none;
font-size: var(--font-small);
}
.comments--replies {
margin-left: var(--spacing-m);
flex: 1;
}
.comment__create {
padding-bottom: var(--spacing-l);
font-size: var(--font-small);
}
.comment__create--reply {
margin-top: var(--spacing-m);
margin-left: calc(#{$thumbnailWidth} + var(--spacing-m));
position: relative;
}
.comment {
display: flex;
flex-direction: row;
font-size: var(--font-body);
font-size: var(--font-small);
margin: 0;
&:not(:last-of-type) {
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-m) 0;
}
&:last-of-type {
padding-top: var(--spacing-m);
&:not(:first-child) {
margin-top: var(--spacing-l);
}
.channel-thumbnail {
@include handleChannelGif(3rem);
@include handleChannelGif($thumbnailWidthSmall);
margin-right: 0;
@media (min-width: $breakpoint-small) {
@include handleChannelGif($thumbnailWidth);
}
}
}
.comment__create--reply {
margin-top: var(--spacing-s);
.comment__replies-container {
margin: 0;
}
.comment__replies {
display: flex;
margin-top: var(--spacing-m);
margin-left: calc(#{$thumbnailWidthSmall} + var(--spacing-xs));
@media (min-width: $breakpoint-small) {
margin-left: calc(#{$thumbnailWidth} + var(--spacing-m));
}
}
.comment__reply {
border-left: 5px solid var(--color-primary-alt);
margin-left: var(--spacing-m);
margin: 0;
.comment__author-thumbnail {
margin-left: var(--spacing-m);
&:not(:first-child) {
margin-top: var(--spacing-m);
}
}
.comment__reply-button {
margin-top: var(--spacing-s);
.comment__threadline {
@extend .button--alt;
height: auto;
align-self: stretch;
padding: 2px;
border-radius: 3px;
background-color: var(--color-comment-threadline);
&:hover {
box-shadow: 0 0 0 1px var(--color-comment-threadline-hover);
background-color: var(--color-comment-threadline-hover);
border-color: var(--color-comment-threadline-hover);
}
}
.comment-new__label-wrapper {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: baseline;
flex-wrap: wrap;
width: 100%;
@media (min-width: $breakpoint-small) {
fieldset-section {
max-width: 10rem;
}
}
}
.comment-new__label {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
.comment__highlighted {
background: var(--color-comment-highlighted);
box-shadow: 0 0 0 var(--spacing-xs) var(--color-comment-highlighted);
border-radius: 4px;
}
.comment__body_container {
padding-right: var(--spacing-s);
flex: 1;
width: 80%;
margin-left: var(--spacing-xs);
@media (min-width: $breakpoint-small) {
margin-left: var(--spacing-m);
}
}
.comment__meta {
display: flex;
justify-content: space-between;
text-overflow: ellipsis;
margin-bottom: var(--spacing-s);
}
.comment__meta-information {
justify-content: flex-start;
display: flex;
height: 100%;
}
.comment__message {
white-space: pre-line;
word-break: break-word;
margin-top: var(--spacing-s);
}
.comment__author {
text-overflow: ellipsis;
padding-right: var(--spacing-xs);
height: 100%;
}
.comment__time {
opacity: 0.3;
white-space: nowrap;
height: 100%;
}
.comment__menu {
@ -109,3 +179,49 @@
border-radius: var(--card-radius);
padding: var(--spacing-s);
}
.comment__actions {
display: flex;
margin-top: var(--spacing-s);
margin-left: calc(#{$thumbnailWidthSmall} + var(--spacing-xs));
> *:not(:last-child) {
margin-right: var(--spacing-xs);
}
.icon {
margin-right: var(--spacing-xxs);
}
.button__label {
margin-left: 0;
}
@media (min-width: $breakpoint-small) {
margin-left: calc(#{$thumbnailWidth} + var(--spacing-m));
}
}
.comment__action {
@extend .button--uri-indicator;
height: auto;
font-size: var(--font-xsmall);
}
.comment__action--nested {
@extend .comment__action;
}
.comment__action--nested,
.comment__create--nested-reply {
margin-top: var(--spacing-s);
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
@media (min-width: $breakpoint-small) {
margin-left: calc((#{$thumbnailWidth} + var(--spacing-m)) * 2 + var(--spacing-m) + 4px);
}
}
.comment__more-below {
margin-top: var(--spacing-l);
}

View file

@ -413,7 +413,9 @@ fieldset-group {
}
.form-field__two-column {
column-count: 2;
@media (min-width: $breakpoint-small) {
column-count: 2;
}
}
.form-field__quick-action {
@ -431,4 +433,11 @@ fieldset-section {
margin-top: var(--spacing-s); // Extra specificity needed here since _section.scss is applied after this file
}
}
.select--slim {
select {
max-height: 1.5rem !important;
padding: 0 var(--spacing-xs);
}
}
}

View file

@ -45,8 +45,6 @@
font-size: 1em;
}
p,
blockquote,
dl,
ul,
ol,

View file

@ -48,7 +48,7 @@
--color-input-color: #111111;
--color-input-label: var(--color-gray-5);
--color-input-placeholder: #212529;
--color-input-bg: var(--color-gray-1);
--color-input-bg: #f4f4f4;
--color-input-bg-copyable: #434b53;
--color-input-border: var(--color-border);
--color-input-border-active: var(--color-secondary);

View file

@ -44,12 +44,6 @@ h6 {
font-size: 1rem;
}
p {
& + p {
margin-top: var(--spacing-s);
}
}
ul,
ol {
li {

View file

@ -53,6 +53,7 @@
--color-purchased: #ffd580;
--color-purchased-alt: var(--color-purchased);
--color-purchased-text: var(--color-gray-5);
--color-comment-highlighted: #484734;
// Text
--color-text: #eeeeee;

View file

@ -24,6 +24,10 @@
--color-purchased: var(--color-cost);
--color-purchased-alt: #ffebc2;
--color-purchased-text: var(--color-gray-5);
--color-comment-highlighted: #fff2d9;
--color-comment-threadline: var(--color-gray-2);
--color-comment-threadline-hover: var(--color-gray-4);
--color-comment-threadline-border: var(--color-gray-2);
// Icons
--color-follow-bg: #ffd4da;