Comments Pagination #6390

Merged
infinite-persistence merged 8 commits from ip/comment.pagination into master 2021-07-14 16:42:11 +02:00
25 changed files with 880 additions and 276 deletions

66
flow-typed/Comment.js vendored
View file

@ -13,6 +13,7 @@ declare type Comment = {
parent_id?: number, // comment_id of comment this is in reply to parent_id?: number, // comment_id of comment this is in reply to
is_pinned: boolean, is_pinned: boolean,
support_amount: number, support_amount: number,
replies: number, // number of direct replies (i.e. excluding nested replies).
}; };
declare type PerChannelSettings = { declare type PerChannelSettings = {
@ -27,15 +28,21 @@ declare type PerChannelSettings = {
declare type CommentsState = { declare type CommentsState = {
commentsByUri: { [string]: string }, commentsByUri: { [string]: string },
superChatsByUri: { [string]: { totalAmount: number, comments: Array<Comment> } }, superChatsByUri: { [string]: { totalAmount: number, comments: Array<Comment> } },
byId: { [string]: Array<string> }, byId: { [string]: Array<string> }, // ClaimID -> list of fetched comment IDs.
repliesByParentId: { [string]: Array<string> }, // ParentCommentID -> list of reply comments totalCommentsById: {}, // ClaimId -> ultimate total (including replies) in commentron.
topLevelCommentsById: { [string]: Array<string> }, // ClaimID -> list of top level comments repliesByParentId: { [string]: Array<string> }, // ParentCommentID -> list of fetched replies.
totalRepliesByParentId: {}, // ParentCommentID -> total replies in commentron.
topLevelCommentsById: { [string]: Array<string> }, // ClaimID -> list of fetched top level comments.
topLevelTotalPagesById: { [string]: number }, // ClaimID -> total number of top-level pages in commentron. Based on COMMENT_PAGE_SIZE_TOP_LEVEL.
topLevelTotalCommentsById: { [string]: number }, // ClaimID -> total top level comments in commentron.
commentById: { [string]: Comment }, commentById: { [string]: Comment },
linkedCommentAncestors: { [string]: Array<string> }, // {"linkedCommentId": ["parentId", "grandParentId", ...]}
isLoading: boolean, isLoading: boolean,
isLoadingByParentId: { [string]: boolean },
myComments: ?Set<string>, myComments: ?Set<string>,
isFetchingReacts: boolean, isFetchingReacts: boolean,
myReactsByCommentId: any, myReactsByCommentId: ?{ [string]: Array<string> }, // {"CommentId:MyChannelId": ["like", "dislike", ...]}
othersReactsByCommentId: any, othersReactsByCommentId: ?{ [string]: { [string]: number } }, // {"CommentId:MyChannelId": {"like": 2, "dislike": 2, ...}}
pendingCommentReactions: Array<string>, pendingCommentReactions: Array<string>,
moderationBlockList: ?Array<string>, // @KP rename to "personalBlockList"? moderationBlockList: ?Array<string>, // @KP rename to "personalBlockList"?
adminBlockList: ?Array<string>, adminBlockList: ?Array<string>,
@ -64,17 +71,48 @@ declare type CommentReactParams = {
remove?: boolean, remove?: boolean,
}; };
declare type CommentReactListParams = {
comment_ids?: string,
channel_id?: string,
channel_name?: string,
wallet_id?: string,
react_types?: string,
};
declare type CommentListParams = { declare type CommentListParams = {
page: number, page: number, // pagination: which page of results
page_size: number, page_size: number, // pagination: nr of comments to show in a page (max 200)
claim_id: string, claim_id: string, // claim id of claim being commented on
channel_name?: string, // signing channel name of claim (enables 'commentsEnabled' check)
channel_id?: string, // signing channel claim id of claim (enables 'commentsEnabled' check)
author_claim_id?: string, // filters comments to just this author
parent_id?: string, // filters comments to those under this thread
top_level?: boolean, // filters to only top level comments
hidden?: boolean, // if true, will show hidden comments as well
sort_by?: number, // NEWEST=0, OLDEST=1, CONTROVERSY=2, POPULARITY=3,
}; };
declare type CommentListResponse = { declare type CommentListResponse = {
items: Array<Comment>, items: Array<Comment>,
total_amount: number, page: number,
page_size: number,
total_items: number, // Grand total for the claim being commented on.
total_filtered_items: number, // Total for filtered queries (e.g. top_level=true, parent_id=xxx, etc.).
total_pages: number,
has_hidden_comments: boolean,
}; };
declare type CommentByIdParams = {
comment_id: string,
with_ancestors: boolean,
}
declare type CommentByIdResponse = {
item: Comment,
items: Comment,
ancestors: Array<Comment>,
}
declare type CommentAbandonParams = { declare type CommentAbandonParams = {
comment_id: string, comment_id: string,
creator_channel_id?: string, creator_channel_id?: string,
@ -94,6 +132,16 @@ declare type CommentCreateParams = {
declare type SuperListParams = {}; declare type SuperListParams = {};
declare type SuperListResponse = {
page: number,
page_size: number,
total_pages: number,
total_items: number,
total_amount: number,
items: Array<Comment>,
has_hidden_comments: boolean,
};
declare type ModerationBlockParams = {}; declare type ModerationBlockParams = {};
declare type ModerationAddDelegateParams = { declare type ModerationAddDelegateParams = {

View file

@ -1445,6 +1445,7 @@
"You loved this": "You loved this", "You loved this": "You loved this",
"Creator loved this": "Creator loved this", "Creator loved this": "Creator loved this",
"A channel is required to throw fire and slime": "A channel is required to throw fire and slime", "A channel is required to throw fire and slime": "A channel is required to throw fire and slime",
"The requested comment is no longer available.": "The requested comment is no longer available.",
"Best": "Best", "Best": "Best",
"Controversial": "Controversial", "Controversial": "Controversial",
"Show Replies": "Show Replies", "Show Replies": "Show Replies",
@ -1513,6 +1514,7 @@
"Create A Channel": "Create A Channel", "Create A Channel": "Create A Channel",
"At least 10 views are required to earn the reward, consume more!": "At least 10 views are required to earn the reward, consume more!", "At least 10 views are required to earn the reward, consume more!": "At least 10 views are required to earn the reward, consume more!",
"Blocked %channel%": "Blocked %channel%", "Blocked %channel%": "Blocked %channel%",
"Comment(s) blocked.": "Comment(s) blocked.",
"You earned %lbc% for streaming your first video.": "You earned %lbc% for streaming your first video.", "You earned %lbc% for streaming your first video.": "You earned %lbc% for streaming your first video.",
"You earned %lbc% for successfully completing The Journey L4: Perfect Harmony.": "You earned %lbc% for successfully completing The Journey L4: Perfect Harmony.", "You earned %lbc% for successfully completing The Journey L4: Perfect Harmony.": "You earned %lbc% for successfully completing The Journey L4: Perfect Harmony.",
"You earned %lbc% for successfully completing The Journey L3: Bliss.": "You earned %lbc% for successfully completing The Journey L3: Bliss.", "You earned %lbc% for successfully completing The Journey L3: Bliss.": "You earned %lbc% for successfully completing The Journey L3: Bliss.",

View file

@ -17,6 +17,7 @@ const Comments = {
comment_list: (params: CommentListParams) => fetchCommentsApi('comment.List', params), comment_list: (params: CommentListParams) => fetchCommentsApi('comment.List', params),
comment_abandon: (params: CommentAbandonParams) => fetchCommentsApi('comment.Abandon', params), comment_abandon: (params: CommentAbandonParams) => fetchCommentsApi('comment.Abandon', params),
comment_create: (params: CommentCreateParams) => fetchCommentsApi('comment.Create', params), comment_create: (params: CommentCreateParams) => fetchCommentsApi('comment.Create', params),
comment_by_id: (params: CommentByIdParams) => fetchCommentsApi('comment.ByID', params),
setting_list: (params: SettingsParams) => fetchCommentsApi('setting.List', params), setting_list: (params: SettingsParams) => fetchCommentsApi('setting.List', params),
setting_block_word: (params: BlockWordParams) => fetchCommentsApi('setting.BlockWord', params), setting_block_word: (params: BlockWordParams) => fetchCommentsApi('setting.BlockWord', params),
setting_unblock_word: (params: BlockWordParams) => fetchCommentsApi('setting.UnBlockWord', params), setting_unblock_word: (params: BlockWordParams) => fetchCommentsApi('setting.UnBlockWord', params),

View file

@ -1,6 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { makeSelectCommentForCommentId } from 'redux/selectors/comments';
import { DISABLE_COMMENTS_TAG } from 'constants/tags'; import { DISABLE_COMMENTS_TAG } from 'constants/tags';
import ChannelDiscussion from './view'; import ChannelDiscussion from './view';
import { makeSelectTagInClaimOrChannelForUri } from 'lbry-redux'; import { makeSelectTagInClaimOrChannelForUri } from 'lbry-redux';
@ -8,10 +7,9 @@ import { makeSelectTagInClaimOrChannelForUri } from 'lbry-redux';
const select = (state, props) => { const select = (state, props) => {
const { search } = props.location; const { search } = props.location;
const urlParams = new URLSearchParams(search); const urlParams = new URLSearchParams(search);
const linkedCommentId = urlParams.get('lc');
return { return {
linkedComment: makeSelectCommentForCommentId(linkedCommentId)(state), linkedCommentId: urlParams.get('lc'),
commentsDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state), commentsDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
}; };
}; };

View file

@ -5,19 +5,19 @@ import Empty from 'component/common/empty';
type Props = { type Props = {
uri: string, uri: string,
linkedComment: ?any, linkedCommentId?: string,
commentsDisabled: boolean, commentsDisabled: boolean,
}; };
function ChannelDiscussion(props: Props) { function ChannelDiscussion(props: Props) {
const { uri, linkedComment, commentsDisabled } = props; const { uri, linkedCommentId, commentsDisabled } = props;
if (commentsDisabled) { if (commentsDisabled) {
return <Empty text={__('This channel has disabled comments on their page.')} />; return <Empty text={__('This channel has disabled comments on their page.')} />;
} }
return ( return (
<section className="section"> <section className="section">
<CommentsList uri={uri} linkedComment={linkedComment} /> <CommentsList uri={uri} linkedCommentId={linkedCommentId} />
</section> </section>
); );
} }

View file

@ -1,30 +1,43 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeSelectStakedLevelForChannelUri, makeSelectClaimForUri, makeSelectThumbnailForUri, selectMyChannelClaims } from 'lbry-redux'; import {
import { doCommentUpdate } from 'redux/actions/comments'; makeSelectStakedLevelForChannelUri,
makeSelectClaimForUri,
makeSelectThumbnailForUri,
selectMyChannelClaims,
} from 'lbry-redux';
import { doCommentUpdate, doCommentList } from 'redux/actions/comments';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
import { doSetPlayingUri } from 'redux/actions/content'; import { doSetPlayingUri } from 'redux/actions/content';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { makeSelectOthersReactionsForComment } from 'redux/selectors/comments'; import { selectLinkedCommentAncestors, makeSelectOthersReactionsForComment } from 'redux/selectors/comments';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelId, selectActiveChannelClaim } from 'redux/selectors/app';
import { selectPlayingUri } from 'redux/selectors/content'; import { selectPlayingUri } from 'redux/selectors/content';
import Comment from './view'; import Comment from './view';
const select = (state, props) => ({ const select = (state, props) => {
claim: makeSelectClaimForUri(props.uri)(state), const activeChannelId = selectActiveChannelId(state);
thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state), const reactionKey = activeChannelId ? `${props.commentId}:${activeChannelId}` : props.commentId;
channelIsBlocked: props.authorUri && makeSelectChannelIsMuted(props.authorUri)(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, return {
othersReacts: makeSelectOthersReactionsForComment(props.commentId)(state), claim: makeSelectClaimForUri(props.uri)(state),
activeChannelClaim: selectActiveChannelClaim(state), thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state),
myChannels: selectMyChannelClaims(state), channelIsBlocked: props.authorUri && makeSelectChannelIsMuted(props.authorUri)(state),
playingUri: selectPlayingUri(state), commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
stakedLevel: makeSelectStakedLevelForChannelUri(props.authorUri)(state), othersReacts: makeSelectOthersReactionsForComment(reactionKey)(state),
}); activeChannelClaim: selectActiveChannelClaim(state),
myChannels: selectMyChannelClaims(state),
playingUri: selectPlayingUri(state),
stakedLevel: makeSelectStakedLevelForChannelUri(props.authorUri)(state),
linkedCommentAncestors: selectLinkedCommentAncestors(state),
};
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })), clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })),
updateComment: (commentId, comment) => dispatch(doCommentUpdate(commentId, comment)), updateComment: (commentId, comment) => dispatch(doCommentUpdate(commentId, comment)),
fetchReplies: (uri, parentId, page, pageSize, sortBy) =>
dispatch(doCommentList(uri, parentId, page, pageSize, sortBy)),
doToast: (options) => dispatch(doToast(options)), doToast: (options) => dispatch(doToast(options)),
}); });

