Re-design comment threads (#1489)

* Redesign threadline and fetching state

- threadline goes right below channel avatar, mimicking reddits implementation, has a increase effect on hover and is slimmer, creating more space for comments on screen
- fetching state now replaces show/hide button, also mimicking reddit, and now says that it is loading, instead of a blank spinner, and also improves space a bit

* Redesign comment threads

- Allow for infinite comment chains
- Can go back and forth between the pages
- Can go back to all comments or to the first comment in the chain
- Some other improvements, which include:
- add title on non-drawer comment sections (couldn't see amount of comments)
- fix Expandable component (would begin expanded and collapse after the effect runs, which looked bad and shifted the layout, now each comments greater than the set length begins collapsed)
- used constants for consistency

* Fix replying to last thread comment

* Fix buttons condition (only on fetched comment to avoid deleted case)

* Fix auto-scroll

* Bring back instant feedback for Show More replies

* Improve thread back links

- Now going back to all comments links the top-level comment for easier navigation
- Going back to ~ previous ~ now goes back into the chain instead of topmost level

* Clear timeouts due to unrelated issue

* Fix deep thread linked comment case and more scroll improvements

* More minor changes

* Flow

* Fix commentList tile style

* Fix long channel names overflowing on small screens

* More scroll changes

* Fix threadline

* Revert "Fix long channel names overflowing on small screens"

This reverts commit e4d2dc7da5861ed8136a60f3352e41a690cd4d33.

* Fix replies fetch

* Revert "Fix replies fetch"

This reverts commit ec70054675a604a7a5f3764ba07c36bf7b0f49c8.

* Cleanup and make smooth

* Always use linked comment on threads

* Cleanup

* Higlight thread comment

* Fix comment body styles
This commit is contained in:
saltrafael 2022-05-16 07:22:13 -03:00 committed by GitHub
parent f6f15531d4
commit b75a4014b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 507 additions and 260 deletions

View file

@ -52,7 +52,7 @@ declare type CommentsState = {
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 }, // commentId -> Comment
linkedCommentAncestors: { [string]: Array<string> }, // {"linkedCommentId": ["parentId", "grandParentId", ...]}
fetchedCommentAncestors: { [string]: Array<string> }, // {"fetchedCommentId": ["parentId", "grandParentId", ...]}
pinnedCommentsById: {}, // ClaimId -> array of pinned comment IDs
isLoading: boolean,
isLoadingById: boolean,

View file

@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
import { LINKED_COMMENT_QUERY_PARAM, THREAD_COMMENT_QUERY_PARAM } from 'constants/comment';
import ChannelDiscussion from './view';
import { makeSelectTagInClaimOrChannelForUri, selectClaimForUri } from 'redux/selectors/claims';
import { selectSettingsByChannelId } from 'redux/selectors/comments';
@ -16,7 +17,8 @@ const select = (state, props) => {
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
return {
linkedCommentId: urlParams.get('lc'),
linkedCommentId: urlParams.get(LINKED_COMMENT_QUERY_PARAM),
threadCommentId: urlParams.get(THREAD_COMMENT_QUERY_PARAM),
commentsDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
commentSettingDisabled: channelSettings && !channelSettings.comments_enabled,
};

View file

@ -8,12 +8,13 @@ const CommentsList = lazyImport(() => import('component/commentsList' /* webpack
type Props = {
uri: string,
linkedCommentId?: string,
threadCommentId?: string,
commentsDisabled: boolean,
commentSettingDisabled?: boolean,
};
function ChannelDiscussion(props: Props) {
const { uri, linkedCommentId, commentsDisabled, commentSettingDisabled } = props;
const { uri, linkedCommentId, threadCommentId, commentsDisabled, commentSettingDisabled } = props;
if (commentsDisabled) {
return <Empty text={__('The creator of this content has disabled comments.')} />;
@ -26,7 +27,13 @@ function ChannelDiscussion(props: Props) {
return (
<section className="section">
<React.Suspense fallback={null}>
<CommentsList uri={uri} linkedCommentId={linkedCommentId} commentsAreExpanded />
<CommentsList
uri={uri}
linkedCommentId={linkedCommentId}
threadCommentId={threadCommentId}
commentsAreExpanded
notInDrawer
/>
</React.Suspense>
</section>
);

View file

@ -12,15 +12,15 @@ import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doToast } from 'redux/actions/notifications';
import { doClearPlayingUri } from 'redux/actions/content';
import {
selectLinkedCommentAncestors,
selectFetchedCommentAncestors,
selectOthersReactsForComment,
makeSelectTotalReplyPagesForParentId,
selectIsFetchingCommentsForParentId,
selectRepliesForParentId,
} from 'redux/selectors/comments';
import { selectActiveChannelClaim } from 'redux/selectors/app';
import { selectPlayingUri } from 'redux/selectors/content';
import {
selectUserVerifiedEmail,
} from 'redux/selectors/user';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import Comment from './view';
const select = (state, props) => {
@ -42,9 +42,11 @@ const select = (state, props) => {
hasChannels: selectHasChannels(state),
playingUri: selectPlayingUri(state),
stakedLevel: selectStakedLevelForChannelUri(state, channel_url),
linkedCommentAncestors: selectLinkedCommentAncestors(state),
linkedCommentAncestors: selectFetchedCommentAncestors(state),
totalReplyPages: makeSelectTotalReplyPagesForParentId(comment_id)(state),
selectOdyseeMembershipForUri: channel_url && selectOdyseeMembershipForUri(state, channel_url),
repliesFetching: selectIsFetchingCommentsForParentId(state, comment_id),
fetchedReplies: selectRepliesForParentId(state, comment_id),
};
};

View file

@ -3,7 +3,12 @@ import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import * as KEYCODES from 'constants/keycodes';
import { COMMENT_HIGHLIGHTED } from 'constants/classnames';
import { SORT_BY, COMMENT_PAGE_SIZE_REPLIES } from 'constants/comment';
import {
SORT_BY,
COMMENT_PAGE_SIZE_REPLIES,
LINKED_COMMENT_QUERY_PARAM,
THREAD_COMMENT_QUERY_PARAM,
} 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';
@ -31,6 +36,7 @@ import { getChannelFromClaim } from 'util/claim';
import { parseSticker } from 'util/comments';
import { useIsMobile } from 'effects/use-screensize';
import PremiumBadge from 'component/common/premium-badge';
import Spinner from 'component/spinner';
const AUTO_EXPAND_ALL_REPLIES = false;
@ -47,12 +53,12 @@ type Props = {
totalReplyPages: number,
commentModBlock: (string) => void,
linkedCommentId?: string,
threadCommentId?: string,
linkedCommentAncestors: { [string]: Array<string> },
hasChannels: boolean,
commentingEnabled: boolean,
doToast: ({ message: string }) => void,
isTopLevel?: boolean,
threadDepth: number,
hideActions?: boolean,
othersReacts: ?{
like: number,
@ -66,6 +72,10 @@ type Props = {
setQuickReply: (any) => void,
quickReply: any,
selectOdyseeMembershipForUri: string,
fetchedReplies: Array<Comment>,
repliesFetching: boolean,
threadLevel?: number,
threadDepthLevel?: number,
};
const LENGTH_TO_COLLAPSE = 300;
@ -82,12 +92,12 @@ function CommentView(props: Props) {
fetchReplies,
totalReplyPages,
linkedCommentId,
threadCommentId,
linkedCommentAncestors,
commentingEnabled,
hasChannels,
doToast,
isTopLevel,
threadDepth,
hideActions,
othersReacts,
playingUri,
@ -96,6 +106,10 @@ function CommentView(props: Props) {
setQuickReply,
quickReply,
selectOdyseeMembershipForUri,
fetchedReplies,
repliesFetching,
threadLevel = 0,
threadDepthLevel = 0,
} = props;
const {
@ -117,6 +131,11 @@ function CommentView(props: Props) {
const commentIsMine = channelId && myChannelIds && myChannelIds.includes(channelId);
const isMobile = useIsMobile();
const ROUGH_HEADER_HEIGHT = isMobile ? 56 : 60; // @see: --header-height
const lastThreadLevel = threadDepthLevel - 1;
// Mobile: 0, 1, 2 -> new thread....., so each 3 comments
const openNewThread = threadLevel > 0 && threadLevel % lastThreadLevel === 0;
const {
push,
@ -124,12 +143,14 @@ function CommentView(props: Props) {
location: { pathname, search },
} = useHistory();
const urlParams = new URLSearchParams(search);
const isLinkedComment = linkedCommentId && linkedCommentId === commentId;
const isThreadComment = threadCommentId && threadCommentId === commentId;
const isInLinkedCommentChain =
linkedCommentId &&
linkedCommentAncestors[linkedCommentId] &&
linkedCommentAncestors[linkedCommentId].includes(commentId);
const showRepliesOnMount = isInLinkedCommentChain || AUTO_EXPAND_ALL_REPLIES;
const showRepliesOnMount = isThreadComment || isInLinkedCommentChain || AUTO_EXPAND_ALL_REPLIES;
const [isReplying, setReplying] = React.useState(false);
const [isEditing, setEditing] = useState(false);
@ -146,6 +167,7 @@ function CommentView(props: Props) {
const contentChannelClaim = getChannelFromClaim(claim);
const commentByOwnerOfContent = contentChannelClaim && contentChannelClaim.permanent_url === authorUri;
const stickerFromMessage = parseSticker(message);
const isExpandable = editedMessage.length >= LENGTH_TO_COLLAPSE;
let channelOwnerOfContent;
try {
@ -208,37 +230,36 @@ function CommentView(props: Props) {
}
function handleTimeClick() {
const urlParams = new URLSearchParams(search);
urlParams.delete('lc');
urlParams.append('lc', commentId);
urlParams.set(LINKED_COMMENT_QUERY_PARAM, commentId);
replace(`${pathname}?${urlParams.toString()}`);
}
function handleOpenNewThread() {
urlParams.set(LINKED_COMMENT_QUERY_PARAM, commentId);
urlParams.set(THREAD_COMMENT_QUERY_PARAM, commentId);
push({ pathname, search: urlParams.toString() });
}
const linkedCommentRef = React.useCallback(
(node) => {
if (node !== null && window.pendingLinkedCommentScroll) {
const ROUGH_HEADER_HEIGHT = 125; // @see: --header-height
delete window.pendingLinkedCommentScroll;
const mobileChatElem = document.querySelector('.MuiPaper-root .card--enable-overflow');
const drawerElem = document.querySelector('.MuiDrawer-root');
const elem = (isMobile && mobileChatElem) || window;
if (elem) {
// $FlowFixMe
elem.scrollTo({
top:
node.getBoundingClientRect().top +
// $FlowFixMe
(mobileChatElem && drawerElem ? drawerElem.getBoundingClientRect().top * -1 : elem.scrollY) -
ROUGH_HEADER_HEIGHT,
// $FlowFixMe
top: node.getBoundingClientRect().top + (mobileChatElem ? 0 : elem.scrollY) - ROUGH_HEADER_HEIGHT,
left: 0,
behavior: 'smooth',
});
}
}
},
[isMobile]
[ROUGH_HEADER_HEIGHT, isMobile]
);
return (
@ -250,28 +271,31 @@ function CommentView(props: Props) {
})}
id={commentId}
>
<div
ref={isLinkedComment ? linkedCommentRef : undefined}
className={classnames('comment__content', {
[COMMENT_HIGHLIGHTED]: isLinkedComment,
'comment--slimed': slimedToDeath && !displayDeadComment,
})}
>
<div className="comment__thumbnail-wrapper">
{authorUri ? (
<ChannelThumbnail
uri={authorUri}
obscure={channelIsBlocked}
xsmall
className="comment__author-thumbnail"
checkMembership={false}
/>
) : (
<ChannelThumbnail xsmall className="comment__author-thumbnail" checkMembership={false} />
)}
</div>
<div className="comment__thumbnail-wrapper">
{authorUri ? (
<ChannelThumbnail
uri={authorUri}
obscure={channelIsBlocked}
xsmall
className="comment__author-thumbnail"
checkMembership={false}
/>
) : (
<ChannelThumbnail xsmall className="comment__author-thumbnail" checkMembership={false} />
)}
<div className="comment__body-container">
{numDirectReplies > 0 && showReplies && (
<Button className="comment__threadline" aria-label="Hide Replies" onClick={() => setShowReplies(false)} />
)}
</div>
<div className="comment__content" ref={isLinkedComment || isThreadComment ? linkedCommentRef : undefined}>
<div
className={classnames('comment__body-container', {
[COMMENT_HIGHLIGHTED]: isLinkedComment || (isThreadComment && !linkedCommentId),
'comment--slimed': slimedToDeath && !displayDeadComment,
})}
>
<div className="comment__meta">
<div className="comment__meta-information">
{!author ? (
@ -361,8 +385,8 @@ function CommentView(props: Props) {
<div className="sticker__comment">
<OptimizedImage src={stickerFromMessage.url} waitLoad loading="lazy" />
</div>
) : editedMessage.length >= LENGTH_TO_COLLAPSE ? (
<Expandable>
) : isExpandable ? (
<Expandable beginCollapsed>
<MarkdownPreview
content={message}
promptLinks
@ -384,49 +408,60 @@ function CommentView(props: Props) {
{!hideActions && (
<div className="comment__actions">
{threadDepth !== 0 && (
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Log in to reply')}
className="comment__action"
onClick={handleCommentReply}
icon={ICONS.REPLY}
iconSize={isMobile && 12}
/>
)}
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Log in to reply')}
className="comment__action"
onClick={handleCommentReply}
icon={ICONS.REPLY}
iconSize={isMobile && 12}
/>
{ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />}
</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);
}
}}
icon={ICONS.DOWN}
/>
</div>
)}
{numDirectReplies > 0 && showReplies && (
<div className="comment__actions">
<Button
label={__('Hide replies')}
button="link"
onClick={() => setShowReplies(false)}
icon={ICONS.UP}
/>
</div>
{repliesFetching && (!fetchedReplies || fetchedReplies.length === 0) ? (
<span className="comment__actions comment__replies-loading">
<Spinner text={numDirectReplies > 1 ? __('Loading Replies') : __('Loading Reply')} type="small" />
</span>
) : (
numDirectReplies > 0 && (
<div className="comment__actions">
{!showReplies ? (
openNewThread ? (
<Button
label={__('Continue Thread')}
button="link"
onClick={handleOpenNewThread}
iconRight={ICONS.ARROW_RIGHT}
/>
) : (
<Button
label={
numDirectReplies < 2
? __('Show reply')
: __('Show %count% replies', { count: numDirectReplies })
}
button="link"
onClick={() => {
setShowReplies(true);
if (page === 0) {
setPage(1);
}
}}
iconRight={ICONS.DOWN}
/>
)
) : (
<Button
label={__('Hide replies')}
button="link"
onClick={() => setShowReplies(false)}
iconRight={ICONS.UP}
/>
)}
</div>
)
)}
{isReplying && (
@ -435,7 +470,11 @@ function CommentView(props: Props) {
uri={uri}
parentId={commentId}
onDoneReplying={() => {
setShowReplies(true);
if (openNewThread) {
handleOpenNewThread();
} else {
setShowReplies(true);
}
setReplying(false);
}}
onCancelReplying={() => {
@ -448,19 +487,21 @@ function CommentView(props: Props) {
)}
</div>
</div>
</div>
{showReplies && (
<CommentsReplies
threadDepth={threadDepth - 1}
uri={uri}
parentId={commentId}
linkedCommentId={linkedCommentId}
numDirectReplies={numDirectReplies}
onShowMore={() => setPage(page + 1)}
hasMore={page < totalReplyPages}
/>
)}
{showReplies && (
<CommentsReplies
threadLevel={threadLevel}
uri={uri}
parentId={commentId}
linkedCommentId={linkedCommentId}
threadCommentId={threadCommentId}
numDirectReplies={numDirectReplies}
onShowMore={() => setPage(page + 1)}
hasMore={page < totalReplyPages}
threadDepthLevel={threadDepthLevel}
/>
)}
</div>
</li>
);
}

View file

@ -1,5 +1,6 @@
// @flow
import { getChannelFromClaim } from 'util/claim';
import { LINKED_COMMENT_QUERY_PARAM } from 'constants/comment';
import { MenuList, MenuItem } from '@reach/menu-button';
import { parseURI } from 'util/lbryURI';
import { URL } from 'config';
@ -163,8 +164,8 @@ function CommentMenuList(props: Props) {
function handleCopyCommentLink() {
const urlParams = new URLSearchParams(search);
urlParams.delete('lc');
urlParams.append('lc', commentId);
urlParams.delete(LINKED_COMMENT_QUERY_PARAM);
urlParams.append(LINKED_COMMENT_QUERY_PARAM, commentId);
navigator.clipboard
.writeText(`${URL}${pathname}?${urlParams.toString()}`)
.then(() => doToast({ message: __('Link copied.') }));

View file

@ -17,6 +17,8 @@ import {
selectCommentIdsForUri,
selectSettingsByChannelId,
selectPinnedCommentsForUri,
selectCommentForCommentId,
selectCommentAncestorsForId,
} from 'redux/selectors/comments';
import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from 'redux/actions/comments';
import { selectActiveChannelClaim } from 'redux/selectors/app';
@ -25,13 +27,15 @@ import { doFetchUserMemberships } from 'redux/actions/user';
import CommentsList from './view';
const select = (state, props) => {
const { uri } = props;
const { uri, threadCommentId, linkedCommentId } = props;
const claim = selectClaimForUri(state, uri);
const activeChannelClaim = selectActiveChannelClaim(state);
const threadComment = selectCommentForCommentId(state, threadCommentId);
return {
topLevelComments: selectTopLevelCommentsForUri(state, uri),
topLevelComments: threadComment ? [threadComment] : selectTopLevelCommentsForUri(state, uri),
threadComment,
allCommentIds: selectCommentIdsForUri(state, uri),
pinnedComments: selectPinnedCommentsForUri(state, uri),
topLevelTotalPages: makeSelectTopLevelTotalPagesForUri(uri)(state),
@ -48,6 +52,8 @@ const select = (state, props) => {
othersReactsById: selectOthersReacts(state),
activeChannelId: activeChannelClaim && activeChannelClaim.claim_id,
claimsByUri: selectClaimsByUri(state),
threadCommentAncestors: selectCommentAncestorsForId(state, threadCommentId),
linkedCommentAncestors: selectCommentAncestorsForId(state, linkedCommentId),
};
};

View file

@ -1,5 +1,10 @@
// @flow
import { COMMENT_PAGE_SIZE_TOP_LEVEL, SORT_BY } from 'constants/comment';
import {
COMMENT_PAGE_SIZE_TOP_LEVEL,
SORT_BY,
LINKED_COMMENT_QUERY_PARAM,
THREAD_COMMENT_QUERY_PARAM,
} from 'constants/comment';
import { ENABLE_COMMENT_REACTIONS } from 'config';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import { getCommentsListTitle } from 'util/comments';
@ -16,6 +21,7 @@ import React, { useEffect } from 'react';
import Spinner from 'component/spinner';
import usePersistedState from 'effects/use-persisted-state';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import { useHistory } from 'react-router-dom';
const DEBOUNCE_SCROLL_HANDLER_MS = 200;
@ -47,6 +53,11 @@ type Props = {
activeChannelId: ?string,
settingsByChannelId: { [channelId: string]: PerChannelSettings },
commentsAreExpanded?: boolean,
threadCommentId: ?string,
threadComment: ?Comment,
notInDrawer?: boolean,
threadCommentAncestors: ?Array<string>,
linkedCommentAncestors: ?Array<string>,
fetchTopLevelComments: (uri: string, parentId: ?string, page: number, pageSize: number, sortBy: number) => void,
fetchComment: (commentId: string) => void,
fetchReacts: (commentIds: Array<string>) => Promise<any>,
@ -75,6 +86,11 @@ export default function CommentList(props: Props) {
activeChannelId,
settingsByChannelId,
commentsAreExpanded,
threadCommentId,
threadComment,
notInDrawer,
threadCommentAncestors,
linkedCommentAncestors,
fetchTopLevelComments,
fetchComment,
fetchReacts,
@ -83,6 +99,13 @@ export default function CommentList(props: Props) {
doFetchUserMemberships,
} = props;
const threadRedirect = React.useRef(false);
const {
push,
location: { pathname, search },
} = useHistory();
const isMobile = useIsMobile();
const isMediumScreen = useIsMediumScreen();
@ -99,6 +122,16 @@ export default function CommentList(props: Props) {
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
const moreBelow = page < topLevelTotalPages;
const title = getCommentsListTitle(totalComments);
const threadDepthLevel = isMobile ? 3 : 10;
let threadCommentParent;
if (threadCommentAncestors) {
threadCommentAncestors.some((ancestor, index) => {
if (index >= threadDepthLevel - 1) return true;
threadCommentParent = ancestor;
});
}
const threadTopLevelComment = threadCommentAncestors && threadCommentAncestors[threadCommentAncestors.length - 1];
// Display comments immediately if not fetching reactions
// If not, wait to show comments until reactions are fetched
@ -125,13 +158,37 @@ export default function CommentList(props: Props) {
setPage(1);
}, [claimId, resetComments]);
function refreshComments() {
// Invalidate existing comments
setPage(0);
}
function changeSort(newSort) {
if (sort !== newSort) {
setSort(newSort);
setPage(0); // Invalidate existing comments
refreshComments();
}
}
// If a linked comment is deep within a thread, redirect to it's own thread page
// based on the set depthLevel (mobile/desktop)
React.useEffect(() => {
if (
!threadCommentId &&
linkedCommentId &&
linkedCommentAncestors &&
linkedCommentAncestors.length > threadDepthLevel - 1 &&
!threadRedirect.current
) {
const urlParams = new URLSearchParams(search);
urlParams.set(THREAD_COMMENT_QUERY_PARAM, linkedCommentId);
push({ pathname, search: urlParams.toString() });
// to do it only once
threadRedirect.current = true;
}
}, [linkedCommentAncestors, linkedCommentId, pathname, push, search, threadCommentId, threadDepthLevel]);
// Force comments reset
useEffect(() => {
if (page === 0) {
@ -147,8 +204,13 @@ export default function CommentList(props: Props) {
// Fetch top-level comments
useEffect(() => {
if (page !== 0) {
if (page === 1 && linkedCommentId) {
fetchComment(linkedCommentId);
if (page === 1) {
if (threadCommentId) {
fetchComment(threadCommentId);
}
if (linkedCommentId) {
fetchComment(linkedCommentId);
}
}
fetchTopLevelComments(uri, undefined, page, COMMENT_PAGE_SIZE_TOP_LEVEL, sort);
@ -157,7 +219,13 @@ export default function CommentList(props: Props) {
// no need to listen for uri change, claimId change will trigger page which
// will handle this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchComment, fetchTopLevelComments, linkedCommentId, page, sort]);
}, [fetchComment, fetchTopLevelComments, linkedCommentId, page, sort, threadCommentId]);
React.useEffect(() => {
if (threadCommentId) {
refreshComments();
}
}, [threadCommentId]);
// Fetch reacts
useEffect(() => {
@ -194,7 +262,7 @@ export default function CommentList(props: Props) {
// Scroll to linked-comment
useEffect(() => {
if (linkedCommentId) {
if (linkedCommentId || threadCommentId) {
window.pendingLinkedCommentScroll = true;
} else {
delete window.pendingLinkedCommentScroll;
@ -262,30 +330,58 @@ export default function CommentList(props: Props) {
topLevelTotalPages,
]);
const commentProps = { isTopLevel: true, threadDepth: 3, uri, claimIsMine, linkedCommentId };
const commentProps = {
isTopLevel: true,
uri,
claimIsMine,
linkedCommentId,
threadCommentId,
threadDepthLevel,
};
const actionButtonsProps = {
totalComments,
sort,
changeSort,
setPage,
handleRefresh: () => setPage(0),
handleRefresh: refreshComments,
};
return (
<Card
className="card--enable-overflow"
title={!isMobile && title}
className="card--enable-overflow comment__list"
title={(!isMobile || notInDrawer) && title}
titleActions={<CommentActionButtons {...actionButtonsProps} />}
actions={
<>
{isMobile && <CommentActionButtons {...actionButtonsProps} />}
{isMobile && !notInDrawer && <CommentActionButtons {...actionButtonsProps} />}
<CommentCreate uri={uri} />
{channelSettings && channelSettings.comments_enabled && !isFetchingComments && !totalComments && (
<Empty padded text={__('That was pretty deep. What do you think?')} />
{threadCommentId && threadComment && (
<span className="comment__actions comment__thread-links">
<ThreadLinkButton
label={__('View all comments')}
threadCommentParent={threadTopLevelComment || threadCommentId}
threadCommentId={threadCommentId}
isViewAll
/>
{threadCommentParent && (
<ThreadLinkButton
label={__('Show parent comments')}
threadCommentParent={threadCommentParent}
threadCommentId={threadCommentId}
/>
)}
</span>
)}
{channelSettings &&
channelSettings.comments_enabled &&
!isFetchingComments &&
!totalComments &&
!threadCommentId && <Empty padded text={__('That was pretty deep. What do you think?')} />}
<ul
ref={commentListRef}
className={classnames('comments', {
@ -294,7 +390,7 @@ export default function CommentList(props: Props) {
>
{readyToDisplayComments && (
<>
{pinnedComments && <CommentElements comments={pinnedComments} {...commentProps} />}
{pinnedComments && !threadCommentId && <CommentElements comments={pinnedComments} {...commentProps} />}
<CommentElements comments={topLevelComments} {...commentProps} />
</>
)}
@ -331,7 +427,7 @@ export default function CommentList(props: Props) {
</div>
)}
{(isFetchingComments || (hasDefaultExpansion && moreBelow)) && (
{(threadCommentId ? !readyToDisplayComments : isFetchingComments || (hasDefaultExpansion && moreBelow)) && (
<div className="main--empty" ref={spinnerRef}>
<Spinner type="small" />
</div>
@ -403,3 +499,45 @@ const SortButton = (sortButtonProps: SortButtonProps) => {
/>
);
};
type ThreadLinkProps = {
label: string,
isViewAll?: boolean,
threadCommentParent: string,
threadCommentId: string,
};
const ThreadLinkButton = (props: ThreadLinkProps) => {
const { label, isViewAll, threadCommentParent, threadCommentId } = props;
const {
push,
location: { pathname, search },
} = useHistory();
return (
<Button
button="link"
label={label}
icon={ICONS.ARROW_LEFT}
iconSize={12}
onClick={() => {
const urlParams = new URLSearchParams(search);
if (!isViewAll) {
urlParams.set(THREAD_COMMENT_QUERY_PARAM, threadCommentParent);
// on moving back, link the current thread comment so that it auto-expands into the correct conversation
urlParams.set(LINKED_COMMENT_QUERY_PARAM, threadCommentId);
} else {
urlParams.delete(THREAD_COMMENT_QUERY_PARAM);
// links the top-level comment when going back to all comments, for easy locating
// in the middle of big comment sections
urlParams.set(LINKED_COMMENT_QUERY_PARAM, threadCommentParent);
}
window.pendingLinkedCommentScroll = true;
push({ pathname, search: urlParams.toString() });
}}
/>
);
};

View file

@ -1,5 +1,4 @@
// @flow
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import Comment from 'component/comment';
import React from 'react';
@ -8,14 +7,16 @@ import Spinner from 'component/spinner';
type Props = {
uri: string,
linkedCommentId?: string,
threadDepth: number,
threadCommentId?: string,
numDirectReplies: number, // Total replies for parentId as reported by 'comment[replies]'. Includes blocked items.
hasMore: boolean,
supportDisabled: boolean,
threadDepthLevel?: number,
onShowMore?: () => void,
// redux
fetchedReplies: Array<Comment>,
claimIsMine: boolean,
threadLevel: number,
isFetching: boolean,
};
@ -25,67 +26,50 @@ export default function CommentsReplies(props: Props) {
fetchedReplies,
claimIsMine,
linkedCommentId,
threadDepth,
threadCommentId,
numDirectReplies,
isFetching,
hasMore,
supportDisabled,
threadDepthLevel,
onShowMore,
threadLevel,
isFetching,
} = props;
const [isExpanded, setExpanded] = React.useState(true);
return !numDirectReplies ? null : (
<div className="comment__replies-container">
{!isExpanded ? (
<div className="comment__actions--nested">
<Button
className="comment__action"
label={__('Show Replies')}
onClick={() => setExpanded(!isExpanded)}
icon={isExpanded ? ICONS.UP : ICONS.DOWN}
<ul className="comment__replies">
{fetchedReplies.map((comment) => (
<Comment
key={comment.comment_id}
uri={uri}
comment={comment}
claimIsMine={claimIsMine}
linkedCommentId={linkedCommentId}
threadCommentId={threadCommentId}
supportDisabled={supportDisabled}
threadLevel={threadLevel + 1}
threadDepthLevel={threadDepthLevel}
/>
</div>
) : (
<div className="comment__replies">
{fetchedReplies.length > 0 && (
<Button className="comment__threadline" aria-label="Hide Replies" onClick={() => setExpanded(false)} />
)}
))}
</ul>
<ul className="comments--replies">
{fetchedReplies.map((comment) => (
<Comment
key={comment.comment_id}
threadDepth={threadDepth}
uri={uri}
comment={comment}
claimIsMine={claimIsMine}
linkedCommentId={linkedCommentId}
supportDisabled={supportDisabled}
/>
))}
</ul>
</div>
)}
{isExpanded && fetchedReplies && hasMore && (
<div className="comment__actions--nested">
<Button
button="link"
label={__('Show more')}
onClick={() => onShowMore && onShowMore()}
className="button--uri-indicator"
/>
</div>
)}
{isFetching && (
<div className="comment__replies-container">
{fetchedReplies.length > 0 &&
hasMore &&
(isFetching ? (
<span className="comment__actions--nested comment__replies-loading--more">
<Spinner text={__('Loading')} type="small" />
</span>
) : (
<div className="comment__actions--nested">
<Spinner type="small" />
<Button
button="link"
label={__('Show more')}
onClick={() => onShowMore && onShowMore()}
className="button--uri-indicator"
/>
</div>
</div>
)}
))}
</div>
);
}

View file

@ -53,11 +53,13 @@ export default function WaitUntilOnPage(props: Props) {
// Handles "element is already in viewport when mounted".
React.useEffect(() => {
setTimeout(() => {
const timer = setTimeout(() => {
if (!shouldRender && shouldElementRender(ref)) {
setShouldRender(true);
}
}, 500);
return () => clearTimeout(timer);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Handles "element scrolled into viewport".

View file

@ -8,12 +8,14 @@ const COLLAPSED_HEIGHT = 120;
type Props = {
children: React$Node | Array<React$Node>,
beginCollapsed?: boolean,
};
export default function Expandable(props: Props) {
const { children, beginCollapsed } = props;
const [expanded, setExpanded] = useState(false);
const [rect, setRect] = useState();
const { children } = props;
const ref = useRef();
// Update the rect initially & when the window size changes.
@ -40,7 +42,7 @@ export default function Expandable(props: Props) {
return (
<div ref={ref}>
{rect && rect.height > COLLAPSED_HEIGHT ? (
{(rect && rect.height > COLLAPSED_HEIGHT) || beginCollapsed ? (
<div ref={ref}>
<div
className={classnames({

View file

@ -20,6 +20,7 @@ import React from 'react';
import UriIndicator from 'component/uriIndicator';
import { generateNotificationTitle } from './helpers/title';
import { generateNotificationText } from './helpers/text';
import { LINKED_COMMENT_QUERY_PARAM } from 'constants/comment';
const CommentCreate = lazyImport(() => import('component/commentCreate' /* webpackChunkName: "comments" */));
const CommentReactions = lazyImport(() => import('component/commentReactions' /* webpackChunkName: "comments" */));
@ -93,7 +94,7 @@ export default function Notification(props: Props) {
let notificationLink = formatLbryUrlForWeb(notificationTarget);
let urlParams = new URLSearchParams();
if (isCommentNotification && notification_parameters.dynamic.hash) {
urlParams.append('lc', notification_parameters.dynamic.hash);
urlParams.append(LINKED_COMMENT_QUERY_PARAM, notification_parameters.dynamic.hash);
}
let channelName;

View file

@ -266,9 +266,11 @@ function PublishForm(props: Props) {
useEffect(() => {
if (!modal) {
setTimeout(() => {
const timer = setTimeout(() => {
setPreviewing(false);
}, 250);
return () => clearTimeout(timer);
}
}, [modal]);

View file

@ -58,7 +58,9 @@ function UserChannelFollowIntro(props: Props) {
if (claimName) channelSubscribe(claimName, channelUri);
});
};
setTimeout(delayedChannelSubscribe, 1000);
const timer = setTimeout(delayedChannelSubscribe, 1000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefsReady]);

View file

@ -1,4 +1,5 @@
export const LINKED_COMMENT_QUERY_PARAM = 'lc';
export const THREAD_COMMENT_QUERY_PARAM = 'tc';
export const SORT_COMMENTS_NEW = 'new';
export const SORT_COMMENTS_BEST = 'best';

View file

@ -239,7 +239,7 @@ function AppWrapper() {
if (readyToLaunch && persistDone) {
app.store.dispatch(doDaemonReady());
setTimeout(() => {
const timer = setTimeout(() => {
if (DEFAULT_LANGUAGE) {
app.store.dispatch(doFetchLanguage(DEFAULT_LANGUAGE));
}
@ -251,6 +251,8 @@ function AppWrapper() {
}, 25);
analytics.startupEvent(Date.now());
return () => clearTimeout(timer);
}
}, [readyToLaunch, persistDone]);

View file

@ -11,6 +11,7 @@ import {
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
import { makeSelectCollectionForId } from 'redux/selectors/collections';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import { LINKED_COMMENT_QUERY_PARAM, THREAD_COMMENT_QUERY_PARAM } from 'constants/comment';
import * as SETTINGS from 'constants/settings';
import { selectCostInfoForUri, doFetchCostInfoForUri } from 'lbryinc';
import { selectShowMatureContent, selectClientSetting } from 'redux/selectors/settings';
@ -33,7 +34,8 @@ const select = (state, props) => {
return {
channelId: getChannelIdFromClaim(claim),
linkedCommentId: urlParams.get('lc'),
linkedCommentId: urlParams.get(LINKED_COMMENT_QUERY_PARAM),
threadCommentId: urlParams.get(THREAD_COMMENT_QUERY_PARAM),
costInfo: selectCostInfoForUri(state, uri),
obscureNsfw: !selectShowMatureContent(state),
isMature: selectClaimIsNsfwForUri(state, uri),

View file

@ -33,6 +33,7 @@ type Props = {
obscureNsfw: boolean,
isMature: boolean,
linkedCommentId?: string,
threadCommentId?: string,
hasCollectionById?: boolean,
collectionId: string,
videoTheaterMode: boolean,
@ -64,6 +65,7 @@ export default function FilePage(props: Props) {
isMature,
costInfo,
linkedCommentId,
threadCommentId,
videoTheaterMode,
claimIsMine,
@ -104,7 +106,7 @@ export default function FilePage(props: Props) {
}, [audioVideoDuration, fileInfo, position]);
React.useEffect(() => {
if (linkedCommentId && isMobile) {
if ((linkedCommentId || threadCommentId) && isMobile) {
doToggleAppDrawer();
}
// only on mount, otherwise clicking on a comments timestamp and linking it
@ -215,7 +217,7 @@ export default function FilePage(props: Props) {
);
}
const commentsListProps = { uri, linkedCommentId };
const commentsListProps = { uri, linkedCommentId, threadCommentId };
const emptyMsgProps = { padded: !isMobile };
return (
@ -254,7 +256,7 @@ export default function FilePage(props: Props) {
<DrawerExpandButton label={commentsListTitle} />
</>
) : (
<CommentsList {...commentsListProps} />
<CommentsList {...commentsListProps} notInDrawer />
)}
</React.Suspense>
</section>
@ -269,7 +271,7 @@ export default function FilePage(props: Props) {
: !contentCommentsDisabled && (
<div className="file-page__post-comments">
<React.Suspense fallback={null}>
<CommentsList uri={uri} linkedCommentId={linkedCommentId} commentsAreExpanded />
<CommentsList {...commentsListProps} commentsAreExpanded notInDrawer />
</React.Suspense>
</div>
)}

View file

@ -1,5 +1,6 @@
// @flow
import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import { LINKED_COMMENT_QUERY_PARAM, THREAD_COMMENT_QUERY_PARAM } from 'constants/comment';
import React, { useEffect } from 'react';
import { lazyImport } from 'util/lazyImport';
import { Redirect } from 'react-router-dom';
@ -68,7 +69,8 @@ export default function ShowPage(props: Props) {
const { search, pathname, hash } = location;
const urlParams = new URLSearchParams(search);
const linkedCommentId = urlParams.get('lc');
const linkedCommentId = urlParams.get(LINKED_COMMENT_QUERY_PARAM);
const threadCommentId = urlParams.get(THREAD_COMMENT_QUERY_PARAM);
const signingChannel = claim && claim.signing_channel;
const canonicalUrl = claim && claim.canonical_url;
@ -134,6 +136,7 @@ export default function ShowPage(props: Props) {
isMine === undefined && isAuthenticated ? { include_is_my_output: true, include_purchase_receipt: true } : {}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
doResolveUri,
isResolvingUri,
@ -250,7 +253,12 @@ export default function ShowPage(props: Props) {
return (
<React.Suspense fallback={null}>
<FilePage uri={uri} collectionId={collectionId} linkedCommentId={linkedCommentId} />
<FilePage
uri={uri}
collectionId={collectionId}
linkedCommentId={linkedCommentId}
threadCommentId={threadCommentId}
/>
</React.Suspense>
);
}

View file

@ -18,7 +18,7 @@ const defaultState: CommentsState = {
// Remove commentsByUri
// It is not needed and doesn't provide anything but confusion
commentsByUri: {}, // URI -> claimId
linkedCommentAncestors: {},
fetchedCommentAncestors: {},
superChatsByUri: {},
pinnedCommentsById: {},
isLoading: false,
@ -386,7 +386,7 @@ export default handleActions(
const topLevelTotalPagesById = Object.assign({}, state.topLevelTotalPagesById);
const pinnedCommentsById = Object.assign({}, state.pinnedCommentsById);
const repliesByParentId = Object.assign({}, state.repliesByParentId);
const linkedCommentAncestors = Object.assign({}, state.linkedCommentAncestors);
const fetchedCommentAncestors = Object.assign({}, state.fetchedCommentAncestors);
const updateStore = (comment, commentById, byId, repliesByParentId, topLevelCommentsById) => {
commentById[comment.comment_id] = comment;
@ -409,7 +409,7 @@ export default handleActions(
if (ancestors) {
ancestors.forEach((ancestor) => {
updateStore(ancestor, commentById, byId, repliesByParentId, topLevelCommentsById);
immPushToArrayInObject(linkedCommentAncestors, comment.comment_id, ancestor.comment_id);
immPushToArrayInObject(fetchedCommentAncestors, comment.comment_id, ancestor.comment_id);
});
}
@ -423,7 +423,7 @@ export default handleActions(
repliesByParentId,
byId,
commentById,
linkedCommentAncestors,
fetchedCommentAncestors,
};
},

View file

@ -162,8 +162,11 @@ export const selectTopLevelCommentsByClaimId = createSelector(
}
);
export const makeSelectCommentForCommentId = (commentId: string) =>
createSelector(selectCommentsById, (comments) => comments[commentId]);
export const selectCommentForCommentId = createSelector(
(state, commentId) => commentId,
selectCommentsById,
(commentId, comments) => comments[commentId]
);
export const selectRepliesByParentId = createSelector(selectState, selectCommentsById, (state, byId) => {
const byParentId = state.repliesByParentId || {};
@ -182,7 +185,13 @@ export const selectRepliesByParentId = createSelector(selectState, selectComment
return comments;
});
export const selectLinkedCommentAncestors = (state: State) => selectState(state).linkedCommentAncestors;
export const selectFetchedCommentAncestors = (state: State) => selectState(state).fetchedCommentAncestors;
export const selectCommentAncestorsForId = createSelector(
(state, commentId) => commentId,
selectFetchedCommentAncestors,
(commentId, fetchedAncestors) => fetchedAncestors && fetchedAncestors[commentId]
);
export const selectCommentIdsForUri = (state: State, uri: string) => {
const claimId = selectClaimIdForUri(state, uri);

View file

@ -19,23 +19,6 @@
.card__main-actions {
margin-top: 0px !important;
.comment__sort {
margin: 0px !important;
display: inline;
button {
padding: var(--spacing-xxs);
span {
font-size: var(--font-xxsmall);
}
}
}
> .button--alt {
padding: var(--spacing-xxs) var(--spacing-s);
}
.comment__sort + button {
margin: 0px var(--spacing-xxs);
}
@ -44,6 +27,23 @@
margin-top: var(--spacing-xxs);
}
}
.comment__sort {
margin: 0px !important;
display: inline;
+ .button--alt {
padding: var(--spacing-xxs) var(--spacing-s);
}
button {
padding: var(--spacing-xxs);
span {
font-size: var(--font-xxsmall);
}
}
}
}
}
@ -279,17 +279,20 @@
}
.card--enable-overflow {
.card__header--between {
@media (max-width: $breakpoint-small) {
@media (max-width: $breakpoint-small) {
.card__header--between {
margin-bottom: 0;
.card__title-section {
width: 60%;
margin: var(--spacing-s) 0px;
h2 {
font-size: var(--font-body);
}
}
}
}
.card__title-actions-container {
@media (max-width: $breakpoint-small) {
.card__title-actions-container {
flex-grow: 1;
min-height: 47px;

View file

@ -34,10 +34,9 @@ $thumbnailWidthSmall: 2rem;
overflow-wrap: anywhere;
}
.comments--replies {
list-style-type: none;
margin-left: var(--spacing-s);
flex: 1;
.comment__list .card__first-pane > .button {
margin-left: 3px;
height: calc(var(--height-button) - 3px);
}
.comment__sort {
@ -56,7 +55,7 @@ $thumbnailWidthSmall: 2rem;
.comment {
width: 100%;
display: flex;
flex-direction: column;
flex-direction: row;
font-size: var(--font-small);
margin: 0;
position: relative;
@ -88,13 +87,17 @@ $thumbnailWidthSmall: 2rem;
}
.comment__thumbnail-wrapper {
flex: 0;
// margin-top: var(--spacing-xxs);
display: grid;
justify-items: stretch;
grid-template-rows: auto 1fr;
grid-gap: 1rem;
}
.comment__content {
display: flex;
flex-direction: row;
flex-direction: column;
width: 100%;
overflow: hidden;
&:hover {
.ff-canvas {
@ -113,19 +116,49 @@ $thumbnailWidthSmall: 2rem;
}
.comment__replies {
display: flex;
margin-top: var(--spacing-m);
margin-left: #{$thumbnailWidthSmall};
@media (min-width: $breakpoint-small) {
margin-left: calc(#{$thumbnailWidth} + var(--spacing-m));
}
list-style-type: none;
margin-left: var(--spacing-s);
flex: 1;
@media (max-width: $breakpoint-small) {
margin-top: var(--spacing-s);
}
}
.comment__replies-loading {
color: var(--color-link);
align-items: center;
margin-left: var(--spacing-xs);
.spinner {
margin: 0px;
.rect {
background-color: var(--color-link) !important;
}
}
}
.comment__replies-loading--more {
align-items: flex-start;
transform: translate(var(--spacing-xs));
.spinner {
margin: 0px;
}
}
.comment__thread-links {
font-size: var(--font-xsmall);
flex-direction: column;
margin-bottom: var(--spacing-m);
.button {
padding: var(--spacing-xxs) 0px;
}
}
.comment--reply {
margin: 0;
@ -151,18 +184,19 @@ $thumbnailWidthSmall: 2rem;
.comment__threadline {
@extend .button--alt;
height: auto;
align-self: stretch;
padding: 1px;
border-radius: 3px;
background-color: var(--color-comment-threadline);
border-left: 1px solid var(--color-comment-threadline);
background-color: transparent !important;
border-radius: 0px;
left: 50%;
&:hover {
background-color: var(--color-comment-threadline-hover);
border-color: var(--color-comment-threadline-hover);
}
border-left: 4px solid var(--color-comment-threadline);
background-color: transparent !important;
padding-right: calc(var(--spacing-m) - 3px);
@media (min-width: $breakpoint-small) {
padding: 2px;
@media (max-width: $breakpoint-small) {
left: calc(50% - 3px);
}
}
}

View file

@ -238,18 +238,16 @@ body {
.card {
padding: 0;
.card__first-pane {
.card__main-actions {
.button--alt:last-of-type {
top: -1px;
float: right;
margin-right: 0;
}
.comment__sort {
.button--alt:last-of-type {
top: unset;
float: unset;
margin-right: unset;
}
.button--alt[aria-label='Refresh'] {
top: -1px;
float: right;
margin-right: 0;
}
.comment__sort {
.button--alt[aria-label='Refresh'] {
top: unset;
float: unset;
margin-right: unset;
}
}
}

View file

@ -270,18 +270,16 @@
}
}
.card__main-actions {
.button--alt:last-of-type {
top: -1px;
float: right;
margin-right: 0;
}
.comment__sort {
.button--alt:last-of-type {
top: unset;
float: unset;
margin-right: unset;
}
.button--alt[aria-label='Refresh'] {
top: -1px;
float: right;
margin-right: 0;
}
.comment__sort {
.button--alt[aria-label='Refresh'] {
top: unset;
float: unset;
margin-right: unset;
}
}