lbry-desktop/ui/component/commentsList/view.jsx
infinite-persistence 4688b4bf58
Livestream: stop pinned comments from appearing as latest (#6888)
## Ticket
6879: Previously pinned livestream comments show as latest

## Issue
`comment.List` will always display the pinned comment first, hence the problem when the chat is refreshed.

## Approach
Completely split pinned comments from top-level comments in the Reducer, and the let the GUI (e.g. regular comments, livestream comments) decide how they want to display it.

For the case of livestream, there is no need to repeat the pinned comments in the regular chat area, since there is a dedicated area on top.
2021-08-17 12:09:55 -04:00

370 lines
12 KiB
JavaScript

// @flow
import * as REACTION_TYPES from 'constants/reactions';
import * as ICONS from 'constants/icons';
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 { useIsMobile } 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,
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,
isFetchingReacts,
linkedCommentId,
totalComments,
fetchingChannels,
myReactsByCommentId,
othersReactsById,
activeChannelId,
settingsByChannelId,
} = props;
const commentRef = React.useRef();
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 [expandedComments, setExpandedComments] = React.useState(!isMobile);
const totalFetchedComments = allCommentIds ? allCommentIds.length : 0;
const channelId = getChannelIdFromClaim(claim);
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
// 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) {
resetComments(uri);
setPage(1);
}
}, [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 && !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 (readyToDisplayComments && linkedCommentId && commentRef && commentRef.current) {
commentRef.current.scrollIntoView({ block: 'start' });
window.scrollBy(0, -125);
}
}, [readyToDisplayComments, linkedCommentId]);
// 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 && 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,
page,
moreBelow,
spinnerRef,
isFetchingComments,
readyToDisplayComments,
topLevelComments.length,
topLevelTotalPages,
]);
return (
<Card
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,
})}
ref={commentRef}
>
{readyToDisplayComments && pinnedComments && getCommentElems(pinnedComments)}
{readyToDisplayComments && topLevelComments && getCommentElems(topLevelComments)}
</ul>
{isMobile && (
<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 && moreBelow)) && (
<div className="main--empty" ref={spinnerRef}>
<Spinner type="small" />
</div>
)}
</>
}
/>
);
}
export default CommentList;