View file

@ -1,6 +1,7 @@
// @flow // @flow
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import { SORT_BY, COMMENT_PAGE_SIZE_REPLIES } from 'constants/comment';
import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field'; import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field';
import { SITE_NAME, SIMPLE_SITE, ENABLE_COMMENT_REACTIONS } from 'config'; import { SITE_NAME, SIMPLE_SITE, ENABLE_COMMENT_REACTIONS } from 'config';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@ -23,6 +24,8 @@ import CommentMenuList from 'component/commentMenuList';
import UriIndicator from 'component/uriIndicator'; import UriIndicator from 'component/uriIndicator';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
const AUTO_EXPAND_ALL_REPLIES = false;
type Props = { type Props = {
clearPlayingUri: () => void, clearPlayingUri: () => void,
uri: string, uri: string,
@ -36,8 +39,10 @@ type Props = {
claimIsMine: boolean, // if you control the claim which this comment was posted on claimIsMine: boolean, // if you control the claim which this comment was posted on
commentIsMine: boolean, // if this comment was signed by an owned channel commentIsMine: boolean, // if this comment was signed by an owned channel
updateComment: (string, string) => void, updateComment: (string, string) => void,
fetchReplies: (string, string, number, number, number) => void,
commentModBlock: (string) => void, commentModBlock: (string) => void,
linkedComment?: any, linkedCommentId?: string,
linkedCommentAncestors: { [string]: Array<string> },
myChannels: ?Array<ChannelClaim>, myChannels: ?Array<ChannelClaim>,
commentingEnabled: boolean, commentingEnabled: boolean,
doToast: ({ message: string }) => void, doToast: ({ message: string }) => void,
@ -53,6 +58,7 @@ type Props = {
playingUri: ?PlayingUri, playingUri: ?PlayingUri,
stakedLevel: number, stakedLevel: number,
supportAmount: number, supportAmount: number,
numDirectReplies: number,
}; };
const LENGTH_TO_COLLAPSE = 300; const LENGTH_TO_COLLAPSE = 300;
@ -71,7 +77,9 @@ function Comment(props: Props) {
commentIsMine, commentIsMine,
commentId, commentId,
updateComment, updateComment,
linkedComment, fetchReplies,
linkedCommentId,
linkedCommentAncestors,
commentingEnabled, commentingEnabled,
myChannels, myChannels,
doToast, doToast,
@ -82,18 +90,23 @@ function Comment(props: Props) {
playingUri, playingUri,
stakedLevel, stakedLevel,
supportAmount, supportAmount,
numDirectReplies,
} = props; } = props;
const { const {
push, push,
replace, replace,
location: { pathname, search }, location: { pathname, search },
} = useHistory(); } = useHistory();
const [isReplying, setReplying] = React.useState(false); const [isReplying, setReplying] = React.useState(false);
const [isEditing, setEditing] = useState(false); const [isEditing, setEditing] = useState(false);
const [editedMessage, setCommentValue] = useState(message); const [editedMessage, setCommentValue] = useState(message);
const [charCount, setCharCount] = useState(editedMessage.length); const [charCount, setCharCount] = useState(editedMessage.length);
// used for controlling the visibility of the menu icon // used for controlling the visibility of the menu icon
const [mouseIsHovering, setMouseHover] = useState(false); const [mouseIsHovering, setMouseHover] = useState(false);
const [showReplies, setShowReplies] = useState(false);
const [page, setPage] = useState(0);
const [advancedEditor] = usePersistedState('comment-editor-mode', false); const [advancedEditor] = usePersistedState('comment-editor-mode', false);
const [displayDeadComment, setDisplayDeadComment] = React.useState(false); const [displayDeadComment, setDisplayDeadComment] = React.useState(false);
const hasChannels = myChannels && myChannels.length > 0; const hasChannels = myChannels && myChannels.length > 0;
@ -111,6 +124,19 @@ function Comment(props: Props) {
} }
} catch (e) {} } catch (e) {}
// Auto-expand (limited to linked-comments for now, but can be for all)
useEffect(() => {
const isInLinkedCommentChain =
linkedCommentId &&
linkedCommentAncestors[linkedCommentId] &&
linkedCommentAncestors[linkedCommentId].includes(commentId);
if (isInLinkedCommentChain || AUTO_EXPAND_ALL_REPLIES) {
setShowReplies(true);
setPage(1);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
setCharCount(editedMessage.length); setCharCount(editedMessage.length);
@ -131,6 +157,12 @@ function Comment(props: Props) {
} }
}, [author, authorUri, editedMessage, isEditing, setEditing]); }, [author, authorUri, editedMessage, isEditing, setEditing]);
useEffect(() => {
if (page > 0) {
fetchReplies(uri, commentId, page, COMMENT_PAGE_SIZE_REPLIES, SORT_BY.OLDEST);
}
}, [page, uri, commentId, fetchReplies]);
function handleEditMessageChanged(event) { function handleEditMessageChanged(event) {
setCommentValue(!SIMPLE_SITE && advancedEditor ? event : event.target.value); setCommentValue(!SIMPLE_SITE && advancedEditor ? event : event.target.value);
} }
@ -176,7 +208,7 @@ function Comment(props: Props) {
> >
<div <div
className={classnames('comment__content', { className={classnames('comment__content', {
'comment--highlighted': linkedComment && linkedComment.comment_id === commentId, 'comment--highlighted': linkedCommentId && linkedCommentId === commentId,
'comment--slimed': slimedToDeath && !displayDeadComment, 'comment--slimed': slimedToDeath && !displayDeadComment,
})} })}
> >
@ -302,13 +334,43 @@ function Comment(props: Props) {
{ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />} {ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />}
</div> </div>
{numDirectReplies > 0 && !showReplies && (
<div className="comment__actions">
<Button
label={
numDirectReplies < 2
? __('Show reply')
: __('Show %count% replies', { count: numDirectReplies })
}
button="link"
onClick={() => {
setShowReplies(true);
if (page === 0) {
setPage(1);
}
}}
/>
</div>
)}
{numDirectReplies > 0 && showReplies && (
<div className="comment__actions">
<Button label={__('Hide replies')} button="link" onClick={() => setShowReplies(false)} />
</div>
)}
{isReplying && ( {isReplying && (
<CommentCreate <CommentCreate
isReply isReply
uri={uri} uri={uri}
parentId={commentId} parentId={commentId}
onDoneReplying={() => setReplying(false)} onDoneReplying={() => {
onCancelReplying={() => setReplying(false)} setShowReplies(true);
setReplying(false);
}}
onCancelReplying={() => {
setReplying(false);
}}
/> />
)} )}
</> </>
@ -317,7 +379,16 @@ function Comment(props: Props) {
</div> </div>
</div> </div>
<CommentsReplies threadDepth={threadDepth - 1} uri={uri} parentId={commentId} linkedComment={linkedComment} /> {showReplies && (
<CommentsReplies
threadDepth={threadDepth - 1}
uri={uri}
parentId={commentId}
linkedCommentId={linkedCommentId}
numDirectReplies={numDirectReplies}
onShowMore={() => setPage(page + 1)}
/>
)}
</li> </li>
); );
} }

View file

