diff --git a/flow-typed/Comment.js b/flow-typed/Comment.js index adbecea26..15c6d3038 100644 --- a/flow-typed/Comment.js +++ b/flow-typed/Comment.js @@ -13,6 +13,7 @@ declare type Comment = { parent_id?: number, // comment_id of comment this is in reply to is_pinned: boolean, support_amount: number, + replies: number, // number of direct replies (i.e. excluding nested replies). }; declare type PerChannelSettings = { @@ -27,15 +28,21 @@ declare type PerChannelSettings = { declare type CommentsState = { commentsByUri: { [string]: string }, superChatsByUri: { [string]: { totalAmount: number, comments: Array } }, - byId: { [string]: Array }, - repliesByParentId: { [string]: Array }, // ParentCommentID -> list of reply comments - topLevelCommentsById: { [string]: Array }, // ClaimID -> list of top level comments + byId: { [string]: Array }, // ClaimID -> list of fetched comment IDs. + totalCommentsById: {}, // ClaimId -> ultimate total (including replies) in commentron. + repliesByParentId: { [string]: Array }, // ParentCommentID -> list of fetched replies. + totalRepliesByParentId: {}, // ParentCommentID -> total replies in commentron. + topLevelCommentsById: { [string]: Array }, // 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 }, + linkedCommentAncestors: { [string]: Array }, // {"linkedCommentId": ["parentId", "grandParentId", ...]} isLoading: boolean, + isLoadingByParentId: { [string]: boolean }, myComments: ?Set, isFetchingReacts: boolean, - myReactsByCommentId: any, - othersReactsByCommentId: any, + myReactsByCommentId: ?{ [string]: Array }, // {"CommentId:MyChannelId": ["like", "dislike", ...]} + othersReactsByCommentId: ?{ [string]: { [string]: number } }, // {"CommentId:MyChannelId": {"like": 2, "dislike": 2, ...}} pendingCommentReactions: Array, moderationBlockList: ?Array, // @KP rename to "personalBlockList"? adminBlockList: ?Array, @@ -64,17 +71,48 @@ declare type CommentReactParams = { remove?: boolean, }; +declare type CommentReactListParams = { + comment_ids?: string, + channel_id?: string, + channel_name?: string, + wallet_id?: string, + react_types?: string, +}; + declare type CommentListParams = { - page: number, - page_size: number, - claim_id: string, + page: number, // pagination: which page of results + page_size: number, // pagination: nr of comments to show in a page (max 200) + 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 = { items: Array, - 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, +} + declare type CommentAbandonParams = { comment_id: string, creator_channel_id?: string, @@ -94,6 +132,16 @@ declare type CommentCreateParams = { declare type SuperListParams = {}; +declare type SuperListResponse = { + page: number, + page_size: number, + total_pages: number, + total_items: number, + total_amount: number, + items: Array, + has_hidden_comments: boolean, +}; + declare type ModerationBlockParams = {}; declare type ModerationAddDelegateParams = { diff --git a/static/app-strings.json b/static/app-strings.json index 6f4749142..e3849098f 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1445,6 +1445,7 @@ "You loved this": "You 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", + "The requested comment is no longer available.": "The requested comment is no longer available.", "Best": "Best", "Controversial": "Controversial", "Show Replies": "Show Replies", @@ -1513,6 +1514,7 @@ "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!", "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 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.", diff --git a/ui/comments.js b/ui/comments.js index 912c7266e..885d54715 100644 --- a/ui/comments.js +++ b/ui/comments.js @@ -17,6 +17,7 @@ const Comments = { comment_list: (params: CommentListParams) => fetchCommentsApi('comment.List', params), comment_abandon: (params: CommentAbandonParams) => fetchCommentsApi('comment.Abandon', 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_block_word: (params: BlockWordParams) => fetchCommentsApi('setting.BlockWord', params), setting_unblock_word: (params: BlockWordParams) => fetchCommentsApi('setting.UnBlockWord', params), diff --git a/ui/component/channelDiscussion/index.js b/ui/component/channelDiscussion/index.js index cb0c52704..ff198701f 100644 --- a/ui/component/channelDiscussion/index.js +++ b/ui/component/channelDiscussion/index.js @@ -1,6 +1,5 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router'; -import { makeSelectCommentForCommentId } from 'redux/selectors/comments'; import { DISABLE_COMMENTS_TAG } from 'constants/tags'; import ChannelDiscussion from './view'; import { makeSelectTagInClaimOrChannelForUri } from 'lbry-redux'; @@ -8,10 +7,9 @@ import { makeSelectTagInClaimOrChannelForUri } from 'lbry-redux'; const select = (state, props) => { const { search } = props.location; const urlParams = new URLSearchParams(search); - const linkedCommentId = urlParams.get('lc'); return { - linkedComment: makeSelectCommentForCommentId(linkedCommentId)(state), + linkedCommentId: urlParams.get('lc'), commentsDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state), }; }; diff --git a/ui/component/channelDiscussion/view.jsx b/ui/component/channelDiscussion/view.jsx index 50a308a70..15b215f50 100644 --- a/ui/component/channelDiscussion/view.jsx +++ b/ui/component/channelDiscussion/view.jsx @@ -5,19 +5,19 @@ import Empty from 'component/common/empty'; type Props = { uri: string, - linkedComment: ?any, + linkedCommentId?: string, commentsDisabled: boolean, }; function ChannelDiscussion(props: Props) { - const { uri, linkedComment, commentsDisabled } = props; + const { uri, linkedCommentId, commentsDisabled } = props; if (commentsDisabled) { return ; } return (
- +
); } diff --git a/ui/component/comment/index.js b/ui/component/comment/index.js index 0e92f94f5..9e4ed5825 100644 --- a/ui/component/comment/index.js +++ b/ui/component/comment/index.js @@ -1,30 +1,43 @@ import { connect } from 'react-redux'; -import { makeSelectStakedLevelForChannelUri, makeSelectClaimForUri, makeSelectThumbnailForUri, selectMyChannelClaims } from 'lbry-redux'; -import { doCommentUpdate } from 'redux/actions/comments'; +import { + makeSelectStakedLevelForChannelUri, + makeSelectClaimForUri, + makeSelectThumbnailForUri, + selectMyChannelClaims, +} from 'lbry-redux'; +import { doCommentUpdate, doCommentList } from 'redux/actions/comments'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { doToast } from 'redux/actions/notifications'; import { doSetPlayingUri } from 'redux/actions/content'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; -import { makeSelectOthersReactionsForComment } from 'redux/selectors/comments'; -import { selectActiveChannelClaim } from 'redux/selectors/app'; +import { selectLinkedCommentAncestors, makeSelectOthersReactionsForComment } from 'redux/selectors/comments'; +import { selectActiveChannelId, selectActiveChannelClaim } from 'redux/selectors/app'; import { selectPlayingUri } from 'redux/selectors/content'; import Comment from './view'; -const select = (state, props) => ({ - claim: makeSelectClaimForUri(props.uri)(state), - thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state), - channelIsBlocked: props.authorUri && makeSelectChannelIsMuted(props.authorUri)(state), - commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, - othersReacts: makeSelectOthersReactionsForComment(props.commentId)(state), - activeChannelClaim: selectActiveChannelClaim(state), - myChannels: selectMyChannelClaims(state), - playingUri: selectPlayingUri(state), - stakedLevel: makeSelectStakedLevelForChannelUri(props.authorUri)(state), -}); +const select = (state, props) => { + const activeChannelId = selectActiveChannelId(state); + const reactionKey = activeChannelId ? `${props.commentId}:${activeChannelId}` : props.commentId; + + return { + claim: makeSelectClaimForUri(props.uri)(state), + thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state), + channelIsBlocked: props.authorUri && makeSelectChannelIsMuted(props.authorUri)(state), + commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, + othersReacts: makeSelectOthersReactionsForComment(reactionKey)(state), + activeChannelClaim: selectActiveChannelClaim(state), + myChannels: selectMyChannelClaims(state), + playingUri: selectPlayingUri(state), + stakedLevel: makeSelectStakedLevelForChannelUri(props.authorUri)(state), + linkedCommentAncestors: selectLinkedCommentAncestors(state), + }; +}; const perform = (dispatch) => ({ clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })), 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)), }); diff --git a/ui/component/comment/view.jsx b/ui/component/comment/view.jsx index 421e4f7b4..19afc945d 100644 --- a/ui/component/comment/view.jsx +++ b/ui/component/comment/view.jsx @@ -1,6 +1,7 @@ // @flow import * as ICONS from 'constants/icons'; 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 { SITE_NAME, SIMPLE_SITE, ENABLE_COMMENT_REACTIONS } from 'config'; import React, { useEffect, useState } from 'react'; @@ -23,6 +24,8 @@ import CommentMenuList from 'component/commentMenuList'; import UriIndicator from 'component/uriIndicator'; import CreditAmount from 'component/common/credit-amount'; +const AUTO_EXPAND_ALL_REPLIES = false; + type Props = { clearPlayingUri: () => void, uri: string, @@ -36,8 +39,10 @@ type Props = { claimIsMine: boolean, // if you control the claim which this comment was posted on commentIsMine: boolean, // if this comment was signed by an owned channel updateComment: (string, string) => void, + fetchReplies: (string, string, number, number, number) => void, commentModBlock: (string) => void, - linkedComment?: any, + linkedCommentId?: string, + linkedCommentAncestors: { [string]: Array }, myChannels: ?Array, commentingEnabled: boolean, doToast: ({ message: string }) => void, @@ -53,6 +58,7 @@ type Props = { playingUri: ?PlayingUri, stakedLevel: number, supportAmount: number, + numDirectReplies: number, }; const LENGTH_TO_COLLAPSE = 300; @@ -71,7 +77,9 @@ function Comment(props: Props) { commentIsMine, commentId, updateComment, - linkedComment, + fetchReplies, + linkedCommentId, + linkedCommentAncestors, commentingEnabled, myChannels, doToast, @@ -82,18 +90,23 @@ function Comment(props: Props) { playingUri, stakedLevel, supportAmount, + numDirectReplies, } = props; + const { push, replace, location: { pathname, search }, } = useHistory(); + const [isReplying, setReplying] = React.useState(false); const [isEditing, setEditing] = useState(false); const [editedMessage, setCommentValue] = useState(message); const [charCount, setCharCount] = useState(editedMessage.length); // used for controlling the visibility of the menu icon const [mouseIsHovering, setMouseHover] = useState(false); + const [showReplies, setShowReplies] = useState(false); + const [page, setPage] = useState(0); const [advancedEditor] = usePersistedState('comment-editor-mode', false); const [displayDeadComment, setDisplayDeadComment] = React.useState(false); const hasChannels = myChannels && myChannels.length > 0; @@ -111,6 +124,19 @@ function Comment(props: Props) { } } 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(() => { if (isEditing) { setCharCount(editedMessage.length); @@ -131,6 +157,12 @@ function Comment(props: Props) { } }, [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) { setCommentValue(!SIMPLE_SITE && advancedEditor ? event : event.target.value); } @@ -176,7 +208,7 @@ function Comment(props: Props) { >
@@ -302,13 +334,43 @@ function Comment(props: Props) { {ENABLE_COMMENT_REACTIONS && }
+ {numDirectReplies > 0 && !showReplies && ( +
+
+ )} + + {numDirectReplies > 0 && showReplies && ( +
+
+ )} + {isReplying && ( setReplying(false)} - onCancelReplying={() => setReplying(false)} + onDoneReplying={() => { + setShowReplies(true); + setReplying(false); + }} + onCancelReplying={() => { + setReplying(false); + }} /> )} @@ -317,7 +379,16 @@ function Comment(props: Props) { - + {showReplies && ( + setPage(page + 1)} + /> + )} ); } diff --git a/ui/component/commentCreate/view.jsx b/ui/component/commentCreate/view.jsx index e683d9b20..4c32eca47 100644 --- a/ui/component/commentCreate/view.jsx +++ b/ui/component/commentCreate/view.jsx @@ -35,7 +35,6 @@ type Props = { toast: (string) => void, claimIsMine: boolean, sendTip: ({}, (any) => void, (any) => void) => void, - justCommented: Array, }; export function CommentCreate(props: Props) { @@ -54,7 +53,6 @@ export function CommentCreate(props: Props) { livestream, claimIsMine, sendTip, - justCommented, } = props; const buttonref: ElementRef = React.useRef(); const { @@ -153,7 +151,6 @@ export function CommentCreate(props: Props) { setIsReviewingSupportComment(false); setIsSupportComment(false); setCommentFailure(false); - justCommented.push(res.comment_id); if (onDoneReplying) { onDoneReplying(); @@ -217,7 +214,13 @@ export function CommentCreate(props: Props) { autoFocus button="primary" disabled={disabled} - label={isSubmitting ? __('Sending...') : (commentFailure && tipAmount === successTip.tipAmount) ? __('Re-submit') : __('Send')} + label={ + isSubmitting + ? __('Sending...') + : commentFailure && tipAmount === successTip.tipAmount + ? __('Re-submit') + : __('Send') + } onClick={handleSupportComment} />