de6c6f9bfd
* Add option to pass in url-search params. Impetus: allow linked comment ID and setting the discussion tab when clicking on the `ClaimPreview`. * comment.list: fix typos and renamed variables - Switch from 'author' to 'creator' to disambiguate between comment author and content author. For comment author, we'll use 'commenter' from now on. - Corrected 'commenterClaimId' to 'creatorClaimId' (just a typo, no functional change). * doCommentReset: change param from uri to claimId This reduces one lookup as clients will always have the claimID ready, but might not have the full URI. It was using URI previously just to match the other APIs. * Add doCommentListOwn -- command to fetch own comments Since the redux slice is set up based on content or channel ID (for Channel Discussion page), re-use the channel ID for the case of "own comments". We always clear each ID when fetching page-0, so no worries of conflict when actually browsing the Channel Discussion page. * Comment: add option to hide the actions section * Implement own-comments page * Use new param to remove sort-pins-first. comment.List currently always pushes pins to the top to support pagination. This new param removes this behavior.
396 lines
13 KiB
JavaScript
396 lines
13 KiB
JavaScript
// @flow
|
|
import * as REACTION_TYPES from 'constants/reactions';
|
|
import * as ICONS from 'constants/icons';
|
|
import { COMMENT_HIGHLIGHTED } from 'constants/classnames';
|
|
import { COMMENT_PAGE_SIZE_TOP_LEVEL, SORT_BY } from 'constants/comment';
|
|
import React, { useEffect } from 'react';
|
|
import classnames from 'classnames';
|
|
import CommentView from 'component/comment';
|
|
import Spinner from 'component/spinner';
|
|
import Button from 'component/button';
|
|
import Card from 'component/common/card';
|
|
import CommentCreate from 'component/commentCreate';
|
|
import usePersistedState from 'effects/use-persisted-state';
|
|
import { ENABLE_COMMENT_REACTIONS } from 'config';
|
|
import Empty from 'component/common/empty';
|
|
import debounce from 'util/debounce';
|
|
import useFetched from 'effects/use-fetched';
|
|
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
|
|
import { getChannelIdFromClaim } from 'util/claim';
|
|
|
|
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 = {
|
|
allCommentIds: any,
|
|
pinnedComments: Array<Comment>,
|
|
topLevelComments: Array<Comment>,
|
|
topLevelTotalPages: number,
|
|
fetchTopLevelComments: (string, number, number, number) => void,
|
|
fetchComment: (string) => void,
|
|
fetchReacts: (Array<string>) => Promise<any>,
|
|
resetComments: (string) => void,
|
|
uri: string,
|
|
claim: ?Claim,
|
|
claimIsMine: boolean,
|
|
myChannels: ?Array<ChannelClaim>,
|
|
isFetchingComments: boolean,
|
|
isFetchingCommentsById: boolean,
|
|
isFetchingReacts: boolean,
|
|
linkedCommentId?: string,
|
|
totalComments: number,
|
|
fetchingChannels: boolean,
|
|
myReactsByCommentId: ?{ [string]: Array<string> }, // "CommentId:MyChannelId" -> reaction array (note the ID concatenation)
|
|
othersReactsById: ?{ [string]: { [REACTION_TYPES.LIKE | REACTION_TYPES.DISLIKE]: number } },
|
|
activeChannelId: ?string,
|
|
settingsByChannelId: { [channelId: string]: PerChannelSettings },
|
|
};
|
|
|
|
function CommentList(props: Props) {
|
|
const {
|
|
allCommentIds,
|
|
fetchTopLevelComments,
|
|
fetchComment,
|
|
fetchReacts,
|
|
resetComments,
|
|
uri,
|
|
pinnedComments,
|
|
topLevelComments,
|
|
topLevelTotalPages,
|
|
claim,
|
|
claimIsMine,
|
|
myChannels,
|
|
isFetchingComments,
|
|
isFetchingCommentsById,
|
|
isFetchingReacts,
|
|
linkedCommentId,
|
|
totalComments,
|
|
fetchingChannels,
|
|
myReactsByCommentId,
|
|
othersReactsById,
|
|
activeChannelId,
|
|
settingsByChannelId,
|
|
} = props;
|
|
|
|
const spinnerRef = React.useRef();
|
|
const DEFAULT_SORT = ENABLE_COMMENT_REACTIONS ? SORT_BY.POPULARITY : SORT_BY.NEWEST;
|
|
const [sort, setSort] = usePersistedState('comment-sort-by', DEFAULT_SORT);
|
|
const [page, setPage] = React.useState(0);
|
|
const isMobile = useIsMobile();
|
|
const isMediumScreen = useIsMediumScreen();
|
|
const [expandedComments, setExpandedComments] = React.useState(!isMobile && !isMediumScreen);
|
|
const totalFetchedComments = allCommentIds ? allCommentIds.length : 0;
|
|
const channelId = getChannelIdFromClaim(claim);
|
|
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
|
|
const fetchedCommentsOnce = useFetched(isFetchingComments);
|
|
const fetchedReactsOnce = useFetched(isFetchingReacts);
|
|
const fetchedLinkedComment = useFetched(isFetchingCommentsById);
|
|
|
|
// Display comments immediately if not fetching reactions
|
|
// If not, wait to show comments until reactions are fetched
|
|
const [readyToDisplayComments, setReadyToDisplayComments] = React.useState(
|
|
Boolean(othersReactsById) || !ENABLE_COMMENT_REACTIONS
|
|
);
|
|
|
|
const hasNoComments = !totalComments;
|
|
const moreBelow = page < topLevelTotalPages;
|
|
|
|
const isMyComment = (channelId: string): boolean => {
|
|
if (myChannels != null && channelId != null) {
|
|
for (let i = 0; i < myChannels.length; i++) {
|
|
if (myChannels[i].claim_id === channelId) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
function changeSort(newSort) {
|
|
if (sort !== newSort) {
|
|
setSort(newSort);
|
|
setPage(0); // Invalidate existing comments
|
|
}
|
|
}
|
|
|
|
function getCommentElems(comments) {
|
|
return comments.map((comment) => {
|
|
return (
|
|
<CommentView
|
|
isTopLevel
|
|
threadDepth={3}
|
|
key={comment.comment_id}
|
|
uri={uri}
|
|
authorUri={comment.channel_url}
|
|
author={comment.channel_name}
|
|
claimId={comment.claim_id}
|
|
commentId={comment.comment_id}
|
|
message={comment.comment}
|
|
timePosted={comment.timestamp * 1000}
|
|
claimIsMine={claimIsMine}
|
|
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
|
|
linkedCommentId={linkedCommentId}
|
|
isPinned={comment.is_pinned}
|
|
supportAmount={comment.support_amount}
|
|
numDirectReplies={comment.replies}
|
|
isModerator={comment.is_moderator}
|
|
isGlobalMod={comment.is_global_mod}
|
|
isFiat={comment.is_fiat}
|
|
/>
|
|
);
|
|
});
|
|
}
|
|
|
|
// Reset comments
|
|
useEffect(() => {
|
|
if (page === 0) {
|
|
if (claim) {
|
|
resetComments(claim.claim_id);
|
|
}
|
|
setPage(1);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [page, uri, resetComments]); // 'claim' is derived from 'uri'
|
|
|
|
// 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 && !isFetchingReacts) {
|
|
let idsForReactionFetch;
|
|
|
|
if (!othersReactsById || !myReactsByCommentId) {
|
|
idsForReactionFetch = allCommentIds;
|
|
} else {
|
|
idsForReactionFetch = allCommentIds.filter((commentId) => {
|
|
const key = activeChannelId ? `${commentId}:${activeChannelId}` : commentId;
|
|
return !othersReactsById[key] || (activeChannelId && !myReactsByCommentId[key]);
|
|
});
|
|
}
|
|
|
|
if (idsForReactionFetch.length !== 0) {
|
|
fetchReacts(idsForReactionFetch)
|
|
.then(() => {
|
|
setReadyToDisplayComments(true);
|
|
})
|
|
.catch(() => setReadyToDisplayComments(true));
|
|
}
|
|
}
|
|
}, [
|
|
totalFetchedComments,
|
|
allCommentIds,
|
|
othersReactsById,
|
|
myReactsByCommentId,
|
|
fetchReacts,
|
|
uri,
|
|
activeChannelId,
|
|
fetchingChannels,
|
|
isFetchingReacts,
|
|
]);
|
|
|
|
// Scroll to linked-comment
|
|
useEffect(() => {
|
|
if (fetchedLinkedComment && fetchedCommentsOnce && fetchedReactsOnce) {
|
|
const elems = document.getElementsByClassName(COMMENT_HIGHLIGHTED);
|
|
if (elems.length > 0) {
|
|
const ROUGH_HEADER_HEIGHT = 125; // @see: --header-height
|
|
const linkedComment = elems[0];
|
|
window.scrollTo({
|
|
top: linkedComment.getBoundingClientRect().top + window.scrollY - ROUGH_HEADER_HEIGHT,
|
|
left: 0,
|
|
behavior: 'smooth',
|
|
});
|
|
}
|
|
}
|
|
}, [fetchedLinkedComment, fetchedCommentsOnce, fetchedReactsOnce]);
|
|
|
|
// Infinite scroll
|
|
useEffect(() => {
|
|
function shouldFetchNextPage(page, topLevelTotalPages, window, document, yPrefetchPx = 1000) {
|
|
if (!spinnerRef || !spinnerRef.current) {
|
|
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 =
|
|
rect.width > 0 &&
|
|
rect.height > 0 &&
|
|
rect.bottom >= 0 &&
|
|
rect.right >= 0 &&
|
|
// $FlowFixMe
|
|
rect.top <= windowH &&
|
|
// $FlowFixMe
|
|
rect.left <= windowW;
|
|
|
|
return (isInViewport || isApproachingViewport) && page < topLevelTotalPages;
|
|
}
|
|
|
|
const handleCommentScroll = debounce(() => {
|
|
if (!isMobile && !isMediumScreen && shouldFetchNextPage(page, topLevelTotalPages, window, document)) {
|
|
setPage(page + 1);
|
|
}
|
|
}, DEBOUNCE_SCROLL_HANDLER_MS);
|
|
|
|
if (!isFetchingComments && readyToDisplayComments && moreBelow && spinnerRef && spinnerRef.current) {
|
|
if (shouldFetchNextPage(page, topLevelTotalPages, window, document, 0)) {
|
|
setPage(page + 1);
|
|
} else {
|
|
window.addEventListener('scroll', handleCommentScroll);
|
|
return () => window.removeEventListener('scroll', handleCommentScroll);
|
|
}
|
|
}
|
|
}, [
|
|
isMobile,
|
|
isMediumScreen,
|
|
page,
|
|
moreBelow,
|
|
spinnerRef,
|
|
isFetchingComments,
|
|
readyToDisplayComments,
|
|
topLevelComments.length,
|
|
topLevelTotalPages,
|
|
]);
|
|
|
|
// Expand comments
|
|
useEffect(() => {
|
|
if (!isMobile && !isMediumScreen && !expandedComments) {
|
|
setExpandedComments(true);
|
|
}
|
|
}, [isMobile, isMediumScreen, expandedComments]);
|
|
|
|
return (
|
|
<Card
|
|
className="card--enable-overflow"
|
|
title={
|
|
totalComments > 0
|
|
? totalComments === 1
|
|
? __('1 comment')
|
|
: __('%total_comments% comments', { total_comments: totalComments })
|
|
: __('Leave a comment')
|
|
}
|
|
titleActions={
|
|
<>
|
|
{totalComments > 1 && ENABLE_COMMENT_REACTIONS && (
|
|
<span className="comment__sort">
|
|
<Button
|
|
button="alt"
|
|
label={__('Best')}
|
|
icon={ICONS.BEST}
|
|
iconSize={18}
|
|
onClick={() => changeSort(SORT_BY.POPULARITY)}
|
|
className={classnames(`button-toggle`, {
|
|
'button-toggle--active': sort === SORT_BY.POPULARITY,
|
|
})}
|
|
/>
|
|
<Button
|
|
button="alt"
|
|
label={__('Controversial')}
|
|
icon={ICONS.CONTROVERSIAL}
|
|
iconSize={18}
|
|
onClick={() => changeSort(SORT_BY.CONTROVERSY)}
|
|
className={classnames(`button-toggle`, {
|
|
'button-toggle--active': sort === SORT_BY.CONTROVERSY,
|
|
})}
|
|
/>
|
|
<Button
|
|
button="alt"
|
|
label={__('New')}
|
|
icon={ICONS.NEW}
|
|
iconSize={18}
|
|
onClick={() => changeSort(SORT_BY.NEWEST)}
|
|
className={classnames(`button-toggle`, {
|
|
'button-toggle--active': sort === SORT_BY.NEWEST,
|
|
})}
|
|
/>
|
|
</span>
|
|
)}
|
|
<Button
|
|
button="alt"
|
|
icon={ICONS.REFRESH}
|
|
title={__('Refresh')}
|
|
onClick={() => {
|
|
setPage(0);
|
|
}}
|
|
/>
|
|
</>
|
|
}
|
|
actions={
|
|
<>
|
|
<CommentCreate uri={uri} />
|
|
|
|
{channelSettings && channelSettings.comments_enabled && !isFetchingComments && hasNoComments && (
|
|
<Empty padded text={__('That was pretty deep. What do you think?')} />
|
|
)}
|
|
|
|
<ul
|
|
className={classnames({
|
|
comments: expandedComments,
|
|
'comments--contracted': !expandedComments,
|
|
})}
|
|
>
|
|
{readyToDisplayComments && pinnedComments && getCommentElems(pinnedComments)}
|
|
{readyToDisplayComments && topLevelComments && getCommentElems(topLevelComments)}
|
|
</ul>
|
|
|
|
{(isMobile || isMediumScreen) && (
|
|
<div className="card__bottom-actions--comments">
|
|
{(!expandedComments || moreBelow) && (
|
|
<Button
|
|
button="link"
|
|
title={!expandedComments ? __('Expand Comments') : __('Load More')}
|
|
label={!expandedComments ? __('Expand') : __('More')}
|
|
onClick={() => {
|
|
if (!expandedComments) {
|
|
setExpandedComments(true);
|
|
} else {
|
|
setPage(page + 1);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
{expandedComments && (
|
|
<Button
|
|
button="link"
|
|
title={__('Collapse Thread')}
|
|
label={__('Collapse')}
|
|
onClick={() => {
|
|
setExpandedComments(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(isFetchingComments || (!isMobile && !isMediumScreen && moreBelow)) && (
|
|
<div className="main--empty" ref={spinnerRef}>
|
|
<Spinner type="small" />
|
|
</div>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default CommentList;
|