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. 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. topLevelTotalCommentsById: { [string]: number }, // ClaimID -> total top level comments in commentron.
commentById: { [string]: Comment }, // commentId -> Comment 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 pinnedCommentsById: {}, // ClaimId -> array of pinned comment IDs
isLoading: boolean, isLoading: boolean,
isLoadingById: boolean, isLoadingById: boolean,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,10 @@
// @flow // @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 { ENABLE_COMMENT_REACTIONS } from 'config';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import { getCommentsListTitle } from 'util/comments'; import { getCommentsListTitle } from 'util/comments';
@ -16,6 +21,7 @@ import React, { useEffect } from 'react';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import useGetUserMemberships from 'effects/use-get-user-memberships'; import useGetUserMemberships from 'effects/use-get-user-memberships';
import { useHistory } from 'react-router-dom';
const DEBOUNCE_SCROLL_HANDLER_MS = 200; const DEBOUNCE_SCROLL_HANDLER_MS = 200;
@ -47,6 +53,11 @@ type Props = {
activeChannelId: ?string, activeChannelId: ?string,
settingsByChannelId: { [channelId: string]: PerChannelSettings }, settingsByChannelId: { [channelId: string]: PerChannelSettings },
commentsAreExpanded?: boolean, 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, fetchTopLevelComments: (uri: string, parentId: ?string, page: number, pageSize: number, sortBy: number) => void,
fetchComment: (commentId: string) => void, fetchComment: (commentId: string) => void,
fetchReacts: (commentIds: Array<string>) => Promise<any>, fetchReacts: (commentIds: Array<string>) => Promise<any>,
@ -75,6 +86,11 @@ export default function CommentList(props: Props) {
activeChannelId, activeChannelId,
settingsByChannelId, settingsByChannelId,
commentsAreExpanded, commentsAreExpanded,
threadCommentId,
threadComment,
notInDrawer,
threadCommentAncestors,
linkedCommentAncestors,
fetchTopLevelComments, fetchTopLevelComments,
fetchComment, fetchComment,
fetchReacts, fetchReacts,
@ -83,6 +99,13 @@ export default function CommentList(props: Props) {
doFetchUserMemberships, doFetchUserMemberships,
} = props; } = props;
const threadRedirect = React.useRef(false);
const {
push,
location: { pathname, search },
} = useHistory();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMediumScreen = useIsMediumScreen(); const isMediumScreen = useIsMediumScreen();
@ -99,6 +122,16 @@ export default function CommentList(props: Props) {
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
const moreBelow = page < topLevelTotalPages; const moreBelow = page < topLevelTotalPages;
const title = getCommentsListTitle(totalComments); 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 // 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
@ -125,13 +158,37 @@ export default function CommentList(props: Props) {
setPage(1); setPage(1);
}, [claimId, resetComments]); }, [claimId, resetComments]);
function refreshComments() {
// Invalidate existing comments
setPage(0);
}
function changeSort(newSort) { function changeSort(newSort) {
if (sort !== newSort) { if (sort !== newSort) {
setSort(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 // Force comments reset
useEffect(() => { useEffect(() => {
if (page === 0) { if (page === 0) {
@ -147,8 +204,13 @@ export default function CommentList(props: Props) {
// Fetch top-level comments // Fetch top-level comments
useEffect(() => { useEffect(() => {
if (page !== 0) { if (page !== 0) {
if (page === 1 && linkedCommentId) { if (page === 1) {
fetchComment(linkedCommentId); if (threadCommentId) {
fetchComment(threadCommentId);
}
if (linkedCommentId) {
fetchComment(linkedCommentId);
}
} }
fetchTopLevelComments(uri, undefined, page, COMMENT_PAGE_SIZE_TOP_LEVEL, sort); 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 // no need to listen for uri change, claimId change will trigger page which
// will handle this // will handle this
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // Fetch reacts
useEffect(() => { useEffect(() => {
@ -194,7 +262,7 @@ export default function CommentList(props: Props) {
// Scroll to linked-comment // Scroll to linked-comment
useEffect(() => { useEffect(() => {
if (linkedCommentId) { if (linkedCommentId || threadCommentId) {
window.pendingLinkedCommentScroll = true; window.pendingLinkedCommentScroll = true;
} else { } else {
delete window.pendingLinkedCommentScroll; delete window.pendingLinkedCommentScroll;
@ -262,30 +330,58 @@ export default function CommentList(props: Props) {
topLevelTotalPages, topLevelTotalPages,
]); ]);
const commentProps = { isTopLevel: true, threadDepth: 3, uri, claimIsMine, linkedCommentId }; const commentProps = {
isTopLevel: true,
uri,
claimIsMine,
linkedCommentId,
threadCommentId,
threadDepthLevel,
};
const actionButtonsProps = { const actionButtonsProps = {
totalComments, totalComments,
sort, sort,
changeSort, changeSort,
setPage, setPage,
handleRefresh: () => setPage(0), handleRefresh: refreshComments,
}; };
return ( return (
<Card <Card
className="card--enable-overflow" className="card--enable-overflow comment__list"
title={!isMobile && title} title={(!isMobile || notInDrawer) && title}
titleActions={<CommentActionButtons {...actionButtonsProps} />} titleActions={<CommentActionButtons {...actionButtonsProps} />}
actions={ actions={
<> <>
{isMobile && <CommentActionButtons {...actionButtonsProps} />} {isMobile && !notInDrawer && <CommentActionButtons {...actionButtonsProps} />}
<CommentCreate uri={uri} /> <CommentCreate uri={uri} />
{channelSettings && channelSettings.comments_enabled && !isFetchingComments && !totalComments && ( {threadCommentId && threadComment && (
<Empty padded text={__('That was pretty deep. What do you think?')} /> <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 <ul
ref={commentListRef} ref={commentListRef}
className={classnames('comments', { className={classnames('comments', {
@ -294,7 +390,7 @@ export default function CommentList(props: Props) {
> >
{readyToDisplayComments && ( {readyToDisplayComments && (
<> <>
{pinnedComments && <CommentElements comments={pinnedComments} {...commentProps} />} {pinnedComments && !threadCommentId && <CommentElements comments={pinnedComments} {...commentProps} />}
<CommentElements comments={topLevelComments} {...commentProps} /> <CommentElements comments={topLevelComments} {...commentProps} />
</> </>
)} )}
@ -331,7 +427,7 @@ export default function CommentList(props: Props) {
</div> </div>
)} )}
{(isFetchingComments || (hasDefaultExpansion && moreBelow)) && ( {(threadCommentId ? !readyToDisplayComments : isFetchingComments || (hasDefaultExpansion && moreBelow)) && (
<div className="main--empty" ref={spinnerRef}> <div className="main--empty" ref={spinnerRef}>
<Spinner type="small" /> <Spinner type="small" />
</div> </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 // @flow
import * as ICONS from 'constants/icons';
import Button from 'component/button'; import Button from 'component/button';
import Comment from 'component/comment'; import Comment from 'component/comment';
import React from 'react'; import React from 'react';
@ -8,14 +7,16 @@ import Spinner from 'component/spinner';
type Props = { type Props = {
uri: string, uri: string,
linkedCommentId?: string, linkedCommentId?: string,
threadDepth: number, threadCommentId?: string,
numDirectReplies: number, // Total replies for parentId as reported by 'comment[replies]'. Includes blocked items. numDirectReplies: number, // Total replies for parentId as reported by 'comment[replies]'. Includes blocked items.
hasMore: boolean, hasMore: boolean,
supportDisabled: boolean, supportDisabled: boolean,
threadDepthLevel?: number,
onShowMore?: () => void, onShowMore?: () => void,
// redux // redux
fetchedReplies: Array<Comment>, fetchedReplies: Array<Comment>,
claimIsMine: boolean, claimIsMine: boolean,
threadLevel: number,
isFetching: boolean, isFetching: boolean,
}; };
@ -25,67 +26,50 @@ export default function CommentsReplies(props: Props) {
fetchedReplies, fetchedReplies,
claimIsMine, claimIsMine,
linkedCommentId, linkedCommentId,
threadDepth, threadCommentId,
numDirectReplies, numDirectReplies,
isFetching,
hasMore, hasMore,
supportDisabled, supportDisabled,
threadDepthLevel,
onShowMore, onShowMore,
threadLevel,
isFetching,
} = props; } = props;
const [isExpanded, setExpanded] = React.useState(true);
return !numDirectReplies ? null : ( return !numDirectReplies ? null : (
<div className="comment__replies-container"> <div className="comment__replies-container">
{!isExpanded ? ( <ul className="comment__replies">
<div className="comment__actions--nested"> {fetchedReplies.map((comment) => (
<Button <Comment
className="comment__action" key={comment.comment_id}
label={__('Show Replies')} uri={uri}
onClick={() => setExpanded(!isExpanded)} comment={comment}
icon={isExpanded ? ICONS.UP : ICONS.DOWN} claimIsMine={claimIsMine}
linkedCommentId={linkedCommentId}
threadCommentId={threadCommentId}
supportDisabled={supportDisabled}
threadLevel={threadLevel + 1}
threadDepthLevel={threadDepthLevel}
/> />
</div> ))}
) : ( </ul>
<div className="comment__replies">
{fetchedReplies.length > 0 && (
<Button className="comment__threadline" aria-label="Hide Replies" onClick={() => setExpanded(false)} />
)}
<ul className="comments--replies"> {fetchedReplies.length > 0 &&
{fetchedReplies.map((comment) => ( hasMore &&
<Comment (isFetching ? (
key={comment.comment_id} <span className="comment__actions--nested comment__replies-loading--more">
threadDepth={threadDepth} <Spinner text={__('Loading')} type="small" />
uri={uri} </span>
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">
<div className="comment__actions--nested"> <div className="comment__actions--nested">
<Spinner type="small" /> <Button
button="link"
label={__('Show more')}
onClick={() => onShowMore && onShowMore()}
className="button--uri-indicator"
/>
</div> </div>
</div> ))}
)}
</div> </div>
); );
} }

View file

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

View file

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

View file

@ -20,6 +20,7 @@ import React from 'react';
import UriIndicator from 'component/uriIndicator'; import UriIndicator from 'component/uriIndicator';
import { generateNotificationTitle } from './helpers/title'; import { generateNotificationTitle } from './helpers/title';
import { generateNotificationText } from './helpers/text'; import { generateNotificationText } from './helpers/text';
import { LINKED_COMMENT_QUERY_PARAM } from 'constants/comment';
const CommentCreate = lazyImport(() => import('component/commentCreate' /* webpackChunkName: "comments" */)); const CommentCreate = lazyImport(() => import('component/commentCreate' /* webpackChunkName: "comments" */));
const CommentReactions = lazyImport(() => import('component/commentReactions' /* 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 notificationLink = formatLbryUrlForWeb(notificationTarget);
let urlParams = new URLSearchParams(); let urlParams = new URLSearchParams();
if (isCommentNotification && notification_parameters.dynamic.hash) { 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; let channelName;

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import {
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info'; import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
import { makeSelectCollectionForId } from 'redux/selectors/collections'; import { makeSelectCollectionForId } from 'redux/selectors/collections';
import * as COLLECTIONS_CONSTS from 'constants/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 * as SETTINGS from 'constants/settings';
import { selectCostInfoForUri, doFetchCostInfoForUri } from 'lbryinc'; import { selectCostInfoForUri, doFetchCostInfoForUri } from 'lbryinc';
import { selectShowMatureContent, selectClientSetting } from 'redux/selectors/settings'; import { selectShowMatureContent, selectClientSetting } from 'redux/selectors/settings';
@ -33,7 +34,8 @@ const select = (state, props) => {
return { return {
channelId: getChannelIdFromClaim(claim), 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), costInfo: selectCostInfoForUri(state, uri),
obscureNsfw: !selectShowMatureContent(state), obscureNsfw: !selectShowMatureContent(state),
isMature: selectClaimIsNsfwForUri(state, uri), isMature: selectClaimIsNsfwForUri(state, uri),

View file

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

View file

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

View file

@ -18,7 +18,7 @@ const defaultState: CommentsState = {
// 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: {}, fetchedCommentAncestors: {},
superChatsByUri: {}, superChatsByUri: {},
pinnedCommentsById: {}, pinnedCommentsById: {},
isLoading: false, isLoading: false,
@ -386,7 +386,7 @@ export default handleActions(
const topLevelTotalPagesById = Object.assign({}, state.topLevelTotalPagesById); const topLevelTotalPagesById = Object.assign({}, state.topLevelTotalPagesById);
const pinnedCommentsById = Object.assign({}, state.pinnedCommentsById); const pinnedCommentsById = Object.assign({}, state.pinnedCommentsById);
const repliesByParentId = Object.assign({}, state.repliesByParentId); 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) => { const updateStore = (comment, commentById, byId, repliesByParentId, topLevelCommentsById) => {
commentById[comment.comment_id] = comment; commentById[comment.comment_id] = comment;
@ -409,7 +409,7 @@ export default handleActions(
if (ancestors) { if (ancestors) {
ancestors.forEach((ancestor) => { ancestors.forEach((ancestor) => {
updateStore(ancestor, commentById, byId, repliesByParentId, topLevelCommentsById); 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, repliesByParentId,
byId, byId,
commentById, commentById,
linkedCommentAncestors, fetchedCommentAncestors,
}; };
}, },

View file

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

View file

@ -19,23 +19,6 @@
.card__main-actions { .card__main-actions {
margin-top: 0px !important; 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 { .comment__sort + button {
margin: 0px var(--spacing-xxs); margin: 0px var(--spacing-xxs);
} }
@ -44,6 +27,23 @@
margin-top: var(--spacing-xxs); 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--enable-overflow {
.card__header--between { @media (max-width: $breakpoint-small) {
@media (max-width: $breakpoint-small) { .card__header--between {
margin-bottom: 0; margin-bottom: 0;
.card__title-section { .card__title-section {
width: 60%; margin: var(--spacing-s) 0px;
h2 {
font-size: var(--font-body);
}
} }
} }
}
.card__title-actions-container { .card__title-actions-container {
@media (max-width: $breakpoint-small) {
flex-grow: 1; flex-grow: 1;
min-height: 47px; min-height: 47px;

View file

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

View file

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

View file

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