@ -35,7 +35,6 @@ type Props = {
toast: (string) => void, toast: (string) => void,
claimIsMine: boolean, claimIsMine: boolean,
sendTip: ({}, (any) => void, (any) => void) => void, sendTip: ({}, (any) => void, (any) => void) => void,
justCommented: Array<string>,
}; };
export function CommentCreate(props: Props) { export function CommentCreate(props: Props) {
@ -54,7 +53,6 @@ export function CommentCreate(props: Props) {
livestream, livestream,
claimIsMine, claimIsMine,
sendTip, sendTip,
justCommented,
} = props; } = props;
const buttonref: ElementRef<any> = React.useRef(); const buttonref: ElementRef<any> = React.useRef();
const { const {
@ -153,7 +151,6 @@ export function CommentCreate(props: Props) {
setIsReviewingSupportComment(false); setIsReviewingSupportComment(false);
setIsSupportComment(false); setIsSupportComment(false);
setCommentFailure(false); setCommentFailure(false);
justCommented.push(res.comment_id);
if (onDoneReplying) { if (onDoneReplying) {
onDoneReplying(); onDoneReplying();
@ -217,7 +214,13 @@ export function CommentCreate(props: Props) {
autoFocus autoFocus
button="primary" button="primary"
disabled={disabled} disabled={disabled}
label={isSubmitting ? __('Sending...') : (commentFailure && tipAmount === successTip.tipAmount) ? __('Re-submit') : __('Send')} label={
isSubmitting
? __('Sending...')
: commentFailure && tipAmount === successTip.tipAmount
? __('Re-submit')
: __('Send')
}
onClick={handleSupportComment} onClick={handleSupportComment}
/> />
<Button button="link" label={__('Cancel')} onClick={() => setIsReviewingSupportComment(false)} /> <Button button="link" label={__('Cancel')} onClick={() => setIsReviewingSupportComment(false)} />

View file

@ -3,7 +3,6 @@ import { makeSelectChannelPermUrlForClaimUri, makeSelectClaimIsMine, makeSelectC
import { import {
doCommentAbandon, doCommentAbandon,
doCommentPin, doCommentPin,
doCommentList,
doCommentModBlock, doCommentModBlock,
doCommentModBlockAsAdmin, doCommentModBlockAsAdmin,
doCommentModBlockAsModerator, doCommentModBlockAsModerator,
@ -30,8 +29,7 @@ const perform = (dispatch) => ({
clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })), clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })),
deleteComment: (commentId, creatorChannelUrl) => dispatch(doCommentAbandon(commentId, creatorChannelUrl)), deleteComment: (commentId, creatorChannelUrl) => dispatch(doCommentAbandon(commentId, creatorChannelUrl)),
muteChannel: (channelUri) => dispatch(doChannelMute(channelUri)), muteChannel: (channelUri) => dispatch(doChannelMute(channelUri)),
pinComment: (commentId, remove) => dispatch(doCommentPin(commentId, remove)), pinComment: (commentId, claimId, remove) => dispatch(doCommentPin(commentId, claimId, remove)),
fetchComments: (uri) => dispatch(doCommentList(uri)),
// setActiveChannel: channelId => dispatch(doSetActiveChannel(channelId)), // setActiveChannel: channelId => dispatch(doSetActiveChannel(channelId)),
commentModBlock: (commenterUri) => dispatch(doCommentModBlock(commenterUri)), commentModBlock: (commenterUri) => dispatch(doCommentModBlock(commenterUri)),
commentModBlockAsAdmin: (commenterUri, blockerId) => dispatch(doCommentModBlockAsAdmin(commenterUri, blockerId)), commentModBlockAsAdmin: (commenterUri, blockerId) => dispatch(doCommentModBlockAsAdmin(commenterUri, blockerId)),

View file

@ -7,18 +7,15 @@ import Icon from 'component/common/icon';
import { parseURI } from 'lbry-redux'; import { parseURI } from 'lbry-redux';
type Props = { type Props = {
uri: string,
claim: ?Claim, claim: ?Claim,
clearPlayingUri: () => void, clearPlayingUri: () => void,
authorUri: string, // full LBRY Channel URI: lbry://@channel#123... authorUri: string, // full LBRY Channel URI: lbry://@channel#123...
commentId: string, // sha256 digest identifying the comment commentId: string, // sha256 digest identifying the comment
commentIsMine: boolean, // if this comment was signed by an owned channel commentIsMine: boolean, // if this comment was signed by an owned channel
deleteComment: (string, ?string) => void, deleteComment: (string, ?string) => void,
linkedComment?: any,
isPinned: boolean, isPinned: boolean,
pinComment: (string, boolean) => Promise<any>, pinComment: (string, string, boolean) => Promise<any>,
muteChannel: (string) => void, muteChannel: (string) => void,
fetchComments: (string) => void,
handleEditComment: () => void, handleEditComment: () => void,
contentChannelPermanentUrl: any, contentChannelPermanentUrl: any,
activeChannelClaim: ?ChannelClaim, activeChannelClaim: ?ChannelClaim,
@ -35,7 +32,6 @@ type Props = {
function CommentMenuList(props: Props) { function CommentMenuList(props: Props) {
const { const {
uri,
claim, claim,
authorUri, authorUri,
commentIsMine, commentIsMine,
@ -49,7 +45,6 @@ function CommentMenuList(props: Props) {
isTopLevel, isTopLevel,
isPinned, isPinned,
handleEditComment, handleEditComment,
fetchComments,
commentModBlock, commentModBlock,
commentModBlockAsAdmin, commentModBlockAsAdmin,
commentModBlockAsModerator, commentModBlockAsModerator,
@ -77,8 +72,8 @@ function CommentMenuList(props: Props) {
activeModeratorInfo && activeModeratorInfo &&
Object.values(activeModeratorInfo.delegators).includes(contentChannelClaim.claim_id); Object.values(activeModeratorInfo.delegators).includes(contentChannelClaim.claim_id);
function handlePinComment(commentId, remove) { function handlePinComment(commentId, claimId, remove) {
pinComment(commentId, remove).then(() => fetchComments(uri)); pinComment(commentId, claimId, remove);
} }
function handleDeleteComment() { function handleDeleteComment() {
@ -122,7 +117,7 @@ function CommentMenuList(props: Props) {
{activeChannelIsCreator && isTopLevel && ( {activeChannelIsCreator && isTopLevel && (
<MenuItem <MenuItem
className="comment__menu-option menu__link" className="comment__menu-option menu__link"
onSelect={isPinned ? () => handlePinComment(commentId, true) : () => handlePinComment(commentId, false)} onSelect={() => handlePinComment(commentId, claim ? claim.claim_id : '', isPinned)}
> >
<span className={'button__content'}> <span className={'button__content'}>
<Icon aria-hidden icon={ICONS.PIN} className={'icon'} /> <Icon aria-hidden icon={ICONS.PIN} className={'icon'} />

View file

@ -6,17 +6,22 @@ import { makeSelectMyReactionsForComment, makeSelectOthersReactionsForComment }
import { doCommentReact } from 'redux/actions/comments'; import { doCommentReact } from 'redux/actions/comments';
import { selectActiveChannelId } from 'redux/selectors/app'; import { selectActiveChannelId } from 'redux/selectors/app';
const select = (state, props) => ({ const select = (state, props) => {
claim: makeSelectClaimForUri(props.uri)(state), const activeChannelId = selectActiveChannelId(state);
claimIsMine: makeSelectClaimIsMine(props.uri)(state), const reactionKey = activeChannelId ? `${props.commentId}:${activeChannelId}` : props.commentId;
myReacts: makeSelectMyReactionsForComment(props.commentId)(state),
othersReacts: makeSelectOthersReactionsForComment(props.commentId)(state),
activeChannelId: selectActiveChannelId(state),
});
const perform = dispatch => ({ return {
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
myReacts: makeSelectMyReactionsForComment(reactionKey)(state),
othersReacts: makeSelectOthersReactionsForComment(reactionKey)(state),
activeChannelId,
};
};
const perform = (dispatch) => ({
react: (commentId, type) => dispatch(doCommentReact(commentId, type)), react: (commentId, type) => dispatch(doCommentReact(commentId, type)),
doToast: params => dispatch(doToast(params)), doToast: (params) => dispatch(doToast(params)),
}); });
export default connect(select, perform)(Comment); export default connect(select, perform)(Comment);

View file

@ -2,32 +2,42 @@ import { connect } from 'react-redux';
import { makeSelectClaimIsMine, selectFetchingMyChannels, selectMyChannelClaims } from 'lbry-redux'; import { makeSelectClaimIsMine, selectFetchingMyChannels, selectMyChannelClaims } from 'lbry-redux';
import { import {
makeSelectTopLevelCommentsForUri, makeSelectTopLevelCommentsForUri,
makeSelectTopLevelTotalPagesForUri,
selectIsFetchingComments, selectIsFetchingComments,
makeSelectTotalCommentsCountForUri, makeSelectTotalCommentsCountForUri,
selectOthersReactsById, selectOthersReactsById,
makeSelectCommentsDisabledForUri, makeSelectCommentsDisabledForUri,
selectMyReactionsByCommentId,
makeSelectCommentIdsForUri,
} from 'redux/selectors/comments'; } from 'redux/selectors/comments';
import { doCommentList, doCommentReactList } from 'redux/actions/comments'; import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from 'redux/actions/comments';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectActiveChannelId } from 'redux/selectors/app'; import { selectActiveChannelId } from 'redux/selectors/app';
import CommentsList from './view'; import CommentsList from './view';
const select = (state, props) => ({ const select = (state, props) => {
myChannels: selectMyChannelClaims(state), return {
comments: makeSelectTopLevelCommentsForUri(props.uri)(state), myChannels: selectMyChannelClaims(state),
totalComments: makeSelectTotalCommentsCountForUri(props.uri)(state), allCommentIds: makeSelectCommentIdsForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state), topLevelComments: makeSelectTopLevelCommentsForUri(props.uri)(state),
isFetchingComments: selectIsFetchingComments(state), topLevelTotalPages: makeSelectTopLevelTotalPagesForUri(props.uri)(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, totalComments: makeSelectTotalCommentsCountForUri(props.uri)(state),
commentsDisabledBySettings: makeSelectCommentsDisabledForUri(props.uri)(state), claimIsMine: makeSelectClaimIsMine(props.uri)(state),
fetchingChannels: selectFetchingMyChannels(state), isFetchingComments: selectIsFetchingComments(state),
reactionsById: selectOthersReactsById(state), commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
activeChannelId: selectActiveChannelId(state), commentsDisabledBySettings: makeSelectCommentsDisabledForUri(props.uri)(state),
}); fetchingChannels: selectFetchingMyChannels(state),
myReactsByCommentId: selectMyReactionsByCommentId(state),
othersReactsById: selectOthersReactsById(state),
activeChannelId: selectActiveChannelId(state),
};
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
fetchComments: (uri) => dispatch(doCommentList(uri)), fetchTopLevelComments: (uri, page, pageSize, sortBy) => dispatch(doCommentList(uri, '', page, pageSize, sortBy)),
fetchReacts: (uri) => dispatch(doCommentReactList(uri)), fetchComment: (commentId) => dispatch(doCommentById(commentId)),
fetchReacts: (commentIds) => dispatch(doCommentReactList(commentIds)),
resetComments: (uri) => dispatch(doCommentReset(uri)),
}); });
export default connect(select, perform)(CommentsList); export default connect(select, perform)(CommentsList);

View file

@ -1,7 +1,7 @@
// @flow // @flow
import * as REACTION_TYPES from 'constants/reactions'; import * as REACTION_TYPES from 'constants/reactions';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import { SORT_COMMENTS_NEW, SORT_COMMENTS_BEST, SORT_COMMENTS_CONTROVERSIAL } from 'constants/comment'; import { COMMENT_PAGE_SIZE_TOP_LEVEL, SORT_BY } from 'constants/comment';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import CommentView from 'component/comment'; import CommentView from 'component/comment';
@ -11,59 +11,78 @@ import Card from 'component/common/card';
import CommentCreate from 'component/commentCreate'; import CommentCreate from 'component/commentCreate';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import { ENABLE_COMMENT_REACTIONS } from 'config'; import { ENABLE_COMMENT_REACTIONS } from 'config';
import { sortComments } from 'util/comments';
import Empty from 'component/common/empty'; import Empty from 'component/common/empty';
import debounce from 'util/debounce';
const DEBOUNCE_SCROLL_HANDLER_MS = 200;
function scaleToDevicePixelRatio(value) {
const devicePixelRatio = window.devicePixelRatio || 1.0;
if (devicePixelRatio < 1.0) {
return Math.ceil(value / devicePixelRatio);
}
return Math.ceil(value * devicePixelRatio);
}
type Props = { type Props = {
comments: Array<Comment>, allCommentIds: any,
topLevelComments: Array<Comment>,
topLevelTotalPages: number,
commentsDisabledBySettings: boolean, commentsDisabledBySettings: boolean,
fetchComments: (string) => void, fetchTopLevelComments: (string, number, number, number) => void,
fetchReacts: (string) => Promise<any>, fetchComment: (string) => void,
fetchReacts: (Array<string>) => Promise<any>,
resetComments: (string) => void,
uri: string, uri: string,
claimIsMine: boolean, claimIsMine: boolean,
myChannels: ?Array<ChannelClaim>, myChannels: ?Array<ChannelClaim>,
isFetchingComments: boolean, isFetchingComments: boolean,
linkedComment: any, linkedCommentId?: string,
totalComments: number, totalComments: number,
fetchingChannels: boolean, fetchingChannels: boolean,
reactionsById: ?{ [string]: { [REACTION_TYPES.LIKE | REACTION_TYPES.DISLIKE]: number } }, myReactsByCommentId: ?{ [string]: Array<string> }, // "CommentId:MyChannelId" -> reaction array (note the ID concatenation)
othersReactsById: ?{ [string]: { [REACTION_TYPES.LIKE | REACTION_TYPES.DISLIKE]: number } },
activeChannelId: ?string, activeChannelId: ?string,
}; };
function CommentList(props: Props) { function CommentList(props: Props) {
const { const {
fetchComments, allCommentIds,
fetchTopLevelComments,
fetchComment,
fetchReacts, fetchReacts,
resetComments,
uri, uri,
comments, topLevelComments,
topLevelTotalPages,
commentsDisabledBySettings, commentsDisabledBySettings,
claimIsMine, claimIsMine,
myChannels, myChannels,
isFetchingComments, isFetchingComments,
linkedComment, linkedCommentId,
totalComments, totalComments,
fetchingChannels, fetchingChannels,
reactionsById, myReactsByCommentId,
othersReactsById,
activeChannelId, activeChannelId,
} = props; } = props;
const commentRef = React.useRef(); const commentRef = React.useRef();
const spinnerRef = React.useRef(); const spinnerRef = React.useRef();
const [sort, setSort] = usePersistedState( const DEFAULT_SORT = ENABLE_COMMENT_REACTIONS ? SORT_BY.POPULARITY : SORT_BY.NEWEST;
'comment-sort', const [sort, setSort] = usePersistedState('comment-sort-by', DEFAULT_SORT);
ENABLE_COMMENT_REACTIONS ? SORT_COMMENTS_BEST : SORT_COMMENTS_NEW const [page, setPage] = React.useState(0);
); const totalFetchedComments = allCommentIds ? allCommentIds.length : 0;
const [start] = React.useState(0);
const [end, setEnd] = React.useState(9);
// Display comments immediately if not fetching reactions // Display comments immediately if not fetching reactions
// If not, wait to show comments until reactions are fetched // If not, wait to show comments until reactions are fetched
const [readyToDisplayComments, setReadyToDisplayComments] = React.useState( const [readyToDisplayComments, setReadyToDisplayComments] = React.useState(
Boolean(reactionsById) || !ENABLE_COMMENT_REACTIONS Boolean(othersReactsById) || !ENABLE_COMMENT_REACTIONS
); );
const [justCommented] = React.useState([]);
const linkedCommentId = linkedComment && linkedComment.comment_id;
const hasNoComments = !totalComments; const hasNoComments = !totalComments;
const moreBelow = totalComments - end > 0; const moreBelow = page < topLevelTotalPages;
const isMyComment = (channelId: string): boolean => { const isMyComment = (channelId: string): boolean => {
if (myChannels != null && channelId != null) { if (myChannels != null && channelId != null) {
for (let i = 0; i < myChannels.length; i++) { for (let i = 0; i < myChannels.length; i++) {
@ -75,86 +94,124 @@ function CommentList(props: Props) {
return false; return false;
}; };
const handleMoreBelow = React.useCallback(() => { function changeSort(newSort) {
if (moreBelow) { if (sort !== newSort) {
setEnd(end + 10); setSort(newSort);
setPage(0); // Invalidate existing comments
} }
}, [end, setEnd, moreBelow]); }
// Reset comments
useEffect(() => { useEffect(() => {
fetchComments(uri); if (page === 0) {
}, [fetchComments, uri]); resetComments(uri);
setPage(1);
useEffect(() => {
if (totalComments && ENABLE_COMMENT_REACTIONS && !fetchingChannels) {
fetchReacts(uri)
.then(() => {
setReadyToDisplayComments(true);
})
.catch(() => setReadyToDisplayComments(true));
} }
}, [fetchReacts, uri, totalComments, activeChannelId, fetchingChannels]); }, [page, uri, resetComments]);
// Fetch top-level comments
useEffect(() => {
if (page !== 0) {
if (page === 1 && linkedCommentId) {
fetchComment(linkedCommentId);
}
fetchTopLevelComments(uri, page, COMMENT_PAGE_SIZE_TOP_LEVEL, sort);
}
}, [fetchTopLevelComments, uri, page, resetComments, sort, linkedCommentId, fetchComment]);
// Fetch reacts
useEffect(() => {
if (totalFetchedComments > 0 && ENABLE_COMMENT_REACTIONS && !fetchingChannels) {
let idsForReactionFetch;
if (!othersReactsById || !myReactsByCommentId) {
idsForReactionFetch = allCommentIds;
} else {
idsForReactionFetch = allCommentIds.filter((commentId) => {
const key = activeChannelId ? `${commentId}:${activeChannelId}` : commentId;
return !othersReactsById[key] || !myReactsByCommentId[key];
});
}
if (idsForReactionFetch.length !== 0) {
fetchReacts(idsForReactionFetch)
.then(() => {
setReadyToDisplayComments(true);
})
.catch(() => setReadyToDisplayComments(true));
}
}
}, [
totalFetchedComments,
allCommentIds,
othersReactsById,
myReactsByCommentId,
fetchReacts,
uri,
activeChannelId,
fetchingChannels,
]);
// Scroll to linked-comment
useEffect(() => { useEffect(() => {
if (readyToDisplayComments && linkedCommentId && commentRef && commentRef.current) { if (readyToDisplayComments && linkedCommentId && commentRef && commentRef.current) {
commentRef.current.scrollIntoView({ block: 'start' }); commentRef.current.scrollIntoView({ block: 'start' });
window.scrollBy(0, -100); window.scrollBy(0, -125);
} }
}, [readyToDisplayComments, linkedCommentId]); }, [readyToDisplayComments, linkedCommentId]);
// Infinite scroll
useEffect(() => { useEffect(() => {
function handleCommentScroll(e) { function shouldFetchNextPage(page, topLevelTotalPages, window, document, yPrefetchPx = 1000) {
// $FlowFixMe if (!spinnerRef || !spinnerRef.current) {
const rect = spinnerRef.current.getBoundingClientRect(); return false;
}
const rect = spinnerRef.current.getBoundingClientRect(); // $FlowFixMe
const windowH = window.innerHeight || document.documentElement.clientHeight; // $FlowFixMe
const windowW = window.innerWidth || document.documentElement.clientWidth; // $FlowFixMe
const isApproachingViewport = yPrefetchPx !== 0 && rect.top < windowH + scaleToDevicePixelRatio(yPrefetchPx);
const isInViewport = const isInViewport =
rect.top >= 0 && rect.width > 0 &&
rect.left >= 0 && rect.height > 0 &&
rect.bottom >= 0 &&
rect.right >= 0 &&
// $FlowFixMe // $FlowFixMe
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.top <= windowH &&
// $FlowFixMe // $FlowFixMe
rect.right <= (window.innerWidth || document.documentElement.clientWidth); rect.left <= windowW;
if (isInViewport) { return (isInViewport || isApproachingViewport) && page < topLevelTotalPages;
handleMoreBelow();
}
} }
const handleCommentScroll = debounce(() => {
if (shouldFetchNextPage(page, topLevelTotalPages, window, document)) {
setPage(page + 1);
}
}, DEBOUNCE_SCROLL_HANDLER_MS);
if (!isFetchingComments && readyToDisplayComments && moreBelow && spinnerRef && spinnerRef.current) { if (!isFetchingComments && readyToDisplayComments && moreBelow && spinnerRef && spinnerRef.current) {
window.addEventListener('scroll', handleCommentScroll); if (shouldFetchNextPage(page, topLevelTotalPages, window, document, 0)) {
} setPage(page + 1);
return () => window.removeEventListener('scroll', handleCommentScroll);
}, [moreBelow, handleMoreBelow, spinnerRef, isFetchingComments, readyToDisplayComments]);
function prepareComments(arrayOfComments, linkedComment, isFetchingComments) {
let orderedComments = [];
if (linkedComment) {
if (!linkedComment.parent_id) {
orderedComments = arrayOfComments.filter((c) => c.comment_id !== linkedComment.comment_id);
orderedComments.unshift(linkedComment);
} else { } else {
const parentComment = arrayOfComments.find((c) => c.comment_id === linkedComment.parent_id); window.addEventListener('scroll', handleCommentScroll);
orderedComments = arrayOfComments.filter((c) => c.comment_id !== linkedComment.parent_id); return () => window.removeEventListener('scroll', handleCommentScroll);
if (parentComment) {
orderedComments.unshift(parentComment);
}
} }
} else {
orderedComments = arrayOfComments;
} }
return orderedComments; }, [
} page,
moreBelow,
spinnerRef,
isFetchingComments,
readyToDisplayComments,
topLevelComments.length,
topLevelTotalPages,
]);
// Default to newest first for apps that don't have comment reactions const displayedComments = readyToDisplayComments ? topLevelComments : [];
const sortedComments = reactionsById
? sortComments({ comments, reactionsById, sort, isMyComment, justCommented })
: [];
const displayedComments = readyToDisplayComments
? prepareComments(sortedComments, linkedComment).slice(start, end)
: [];
return ( return (
<Card <Card
@ -174,9 +231,9 @@ function CommentList(props: Props) {
label={__('Best')} label={__('Best')}
icon={ICONS.BEST} icon={ICONS.BEST}
iconSize={18} iconSize={18}
onClick={() => setSort(SORT_COMMENTS_BEST)} onClick={() => changeSort(SORT_BY.POPULARITY)}
className={classnames(`button-toggle`, { className={classnames(`button-toggle`, {
'button-toggle--active': sort === SORT_COMMENTS_BEST, 'button-toggle--active': sort === SORT_BY.POPULARITY,
})} })}
/> />
<Button <Button
@ -184,9 +241,9 @@ function CommentList(props: Props) {
label={__('Controversial')} label={__('Controversial')}
icon={ICONS.CONTROVERSIAL} icon={ICONS.CONTROVERSIAL}
iconSize={18} iconSize={18}
onClick={() => setSort(SORT_COMMENTS_CONTROVERSIAL)} onClick={() => changeSort(SORT_BY.CONTROVERSY)}
className={classnames(`button-toggle`, { className={classnames(`button-toggle`, {
'button-toggle--active': sort === SORT_COMMENTS_CONTROVERSIAL, 'button-toggle--active': sort === SORT_BY.CONTROVERSY,
})} })}
/> />
<Button <Button
@ -194,9 +251,9 @@ function CommentList(props: Props) {
label={__('New')} label={__('New')}
icon={ICONS.NEW} icon={ICONS.NEW}
iconSize={18} iconSize={18}
onClick={() => setSort(SORT_COMMENTS_NEW)} onClick={() => changeSort(SORT_BY.NEWEST)}
className={classnames(`button-toggle`, { className={classnames(`button-toggle`, {
'button-toggle--active': sort === SORT_COMMENTS_NEW, 'button-toggle--active': sort === SORT_BY.NEWEST,
})} })}
/> />
</span> </span>
@ -206,22 +263,21 @@ function CommentList(props: Props) {
icon={ICONS.REFRESH} icon={ICONS.REFRESH}
title={__('Refresh')} title={__('Refresh')}
onClick={() => { onClick={() => {
fetchComments(uri); setPage(0);
fetchReacts(uri);
}} }}
/> />
</> </>
} }
actions={ actions={
<> <>
<CommentCreate uri={uri} justCommented={justCommented} /> <CommentCreate uri={uri} />
{!commentsDisabledBySettings && !isFetchingComments && hasNoComments && ( {!commentsDisabledBySettings && !isFetchingComments && hasNoComments && (
<Empty padded text={__('That was pretty deep. What do you think?')} /> <Empty padded text={__('That was pretty deep. What do you think?')} />
)} )}
<ul className="comments" ref={commentRef}> <ul className="comments" ref={commentRef}>
{comments && {topLevelComments &&
displayedComments && displayedComments &&
displayedComments.map((comment) => { displayedComments.map((comment) => {
return ( return (
@ -238,9 +294,10 @@ function CommentList(props: Props) {
timePosted={comment.timestamp * 1000} timePosted={comment.timestamp * 1000}
claimIsMine={claimIsMine} claimIsMine={claimIsMine}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)} commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
linkedComment={linkedComment} linkedCommentId={linkedCommentId}
isPinned={comment.is_pinned} isPinned={comment.is_pinned}
supportAmount={comment.support_amount} supportAmount={comment.support_amount}
numDirectReplies={comment.replies}
/> />
); );
})} })}

View file

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

View file

@ -3,32 +3,45 @@ import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import Comment from 'component/comment'; import Comment from 'component/comment';
import Button from 'component/button'; import Button from 'component/button';
import Spinner from 'component/spinner';
import ChannelThumbnail from 'component/channelThumbnail';
type Props = { type Props = {
comments: Array<any>, fetchedReplies: Array<any>,
totalReplies: number,
uri: string, uri: string,
parentId: string,
claimIsMine: boolean, claimIsMine: boolean,
myChannels: ?Array<ChannelClaim>, myChannels: ?Array<ChannelClaim>,
linkedComment?: Comment, linkedCommentId?: string,
commentingEnabled: boolean, commentingEnabled: boolean,
threadDepth: number, threadDepth: number,
numDirectReplies: number,
isFetchingByParentId: { [string]: boolean },
onShowMore?: () => void,
}; };
function CommentsReplies(props: Props) { function CommentsReplies(props: Props) {
const { uri, comments, claimIsMine, myChannels, linkedComment, commentingEnabled, threadDepth } = props; const {
uri,
parentId,
fetchedReplies,
totalReplies,
claimIsMine,
myChannels,
linkedCommentId,
commentingEnabled,
threadDepth,
numDirectReplies,
isFetchingByParentId,
onShowMore,
} = props;
const [isExpanded, setExpanded] = React.useState(true); const [isExpanded, setExpanded] = React.useState(true);
const [start, setStart] = React.useState(0);
const [end, setEnd] = React.useState(9);
const sortedComments = comments ? [...comments].reverse() : [];
const numberOfComments = comments ? comments.length : 0;
const linkedCommentId = linkedComment ? linkedComment.comment_id : '';
const commentsIndexOfLInked = comments && sortedComments.findIndex((e) => e.comment_id === linkedCommentId);
function showMore() { function showMore() {
if (start > 0) { if (onShowMore) {
setStart(0); onShowMore();
} else {
setEnd(numberOfComments);
} }
} }
@ -44,35 +57,12 @@ function CommentsReplies(props: Props) {
return false; return false;
} }
function handleCommentDone() { const displayedComments = fetchedReplies;
if (!isExpanded) {
setExpanded(true);
setStart(numberOfComments || 0);
}
setEnd(numberOfComments + 1);
}
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 ( return (
Boolean(numberOfComments) && ( Boolean(numDirectReplies) && (
<div className="comment__replies-container"> <div className="comment__replies-container">
{Boolean(numberOfComments) && !isExpanded && ( {Boolean(numDirectReplies) && !isExpanded && (
<div className="comment__actions--nested"> <div className="comment__actions--nested">
<Button <Button
className="comment__action" className="comment__action"
@ -82,13 +72,13 @@ function CommentsReplies(props: Props) {
/> />
</div> </div>
)} )}
{comments && displayedComments && isExpanded && ( {fetchedReplies && displayedComments && isExpanded && (
<div> <div>
<div className="comment__replies"> <div className="comment__replies">
<Button className="comment__threadline" aria-label="Hide Replies" onClick={() => setExpanded(false)} /> <Button className="comment__threadline" aria-label="Hide Replies" onClick={() => setExpanded(false)} />
<ul className="comments--replies"> <ul className="comments--replies">
{displayedComments.map((comment, index) => { {displayedComments.map((comment) => {
return ( return (
<Comment <Comment
threadDepth={threadDepth} threadDepth={threadDepth}
@ -102,22 +92,46 @@ function CommentsReplies(props: Props) {
timePosted={comment.timestamp * 1000} timePosted={comment.timestamp * 1000}
claimIsMine={claimIsMine} claimIsMine={claimIsMine}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)} commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
linkedComment={linkedComment} linkedCommentId={linkedCommentId}
commentingEnabled={commentingEnabled} commentingEnabled={commentingEnabled}
handleCommentDone={handleCommentDone}
supportAmount={comment.support_amount} supportAmount={comment.support_amount}
numDirectReplies={comment.replies}
/> />
); );
})} })}
{totalReplies < numDirectReplies && (
<li className="comment comment--reply">
<div className="comment__content">
<div className="comment__thumbnail-wrapper">
<ChannelThumbnail xsmall className="comment__author-thumbnail" />
</div>
<div className="comment__body-container comment--blocked">
<div className="comment__meta">
<em>---</em>
</div>
<div>
<em>{__('Comment(s) blocked.')}</em>
</div>
</div>
</div>
</li>
)}
</ul> </ul>
</div> </div>
</div> </div>
)} )}
{isExpanded && comments && (end < numberOfComments || start > 0) && ( {isExpanded && fetchedReplies && displayedComments.length < totalReplies && (
<div className="comment__actions"> <div className="comment__actions--nested">
<Button button="link" label={__('Show more')} onClick={showMore} className="button--uri-indicator" /> <Button button="link" label={__('Show more')} onClick={showMore} className="button--uri-indicator" />
</div> </div>
)} )}
{isFetchingByParentId[parentId] && (
<div className="comment__replies-container">
<div className="comment__actions--nested">
<Spinner type="small" />
</div>
</div>
)}
</div> </div>
) )
); );

View file

@ -17,7 +17,7 @@ type Props = {
embed?: boolean, embed?: boolean,
doCommentSocketConnect: (string, string) => void, doCommentSocketConnect: (string, string) => void,
doCommentSocketDisconnect: (string) => void, doCommentSocketDisconnect: (string) => void,
doCommentList: (string, number, number) => void, doCommentList: (string, string, number, number) => void,
comments: Array<Comment>, comments: Array<Comment>,
fetchingComments: boolean, fetchingComments: boolean,
doSuperChatList: (string) => void, doSuperChatList: (string) => void,
@ -68,7 +68,7 @@ export default function LivestreamComments(props: Props) {
React.useEffect(() => { React.useEffect(() => {
if (claimId) { if (claimId) {
doCommentList(uri, 1, 75); doCommentList(uri, '', 1, 75);
doSuperChatList(uri); doSuperChatList(uri);
doCommentSocketConnect(uri, claimId); doCommentSocketConnect(uri, claimId);
} }

View file

@ -248,6 +248,8 @@ export const DISMISS_ERROR = 'DISMISS_ERROR';
export const COMMENT_LIST_STARTED = 'COMMENT_LIST_STARTED'; export const COMMENT_LIST_STARTED = 'COMMENT_LIST_STARTED';
export const COMMENT_LIST_COMPLETED = 'COMMENT_LIST_COMPLETED'; export const COMMENT_LIST_COMPLETED = 'COMMENT_LIST_COMPLETED';
export const COMMENT_LIST_FAILED = 'COMMENT_LIST_FAILED'; export const COMMENT_LIST_FAILED = 'COMMENT_LIST_FAILED';
export const COMMENT_LIST_RESET = 'COMMENT_LIST_RESET';
export const COMMENT_BY_ID_COMPLETED = 'COMMENT_BY_ID_COMPLETED';
export const COMMENT_CREATE_STARTED = 'COMMENT_CREATE_STARTED'; export const COMMENT_CREATE_STARTED = 'COMMENT_CREATE_STARTED';
export const COMMENT_CREATE_COMPLETED = 'COMMENT_CREATE_COMPLETED'; export const COMMENT_CREATE_COMPLETED = 'COMMENT_CREATE_COMPLETED';
export const COMMENT_CREATE_FAILED = 'COMMENT_CREATE_FAILED'; export const COMMENT_CREATE_FAILED = 'COMMENT_CREATE_FAILED';

View file

@ -4,8 +4,18 @@ export const SORT_COMMENTS_NEW = 'new';
export const SORT_COMMENTS_BEST = 'best'; export const SORT_COMMENTS_BEST = 'best';
export const SORT_COMMENTS_CONTROVERSIAL = 'controversial'; export const SORT_COMMENTS_CONTROVERSIAL = 'controversial';
export const SORT_BY = {
NEWEST: 0,
OLDEST: 1,
CONTROVERSY: 2,
POPULARITY: 3,
};
export const BLOCK_LEVEL = { export const BLOCK_LEVEL = {
SELF: 'self', SELF: 'self',
MODERATOR: 'moderator', MODERATOR: 'moderator',
ADMIN: 'admin', ADMIN: 'admin',
}; };
export const COMMENT_PAGE_SIZE_TOP_LEVEL = 10;
export const COMMENT_PAGE_SIZE_REPLIES = 10;

View file

@ -16,7 +16,6 @@ import {
import { makeSelectCostInfoForUri, doFetchCostInfoForUri } from 'lbryinc'; import { makeSelectCostInfoForUri, doFetchCostInfoForUri } from 'lbryinc';
import { selectShowMatureContent, makeSelectClientSetting } from 'redux/selectors/settings'; import { selectShowMatureContent, makeSelectClientSetting } from 'redux/selectors/settings';
import { makeSelectFileRenderModeForUri } from 'redux/selectors/content'; import { makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import { makeSelectCommentForCommentId } from 'redux/selectors/comments';
import { DISABLE_COMMENTS_TAG } from 'constants/tags'; import { DISABLE_COMMENTS_TAG } from 'constants/tags';
import FilePage from './view'; import FilePage from './view';
@ -24,11 +23,10 @@ import FilePage from './view';
const select = (state, props) => { const select = (state, props) => {
const { search } = props.location; const { search } = props.location;
const urlParams = new URLSearchParams(search); const urlParams = new URLSearchParams(search);
const linkedCommentId = urlParams.get('lc');
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID); const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
return { return {
linkedComment: makeSelectCommentForCommentId(linkedCommentId)(state), linkedCommentId: urlParams.get('lc'),
costInfo: makeSelectCostInfoForUri(props.uri)(state), costInfo: makeSelectCostInfoForUri(props.uri)(state),
metadata: makeSelectMetadataForUri(props.uri)(state), metadata: makeSelectMetadataForUri(props.uri)(state),
obscureNsfw: !selectShowMatureContent(state), obscureNsfw: !selectShowMatureContent(state),

View file

@ -27,7 +27,7 @@ type Props = {
renderMode: string, renderMode: string,
obscureNsfw: boolean, obscureNsfw: boolean,
isMature: boolean, isMature: boolean,
linkedComment: any, linkedCommentId?: string,
setPrimaryUri: (?string) => void, setPrimaryUri: (?string) => void,
collection?: Collection, collection?: Collection,
collectionId: string, collectionId: string,
@ -46,7 +46,7 @@ function FilePage(props: Props) {
obscureNsfw, obscureNsfw,
isMature, isMature,
costInfo, costInfo,
linkedComment, linkedCommentId,
setPrimaryUri, setPrimaryUri,
videoTheaterMode, videoTheaterMode,
commentsDisabled, commentsDisabled,
@ -146,7 +146,7 @@ function FilePage(props: Props) {
<div> <div>
{RENDER_MODES.FLOATING_MODES.includes(renderMode) && <FileTitleSection uri={uri} />} {RENDER_MODES.FLOATING_MODES.includes(renderMode) && <FileTitleSection uri={uri} />}
{commentsDisabled && <Empty text={__('The creator of this content has disabled comments.')} />} {commentsDisabled && <Empty text={__('The creator of this content has disabled comments.')} />}
{!commentsDisabled && <CommentsList uri={uri} linkedComment={linkedComment} />} {!commentsDisabled && <CommentsList uri={uri} linkedCommentId={linkedCommentId} />}
</div> </div>
{!collection && !isMarkdown && videoTheaterMode && <RecommendedContent uri={uri} />} {!collection && !isMarkdown && videoTheaterMode && <RecommendedContent uri={uri} />}
{collection && !isMarkdown && videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />} {collection && !isMarkdown && videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />}
@ -157,7 +157,7 @@ function FilePage(props: Props) {
{!collection && !isMarkdown && !videoTheaterMode && <RecommendedContent uri={uri} />} {!collection && !isMarkdown && !videoTheaterMode && <RecommendedContent uri={uri} />}
{isMarkdown && ( {isMarkdown && (
<div className="file-page__post-comments"> <div className="file-page__post-comments">
<CommentsList uri={uri} linkedComment={linkedComment} /> <CommentsList uri={uri} linkedCommentId={linkedCommentId} />
</div> </div>
)} )}
</Page> </Page>

View file

@ -2,11 +2,10 @@
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import * as REACTION_TYPES from 'constants/reactions'; import * as REACTION_TYPES from 'constants/reactions';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import { BLOCK_LEVEL } from 'constants/comment'; import { SORT_BY, BLOCK_LEVEL } from 'constants/comment';
import { Lbry, parseURI, buildURI, selectClaimsById, selectClaimsByUri, selectMyChannelClaims } from 'lbry-redux'; import { Lbry, parseURI, buildURI, selectClaimsById, selectClaimsByUri, selectMyChannelClaims } from 'lbry-redux';
import { doToast, doSeeNotifications } from 'redux/actions/notifications'; import { doToast, doSeeNotifications } from 'redux/actions/notifications';
import { import {
makeSelectCommentIdsForUri,
makeSelectMyReactionsForComment, makeSelectMyReactionsForComment,
makeSelectOthersReactionsForComment, makeSelectOthersReactionsForComment,
selectPendingCommentReacts, selectPendingCommentReacts,
@ -18,7 +17,22 @@ import { selectActiveChannelClaim } from 'redux/selectors/app';
import { toHex } from 'util/hex'; import { toHex } from 'util/hex';
import Comments from 'comments'; import Comments from 'comments';
export function doCommentList(uri: string, page: number = 1, pageSize: number = 99999) { const isDev = process.env.NODE_ENV !== 'production';
function devToast(dispatch, msg) {
if (isDev) {
console.error(msg); // eslint-disable-line
dispatch(doToast({ isError: true, message: `DEV: ${msg}` }));
}
}
export function doCommentList(
uri: string,
parentId: string,
page: number = 1,
pageSize: number = 99999,
sortBy: number = SORT_BY.NEWEST
) {
return (dispatch: Dispatch, getState: GetState) => { return (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
const claim = selectClaimsByUri(state)[uri]; const claim = selectClaimsByUri(state)[uri];
@ -35,6 +49,9 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number =
dispatch({ dispatch({
type: ACTIONS.COMMENT_LIST_STARTED, type: ACTIONS.COMMENT_LIST_STARTED,
data: {
parentId,
},
}); });
// Adding 'channel_id' and 'channel_name' enables "CreatorSettings > commentsEnabled". // Adding 'channel_id' and 'channel_name' enables "CreatorSettings > commentsEnabled".
@ -44,20 +61,28 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number =
page, page,
claim_id: claimId, claim_id: claimId,
page_size: pageSize, page_size: pageSize,
parent_id: parentId || undefined,
top_level: !parentId,
channel_id: authorChannelClaim ? authorChannelClaim.claim_id : undefined, channel_id: authorChannelClaim ? authorChannelClaim.claim_id : undefined,
channel_name: authorChannelClaim ? authorChannelClaim.name : undefined, channel_name: authorChannelClaim ? authorChannelClaim.name : undefined,
sort_by: sortBy,
}) })
.then((result: CommentListResponse) => { .then((result: CommentListResponse) => {
const { items: comments } = result; const { items: comments, total_items, total_filtered_items, total_pages } = result;
dispatch({ dispatch({
type: ACTIONS.COMMENT_LIST_COMPLETED, type: ACTIONS.COMMENT_LIST_COMPLETED,
data: { data: {
comments, comments,
parentId,
totalItems: total_items,
totalFilteredItems: total_filtered_items,
totalPages: total_pages,
claimId: claimId, claimId: claimId,
authorClaimId: authorChannelClaim ? authorChannelClaim.claim_id : undefined, authorClaimId: authorChannelClaim ? authorChannelClaim.claim_id : undefined,
uri: uri, uri: uri,
}, },
}); });
return result; return result;
}) })
.catch((error) => { .catch((error) => {
@ -70,6 +95,7 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number =
}, },
}); });
} else { } else {
devToast(dispatch, `doCommentList: ${error.message}`);
dispatch({ dispatch({
type: ACTIONS.COMMENT_LIST_FAILED, type: ACTIONS.COMMENT_LIST_FAILED,
data: error, data: error,
@ -79,6 +105,60 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number =
}; };
} }
export function doCommentById(commentId: string, toastIfNotFound: boolean = true) {
return (dispatch: Dispatch, getState: GetState) => {
return Comments.comment_by_id({ comment_id: commentId, with_ancestors: true })
.then((result: CommentByIdResponse) => {
const { item, items, ancestors } = result;
dispatch({
type: ACTIONS.COMMENT_BY_ID_COMPLETED,
data: {
comment: item || items, // Requested a change to rename it to 'item'. This covers both.
ancestors: ancestors,
},
});
return result;
})
.catch((error) => {
if (error.message === 'sql: no rows in result set' && toastIfNotFound) {
dispatch(
doToast({
isError: true,
message: __('The requested comment is no longer available.'),
})
);
} else {
devToast(dispatch, error.message);
}
});
};
}
export function doCommentReset(uri: string) {
return (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const claim = selectClaimsByUri(state)[uri];
const claimId = claim ? claim.claim_id : null;
if (!claimId) {
dispatch({
type: ACTIONS.COMMENT_LIST_FAILED,
data: 'unable to find claim for uri',
});
return;
}
dispatch({
type: ACTIONS.COMMENT_LIST_RESET,
data: {
claimId,
},
});
};
}
export function doSuperChatList(uri: string) { export function doSuperChatList(uri: string) {
return (dispatch: Dispatch, getState: GetState) => { return (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
@ -97,7 +177,7 @@ export function doSuperChatList(uri: string) {
return Comments.super_list({ return Comments.super_list({
claim_id: claimId, claim_id: claimId,
}) })
.then((result: CommentListResponse) => { .then((result: SuperListResponse) => {
const { items: comments, total_amount: totalAmount } = result; const { items: comments, total_amount: totalAmount } = result;
dispatch({ dispatch({
type: ACTIONS.COMMENT_SUPER_CHAT_LIST_COMPLETED, type: ACTIONS.COMMENT_SUPER_CHAT_LIST_COMPLETED,
@ -117,17 +197,16 @@ export function doSuperChatList(uri: string) {
}; };
} }
export function doCommentReactList(uri: string | null, commentId?: string) { export function doCommentReactList(commentIds: Array<string>) {
return (dispatch: Dispatch, getState: GetState) => { return (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
const activeChannelClaim = selectActiveChannelClaim(state); const activeChannelClaim = selectActiveChannelClaim(state);
const commentIds = uri ? makeSelectCommentIdsForUri(uri)(state) : [commentId];
dispatch({ dispatch({
type: ACTIONS.COMMENT_REACTION_LIST_STARTED, type: ACTIONS.COMMENT_REACTION_LIST_STARTED,
}); });
const params: { comment_ids: string, channel_name?: string, channel_id?: string } = { const params: CommentReactListParams = {
comment_ids: commentIds.join(','), comment_ids: commentIds.join(','),
}; };
@ -144,10 +223,12 @@ export function doCommentReactList(uri: string | null, commentId?: string) {
data: { data: {
myReactions: myReactions || {}, myReactions: myReactions || {},
othersReactions, othersReactions,
channelId: activeChannelClaim ? activeChannelClaim.claim_id : undefined,
}, },
}); });
}) })
.catch((error) => { .catch((error) => {
devToast(dispatch, `doCommentReactList: ${error.message}`);
dispatch({ dispatch({
type: ACTIONS.COMMENT_REACTION_LIST_FAILED, type: ACTIONS.COMMENT_REACTION_LIST_FAILED,
data: error, data: error,
@ -182,8 +263,9 @@ export function doCommentReact(commentId: string, type: string) {
return; return;
} }
let myReacts = makeSelectMyReactionsForComment(commentId)(state); const reactKey = `${commentId}:${activeChannelClaim.claim_id}`;
const othersReacts = makeSelectOthersReactionsForComment(commentId)(state); const myReacts = makeSelectMyReactionsForComment(reactKey)(state);
const othersReacts = makeSelectOthersReactionsForComment(reactKey)(state);
const params: CommentReactParams = { const params: CommentReactParams = {
comment_ids: commentId, comment_ids: commentId,
channel_name: activeChannelClaim.name, channel_name: activeChannelClaim.name,
@ -217,8 +299,8 @@ export function doCommentReact(commentId: string, type: string) {
dispatch({ dispatch({
type: ACTIONS.COMMENT_REACTION_LIST_COMPLETED, type: ACTIONS.COMMENT_REACTION_LIST_COMPLETED,
data: { data: {
myReactions: { [commentId]: myReactsObj }, myReactions: { [reactKey]: myReactsObj },
othersReactions: { [commentId]: othersReacts }, othersReactions: { [reactKey]: othersReacts },
}, },
}); });
@ -371,7 +453,7 @@ export function doCommentCreate(
}; };
} }
export function doCommentPin(commentId: string, remove: boolean) { export function doCommentPin(commentId: string, claimId: string, remove: boolean) {
return (dispatch: Dispatch, getState: GetState) => { return (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
const activeChannel = selectActiveChannelClaim(state); const activeChannel = selectActiveChannelClaim(state);
@ -394,7 +476,11 @@ export function doCommentPin(commentId: string, remove: boolean) {
.then((result: CommentPinResponse) => { .then((result: CommentPinResponse) => {
dispatch({ dispatch({
type: ACTIONS.COMMENT_PIN_COMPLETED, type: ACTIONS.COMMENT_PIN_COMPLETED,
data: result, data: {
pinnedComment: result.items,
claimId,
unpin: remove,
},
}); });
}) })
.catch((error) => { .catch((error) => {

View file

@ -3,17 +3,25 @@ import * as ACTIONS from 'constants/action_types';
import { handleActions } from 'util/redux-utils'; import { handleActions } from 'util/redux-utils';
import { BLOCK_LEVEL } from 'constants/comment'; import { BLOCK_LEVEL } from 'constants/comment';
const IS_DEV = process.env.NODE_ENV !== 'production';
const defaultState: CommentsState = { const defaultState: CommentsState = {
commentById: {}, // commentId -> Comment commentById: {}, // commentId -> Comment
byId: {}, // ClaimID -> list of comments byId: {}, // ClaimID -> list of fetched comment IDs.
repliesByParentId: {}, // ParentCommentID -> list of reply comments totalCommentsById: {}, // ClaimId -> ultimate total (including replies) in commentron.
topLevelCommentsById: {}, // ClaimID -> list of top level comments repliesByParentId: {}, // ParentCommentID -> list of fetched replies.
totalRepliesByParentId: {}, // ParentCommentID -> total replies in commentron.
topLevelCommentsById: {}, // ClaimID -> list of fetched top level comments.
topLevelTotalPagesById: {}, // ClaimID -> total number of top-level pages in commentron. Based on COMMENT_PAGE_SIZE_TOP_LEVEL.
topLevelTotalCommentsById: {}, // ClaimID -> total top level comments in commentron.
// TODO: // TODO:
// Remove commentsByUri // Remove commentsByUri
// It is not needed and doesn't provide anything but confusion // It is not needed and doesn't provide anything but confusion
commentsByUri: {}, // URI -> claimId commentsByUri: {}, // URI -> claimId
linkedCommentAncestors: {}, // {"linkedCommentId": ["parentId", "grandParentId", ...]}
superChatsByUri: {}, superChatsByUri: {},
isLoading: false, isLoading: false,
isLoadingByParentId: {},
isCommenting: false, isCommenting: false,
myComments: undefined, myComments: undefined,
isFetchingReacts: false, isFetchingReacts: false,
@ -39,6 +47,14 @@ const defaultState: CommentsState = {
fetchingBlockedWords: false, fetchingBlockedWords: false,
}; };
function pushToArrayInObject(obj, key, valueToPush) {
if (!obj[key]) {
obj[key] = [valueToPush];
} else if (!obj[key].includes(valueToPush)) {
obj[key].push(valueToPush);
}
}
export default handleActions( export default handleActions(
{ {
[ACTIONS.COMMENT_CREATE_STARTED]: (state: CommentsState, action: any): CommentsState => ({ [ACTIONS.COMMENT_CREATE_STARTED]: (state: CommentsState, action: any): CommentsState => ({
@ -58,10 +74,13 @@ export default handleActions(
uri, uri,
livestream, livestream,
}: { comment: Comment, claimId: string, uri: string, livestream: boolean } = action.data; }: { comment: Comment, claimId: string, uri: string, livestream: boolean } = action.data;
const commentById = Object.assign({}, state.commentById); const commentById = Object.assign({}, state.commentById);
const byId = Object.assign({}, state.byId); const byId = Object.assign({}, state.byId);
const totalCommentsById = Object.assign({}, state.totalCommentsById);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]} const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]}
const repliesByParentId = Object.assign({}, state.repliesByParentId); // {ParentCommentID -> [commentIds...] } list of reply comments const repliesByParentId = Object.assign({}, state.repliesByParentId); // {ParentCommentID -> [commentIds...] } list of reply comments
const totalRepliesByParentId = Object.assign({}, state.totalRepliesByParentId);
const commentsByUri = Object.assign({}, state.commentsByUri); const commentsByUri = Object.assign({}, state.commentsByUri);
const comments = byId[claimId] || []; const comments = byId[claimId] || [];
const newCommentIds = comments.slice(); const newCommentIds = comments.slice();
@ -75,12 +94,27 @@ export default handleActions(
newCommentIds.unshift(comment.comment_id); newCommentIds.unshift(comment.comment_id);
byId[claimId] = newCommentIds; byId[claimId] = newCommentIds;
if (totalCommentsById[claimId]) {
totalCommentsById[claimId] += 1;
}
if (comment['parent_id']) { if (comment['parent_id']) {
if (!repliesByParentId[comment.parent_id]) { if (!repliesByParentId[comment.parent_id]) {
repliesByParentId[comment.parent_id] = [comment.comment_id]; repliesByParentId[comment.parent_id] = [comment.comment_id];
} else { } else {
repliesByParentId[comment.parent_id].unshift(comment.comment_id); repliesByParentId[comment.parent_id].unshift(comment.comment_id);
} }
if (!totalRepliesByParentId[comment.parent_id]) {
totalRepliesByParentId[comment.parent_id] = 1;
} else {
totalRepliesByParentId[comment.parent_id] += 1;
}
// Update the parent's "replies" value
if (commentById[comment.parent_id]) {
commentById[comment.parent_id].replies = (commentById[comment.parent_id].replies || 0) + 1;
}
} else { } else {
if (!topLevelCommentsById[claimId]) { if (!topLevelCommentsById[claimId]) {
commentsByUri[uri] = claimId; commentsByUri[uri] = claimId;
@ -95,8 +129,10 @@ export default handleActions(
...state, ...state,
topLevelCommentsById, topLevelCommentsById,
repliesByParentId, repliesByParentId,
totalRepliesByParentId,
commentById, commentById,
byId, byId,
totalCommentsById,
commentsByUri, commentsByUri,
isLoading: false, isLoading: false,
isCommenting: false, isCommenting: false,
@ -147,12 +183,14 @@ export default handleActions(
}, },
[ACTIONS.COMMENT_REACTION_LIST_COMPLETED]: (state: CommentsState, action: any): CommentsState => { [ACTIONS.COMMENT_REACTION_LIST_COMPLETED]: (state: CommentsState, action: any): CommentsState => {
const { myReactions, othersReactions } = action.data; const { myReactions, othersReactions, channelId } = action.data;
const myReacts = Object.assign({}, state.myReactsByCommentId); const myReacts = Object.assign({}, state.myReactsByCommentId);
const othersReacts = Object.assign({}, state.othersReactsByCommentId); const othersReacts = Object.assign({}, state.othersReactsByCommentId);
if (myReactions) { if (myReactions) {
Object.entries(myReactions).forEach(([commentId, reactions]) => { Object.entries(myReactions).forEach(([commentId, reactions]) => {
myReacts[commentId] = Object.entries(reactions).reduce((acc, [name, count]) => { const key = channelId ? `${commentId}:${channelId}` : commentId;
myReacts[key] = Object.entries(reactions).reduce((acc, [name, count]) => {
if (count === 1) { if (count === 1) {
acc.push(name); acc.push(name);
} }
@ -160,9 +198,11 @@ export default handleActions(
}, []); }, []);
}); });
} }
if (othersReactions) { if (othersReactions) {
Object.entries(othersReactions).forEach(([commentId, reactions]) => { Object.entries(othersReactions).forEach(([commentId, reactions]) => {
othersReacts[commentId] = reactions; const key = channelId ? `${commentId}:${channelId}` : commentId;
othersReacts[key] = reactions;
}); });
} }
@ -174,10 +214,32 @@ export default handleActions(
}; };
}, },
[ACTIONS.COMMENT_LIST_STARTED]: (state) => ({ ...state, isLoading: true }), [ACTIONS.COMMENT_LIST_STARTED]: (state, action: any) => {
const { parentId } = action.data;
const isLoadingByParentId = Object.assign({}, state.isLoadingByParentId);
if (parentId) {
isLoadingByParentId[parentId] = true;
}
return {
...state,
isLoading: true,
isLoadingByParentId,
};
},
[ACTIONS.COMMENT_LIST_COMPLETED]: (state: CommentsState, action: any) => { [ACTIONS.COMMENT_LIST_COMPLETED]: (state: CommentsState, action: any) => {
const { comments, claimId, uri, disabled, authorClaimId } = action.data; const {
comments,
parentId,
totalItems,
totalFilteredItems,
totalPages,
claimId,
uri,
disabled,
authorClaimId,
} = action.data;
const commentsDisabledChannelIds = [...state.commentsDisabledChannelIds]; const commentsDisabledChannelIds = [...state.commentsDisabledChannelIds];
if (disabled) { if (disabled) {
@ -185,10 +247,16 @@ export default handleActions(
commentsDisabledChannelIds.push(authorClaimId); commentsDisabledChannelIds.push(authorClaimId);
} }
const isLoadingByParentId = Object.assign({}, state.isLoadingByParentId);
if (parentId) {
isLoadingByParentId[parentId] = false;
}
return { return {
...state, ...state,
commentsDisabledChannelIds, commentsDisabledChannelIds,
isLoading: false, isLoading: false,
isLoadingByParentId,
}; };
} else { } else {
const index = commentsDisabledChannelIds.indexOf(authorClaimId); const index = commentsDisabledChannelIds.indexOf(authorClaimId);
@ -200,49 +268,135 @@ export default handleActions(
const commentById = Object.assign({}, state.commentById); const commentById = Object.assign({}, state.commentById);
const byId = Object.assign({}, state.byId); const byId = Object.assign({}, state.byId);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]} const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]}
const topLevelTotalCommentsById = Object.assign({}, state.topLevelTotalCommentsById);
const topLevelTotalPagesById = Object.assign({}, state.topLevelTotalPagesById);
const commentsByUri = Object.assign({}, state.commentsByUri); const commentsByUri = Object.assign({}, state.commentsByUri);
const repliesByParentId = Object.assign({}, state.repliesByParentId);
const totalCommentsById = Object.assign({}, state.totalCommentsById);
const totalRepliesByParentId = Object.assign({}, state.totalRepliesByParentId);
const isLoadingByParentId = Object.assign({}, state.isLoadingByParentId);
const commonUpdateAction = (comment, commentById, commentIds, index) => {
// map the comment_ids to the new comments
commentById[comment.comment_id] = comment;
commentIds[index] = comment.comment_id;
};
const tempRepliesByParent = {};
const topLevelComments = [];
if (comments) { if (comments) {
// we use an Array to preserve order of listing // we use an Array to preserve order of listing
// in reality this doesn't matter and we can just // in reality this doesn't matter and we can just
// sort comments by their timestamp // sort comments by their timestamp
const commentIds = Array(comments.length); const commentIds = Array(comments.length);
// map the comment_ids to the new comments // totalCommentsById[claimId] = totalItems;
for (let i = 0; i < comments.length; i++) { // --> currently, this value is only correct when done via a top-level query.
const comment = comments[i]; // Until this is fixed, I'm moving it downwards to **
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; // --- Top-level comments ---
if (!parentId) {
totalCommentsById[claimId] = totalItems; // **
topLevelTotalCommentsById[claimId] = totalFilteredItems;
topLevelTotalPagesById[claimId] = totalPages;
if (!topLevelCommentsById[claimId]) {
topLevelCommentsById[claimId] = [];
}
const topLevelCommentIds = topLevelCommentsById[claimId];
for (let i = 0; i < comments.length; ++i) {
const comment = comments[i];
commonUpdateAction(comment, commentById, commentIds, i);
if (IS_DEV && comment['parent_id']) console.error('Invalid top-level comment:', comment); // eslint-disable-line
if (!topLevelCommentIds.includes(comment.comment_id)) {
topLevelCommentIds.push(comment.comment_id);
}
}
}
// --- Replies ---
else {
totalRepliesByParentId[parentId] = totalFilteredItems;
isLoadingByParentId[parentId] = false;
for (let i = 0; i < comments.length; ++i) {
const comment = comments[i];
commonUpdateAction(comment, commentById, commentIds, i);
if (IS_DEV && !comment['parent_id']) console.error('Missing parent_id:', comment); // eslint-disable-line
if (IS_DEV && comment.parent_id !== parentId) console.error('Black sheep in the family?:', comment); // eslint-disable-line
pushToArrayInObject(repliesByParentId, parentId, comment.comment_id);
}
}
byId[claimId] ? byId[claimId].push(...commentIds) : (byId[claimId] = commentIds);
commentsByUri[uri] = claimId; commentsByUri[uri] = claimId;
} }
const repliesByParentId = Object.assign({}, state.repliesByParentId, tempRepliesByParent); // {ParentCommentID -> [commentIds...] } list of reply comments
return { return {
...state, ...state,
topLevelCommentsById, topLevelCommentsById,
topLevelTotalCommentsById,
topLevelTotalPagesById,
repliesByParentId, repliesByParentId,
totalCommentsById,
totalRepliesByParentId,
byId, byId,
commentById, commentById,
commentsByUri, commentsByUri,
commentsDisabledChannelIds, commentsDisabledChannelIds,
isLoading: false, isLoading: false,
isLoadingByParentId,
};
},
[ACTIONS.COMMENT_BY_ID_COMPLETED]: (state: CommentsState, action: any) => {
const { comment, ancestors } = action.data;
const claimId = comment.claim_id;
const commentById = Object.assign({}, state.commentById);
const byId = Object.assign({}, state.byId);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]}
const topLevelTotalCommentsById = Object.assign({}, state.topLevelTotalCommentsById);
const topLevelTotalPagesById = Object.assign({}, state.topLevelTotalPagesById);
const repliesByParentId = Object.assign({}, state.repliesByParentId);
const linkedCommentAncestors = Object.assign({}, state.linkedCommentAncestors);
const updateStore = (comment, commentById, byId, repliesByParentId, topLevelCommentsById) => {
// 'comment.ByID' doesn't populate 'replies'. We should have at least 1
// at the moment, and the correct value will populated by 'comment.List'.
commentById[comment.comment_id] = { ...comment, replies: 1 };
byId[claimId] ? byId[claimId].unshift(comment.comment_id) : (byId[claimId] = [comment.comment_id]);
const parentId = comment.parent_id;
if (comment.parent_id) {
pushToArrayInObject(repliesByParentId, parentId, comment.comment_id);
} else {
pushToArrayInObject(topLevelCommentsById, claimId, comment.comment_id);
}
};
updateStore(comment, commentById, byId, repliesByParentId, topLevelCommentsById);
if (ancestors) {
ancestors.forEach((ancestor) => {
updateStore(ancestor, commentById, byId, repliesByParentId, topLevelCommentsById);
pushToArrayInObject(linkedCommentAncestors, comment.comment_id, ancestor.comment_id);
});
}
return {
...state,
topLevelCommentsById,
topLevelTotalCommentsById,
topLevelTotalPagesById,
repliesByParentId,
byId,
commentById,
linkedCommentAncestors,
}; };
}, },
@ -273,6 +427,51 @@ export default handleActions(
isLoading: false, isLoading: false,
}), }),
[ACTIONS.COMMENT_LIST_RESET]: (state: CommentsState, action: any) => {
const { claimId } = action.data;
const byId = Object.assign({}, state.byId);
const totalCommentsById = Object.assign({}, state.totalCommentsById);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]}
const topLevelTotalCommentsById = Object.assign({}, state.topLevelTotalCommentsById);
const topLevelTotalPagesById = Object.assign({}, state.topLevelTotalPagesById);
const myReacts = Object.assign({}, state.myReactsByCommentId);
const othersReacts = Object.assign({}, state.othersReactsByCommentId);
function deleteReacts(reactObj, commentIdsToRemove) {
if (commentIdsToRemove && commentIdsToRemove.length > 0) {
let reactionKeys = Object.keys(reactObj);
reactionKeys.forEach((rk) => {
const colonIndex = rk.indexOf(':');
const commentId = colonIndex === -1 ? rk : rk.substring(0, colonIndex);
if (commentIdsToRemove.includes(commentId)) {
delete reactObj[rk];
}
});
}
}
deleteReacts(myReacts, byId[claimId]);
deleteReacts(othersReacts, byId[claimId]);
delete byId[claimId];
delete totalCommentsById[claimId];
delete topLevelCommentsById[claimId];
delete topLevelTotalCommentsById[claimId];
delete topLevelTotalPagesById[claimId];
return {
...state,
byId,
totalCommentsById,
topLevelCommentsById,
topLevelTotalCommentsById,
topLevelTotalPagesById,
myReactsByCommentId: myReacts,
othersReactsByCommentId: othersReacts,
};
},
[ACTIONS.COMMENT_RECEIVED]: (state: CommentsState, action: any) => { [ACTIONS.COMMENT_RECEIVED]: (state: CommentsState, action: any) => {
const { uri, claimId, comment } = action.data; const { uri, claimId, comment } = action.data;
const commentsByUri = Object.assign({}, state.commentsByUri); const commentsByUri = Object.assign({}, state.commentsByUri);
@ -352,21 +551,50 @@ export default handleActions(
const { comment_id } = action.data; const { comment_id } = action.data;
const commentById = Object.assign({}, state.commentById); const commentById = Object.assign({}, state.commentById);
const byId = Object.assign({}, state.byId); const byId = Object.assign({}, state.byId);
const repliesByParentId = Object.assign({}, state.repliesByParentId); // {ParentCommentID -> [commentIds...] } list of reply comments
const totalRepliesByParentId = Object.assign({}, state.totalRepliesByParentId);
const totalCommentsById = Object.assign({}, state.totalCommentsById);
const comment = commentById[comment_id];
// to remove the comment and its references // to remove the comment and its references
const claimId = commentById[comment_id].claim_id; const claimId = comment.claim_id;
for (let i = 0; i < byId[claimId].length; i++) { for (let i = 0; i < byId[claimId].length; i++) {
if (byId[claimId][i] === comment_id) { if (byId[claimId][i] === comment_id) {
byId[claimId].splice(i, 1); byId[claimId].splice(i, 1);
break; break;
} }
} }
// Update replies
if (comment['parent_id'] && repliesByParentId[comment.parent_id]) {
const index = repliesByParentId[comment.parent_id].indexOf(comment.comment_id);
if (index > -1) {
repliesByParentId[comment.parent_id].splice(index, 1);
if (commentById[comment.parent_id]) {
commentById[comment.parent_id].replies = Math.max(0, (commentById[comment.parent_id].replies || 0) - 1);
}
if (totalRepliesByParentId[comment.parent_id]) {
totalRepliesByParentId[comment.parent_id] = Math.max(0, totalRepliesByParentId[comment.parent_id] - 1);
}
}
}
if (totalCommentsById[claimId]) {
totalCommentsById[claimId] = Math.max(0, totalCommentsById[claimId] - 1);
}
delete commentById[comment_id]; delete commentById[comment_id];
return { return {
...state, ...state,
commentById, commentById,
byId, byId,
totalCommentsById,
repliesByParentId,
totalRepliesByParentId,
isLoading: false, isLoading: false,
}; };
}, },
@ -390,11 +618,41 @@ export default handleActions(
isCommenting: false, isCommenting: false,
}; };
}, },
[ACTIONS.COMMENT_UPDATE_FAILED]: (state: CommentsState, action: any) => ({ [ACTIONS.COMMENT_UPDATE_FAILED]: (state: CommentsState, action: any) => ({
...state, ...state,
isCmmenting: false, isCmmenting: false,
}), }),
[ACTIONS.COMMENT_PIN_COMPLETED]: (state: CommentsState, action: any) => {
const { pinnedComment, claimId, unpin } = action.data;
const commentById = Object.assign({}, state.commentById);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById);
if (pinnedComment && topLevelCommentsById[claimId]) {
const index = topLevelCommentsById[claimId].indexOf(pinnedComment.comment_id);
if (index > -1) {
topLevelCommentsById[claimId].splice(index, 1);
if (unpin) {
// Without the sort score, I have no idea where to put it. Just
// dump it at the bottom. Users can refresh if they want it back to
// the correct sorted position.
topLevelCommentsById[claimId].push(pinnedComment.comment_id);
} else {
topLevelCommentsById[claimId].unshift(pinnedComment.comment_id);
}
commentById[pinnedComment.comment_id] = pinnedComment;
}
}
return {
...state,
commentById,
topLevelCommentsById,
};
},
[ACTIONS.COMMENT_MODERATION_BLOCK_LIST_STARTED]: (state: CommentsState, action: any) => ({ [ACTIONS.COMMENT_MODERATION_BLOCK_LIST_STARTED]: (state: CommentsState, action: any) => ({
...state, ...state,
fetchingModerationBlockList: true, fetchingModerationBlockList: true,

View file

@ -9,6 +9,7 @@ const selectState = (state) => state.comments || {};
export const selectCommentsById = createSelector(selectState, (state) => state.commentById || {}); export const selectCommentsById = createSelector(selectState, (state) => state.commentById || {});
export const selectIsFetchingComments = createSelector(selectState, (state) => state.isLoading); export const selectIsFetchingComments = createSelector(selectState, (state) => state.isLoading);
export const selectIsFetchingCommentsByParentId = createSelector(selectState, (state) => state.isLoadingByParentId);
export const selectIsPostingComment = createSelector(selectState, (state) => state.isCommenting); export const selectIsPostingComment = createSelector(selectState, (state) => state.isCommenting);
export const selectIsFetchingReacts = createSelector(selectState, (state) => state.isFetchingReacts); export const selectIsFetchingReacts = createSelector(selectState, (state) => state.isFetchingReacts);
export const selectCommentsDisabledChannelIds = createSelector( export const selectCommentsDisabledChannelIds = createSelector(
@ -143,19 +144,28 @@ export const selectCommentsByUri = createSelector(selectState, (state) => {
return comments; return comments;
}); });
export const selectLinkedCommentAncestors = createSelector(selectState, (state) => state.linkedCommentAncestors);
export const makeSelectCommentIdsForUri = (uri: string) => export const makeSelectCommentIdsForUri = (uri: string) =>
createSelector(selectState, selectCommentsByUri, selectClaimsById, (state, byUri) => { createSelector(selectState, selectCommentsByUri, selectClaimsById, (state, byUri) => {
const claimId = byUri[uri]; const claimId = byUri[uri];
return state.byId[claimId]; return state.byId[claimId];
}); });
export const makeSelectMyReactionsForComment = (commentId: string) => export const selectMyReactionsByCommentId = createSelector(selectState, (state) => state.myReactsByCommentId);
/**
* makeSelectMyReactionsForComment
*
* @param commentIdChannelId Format = "commentId:MyChannelId".
*/
export const makeSelectMyReactionsForComment = (commentIdChannelId: string) =>
createSelector(selectState, (state) => { createSelector(selectState, (state) => {
if (!state.myReactsByCommentId) { if (!state.myReactsByCommentId) {
return []; return [];
} }
return state.myReactsByCommentId[commentId] || []; return state.myReactsByCommentId[commentIdChannelId] || [];
}); });
export const makeSelectOthersReactionsForComment = (commentId: string) => export const makeSelectOthersReactionsForComment = (commentId: string) =>
@ -276,6 +286,18 @@ export const makeSelectTopLevelCommentsForUri = (uri: string) =>
} }
); );
export const makeSelectTopLevelTotalCommentsForUri = (uri: string) =>
createSelector(selectState, selectCommentsByUri, (state, byUri) => {
const claimId = byUri[uri];
return state.topLevelTotalCommentsById[claimId] || 0;
});
export const makeSelectTopLevelTotalPagesForUri = (uri: string) =>
createSelector(selectState, selectCommentsByUri, (state, byUri) => {
const claimId = byUri[uri];
return state.topLevelTotalPagesById[claimId] || 0;
});
export const makeSelectRepliesForParentId = (id: string) => export const makeSelectRepliesForParentId = (id: string) =>
createSelector( createSelector(
selectState, // no selectRepliesByParentId selectState, // no selectRepliesByParentId
@ -334,9 +356,15 @@ export const makeSelectRepliesForParentId = (id: string) =>
} }
); );
export const makeSelectTotalRepliesForParentId = (parentId: string) =>
createSelector(selectState, (state) => {
return state.totalRepliesByParentId[parentId] || 0;
});
export const makeSelectTotalCommentsCountForUri = (uri: string) => export const makeSelectTotalCommentsCountForUri = (uri: string) =>
createSelector(makeSelectCommentsForUri(uri), (comments) => { createSelector(selectState, selectCommentsByUri, (state, byUri) => {
return comments ? comments.length : 0; const claimId = byUri[uri];
return state.totalCommentsById[claimId] || 0;
}); });
// Personal list // Personal list

View file

@ -441,3 +441,7 @@ $thumbnailWidthSmall: 1rem;
.comment__tip-input { .comment__tip-input {
margin: var(--spacing-s) 0; margin: var(--spacing-s) 0;
} }
.comment--blocked {
opacity: 0.5;
}

View file

@ -10,11 +10,10 @@ type SortProps = {
reactionsById: {}, reactionsById: {},
sort: string, sort: string,
isMyComment: (string) => boolean, isMyComment: (string) => boolean,
justCommented: Array<string>,
}; };
export function sortComments(sortProps: SortProps): Array<Comment> { export function sortComments(sortProps: SortProps): Array<Comment> {
const { comments, reactionsById, sort, isMyComment, justCommented } = sortProps; const { comments, reactionsById, sort, isMyComment } = sortProps;
if (!comments) return []; if (!comments) return [];
@ -29,12 +28,10 @@ export function sortComments(sortProps: SortProps): Array<Comment> {
const aIsMine = isMyComment(a.channel_id); const aIsMine = isMyComment(a.channel_id);
const bIsMine = isMyComment(b.channel_id); const bIsMine = isMyComment(b.channel_id);
const aIsMyRecent = justCommented.includes(a.comment_id);
const bIsMyRecent = justCommented.includes(b.comment_id);
if (aIsMine && justCommented.length && aIsMyRecent) { if (aIsMine) {
return -1; return -1;
} else if (bIsMine && justCommented.length && bIsMyRecent) { } else if (bIsMine) {
return 1; return 1;
} }