Improve scrolling behavior, fix tips sorting (#863)

This commit is contained in:
saltrafael 2022-02-14 16:28:25 -03:00 committed by GitHub
parent 5c5b46ddb3
commit ecc3599a85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 163 additions and 132 deletions

View file

@ -14,7 +14,7 @@ type Props = {
children: React.Node, children: React.Node,
description: ?string, description: ?string,
isResolvingUri: boolean, isResolvingUri: boolean,
doResolveUri: (string) => void, doResolveUri: (string, boolean) => void,
playingUri: ?PlayingUri, playingUri: ?PlayingUri,
parentCommentId?: string, parentCommentId?: string,
isMarkdownPost?: boolean, isMarkdownPost?: boolean,
@ -43,7 +43,7 @@ class ClaimLink extends React.Component<Props> {
const { isResolvingUri, doResolveUri, claim, uri } = props; const { isResolvingUri, doResolveUri, claim, uri } = props;
if (!isResolvingUri && claim === undefined && uri) { if (!isResolvingUri && claim === undefined && uri) {
doResolveUri(uri); doResolveUri(uri, true);
} }
}; };

View file

@ -6,7 +6,6 @@ import { doCommentList, doSuperChatList } from 'redux/actions/comments';
import { import {
selectTopLevelCommentsForUri, selectTopLevelCommentsForUri,
selectSuperChatsForUri, selectSuperChatsForUri,
selectSuperChatTotalAmountForUri,
selectPinnedCommentsForUri, selectPinnedCommentsForUri,
} from 'redux/selectors/comments'; } from 'redux/selectors/comments';
import { selectThemePath } from 'redux/selectors/settings'; import { selectThemePath } from 'redux/selectors/settings';
@ -14,19 +13,21 @@ import LivestreamChatLayout from './view';
const select = (state, props) => { const select = (state, props) => {
const { uri } = props; const { uri } = props;
const claim = selectClaimForUri(state, uri);
return { return {
claim: selectClaimForUri(state, uri), claimId: claim && claim.claim_id,
comments: selectTopLevelCommentsForUri(state, uri, MAX_LIVESTREAM_COMMENTS), comments: selectTopLevelCommentsForUri(state, uri, MAX_LIVESTREAM_COMMENTS),
pinnedComments: selectPinnedCommentsForUri(state, uri), pinnedComments: selectPinnedCommentsForUri(state, uri),
superChats: selectSuperChatsForUri(state, uri), superChats: selectSuperChatsForUri(state, uri),
superChatsTotalAmount: selectSuperChatTotalAmountForUri(state, uri),
theme: selectThemePath(state), theme: selectThemePath(state),
}; };
}; };
export default connect(select, { const perform = {
doCommentList, doCommentList,
doSuperChatList, doSuperChatList,
doResolveUris, doResolveUris,
})(LivestreamChatLayout); };
export default connect(select, perform)(LivestreamChatLayout);

View file

@ -1,8 +1,6 @@
// @flow // @flow
import 'scss/component/_livestream-chat.scss'; import 'scss/component/_livestream-chat.scss';
// $FlowFixMe
import { Global } from '@emotion/react';
// $FlowFixMe // $FlowFixMe
import { grey } from '@mui/material/colors'; import { grey } from '@mui/material/colors';
@ -17,39 +15,38 @@ import LivestreamComments from 'component/livestreamComments';
import LivestreamSuperchats from './livestream-superchats'; import LivestreamSuperchats from './livestream-superchats';
import LivestreamMenu from './livestream-menu'; import LivestreamMenu from './livestream-menu';
import React from 'react'; import React from 'react';
import Spinner from 'component/spinner';
import Yrbl from 'component/yrbl'; import Yrbl from 'component/yrbl';
import { getTipValues } from 'util/livestream'; import { getTipValues } from 'util/livestream';
import Slide from '@mui/material/Slide'; import Slide from '@mui/material/Slide';
const VIEW_MODES = { export const VIEW_MODES = {
CHAT: 'chat', CHAT: 'chat',
SUPERCHAT: 'sc', SUPERCHAT: 'sc',
}; };
const COMMENT_SCROLL_TIMEOUT = 25; const COMMENT_SCROLL_TIMEOUT = 25;
const LARGE_SUPER_CHAT_LIST_THRESHOLD = 20;
type Props = { type Props = {
claim: ?StreamClaim,
comments: Array<Comment>,
embed?: boolean, embed?: boolean,
isPopoutWindow?: boolean, isPopoutWindow?: boolean,
pinnedComments: Array<Comment>,
superChats: Array<Comment>,
uri: string, uri: string,
hideHeader?: boolean, hideHeader?: boolean,
superchatsHidden?: boolean, superchatsHidden?: boolean,
customViewMode?: string, customViewMode?: string,
theme: string,
setCustomViewMode?: (any) => void, setCustomViewMode?: (any) => void,
doCommentList: (string, string, number, number) => void, // redux
doResolveUris: (Array<string>, boolean) => void, claimId?: string,
doSuperChatList: (string) => void, comments: Array<Comment>,
pinnedComments: Array<Comment>,
superChats: Array<Comment>,
theme: string,
doCommentList: (uri: string, parentId: string, page: number, pageSize: number) => void,
doResolveUris: (uris: Array<string>, cache: boolean) => void,
doSuperChatList: (uri: string) => void,
}; };
export default function LivestreamChatLayout(props: Props) { export default function LivestreamChatLayout(props: Props) {
const { const {
claim, claimId,
comments: commentsByChronologicalOrder, comments: commentsByChronologicalOrder,
embed, embed,
isPopoutWindow, isPopoutWindow,
@ -83,14 +80,21 @@ export default function LivestreamChatLayout(props: Props) {
const [didInitialScroll, setDidInitialScroll] = React.useState(false); const [didInitialScroll, setDidInitialScroll] = React.useState(false);
const [minScrollHeight, setMinScrollHeight] = React.useState(0); const [minScrollHeight, setMinScrollHeight] = React.useState(0);
const [keyboardOpened, setKeyboardOpened] = React.useState(false); const [keyboardOpened, setKeyboardOpened] = React.useState(false);
const [superchatsAmount, setSuperchatsAmount] = React.useState(false);
const [chatElement, setChatElement] = React.useState();
const claimId = claim && claim.claim_id; const superChatsByChronologicalOrder =
const commentsToDisplay = viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByAmount; superChatsByAmount && superChatsByAmount.sort((a, b) => b.timestamp - a.timestamp);
const commentsToDisplay =
viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByChronologicalOrder;
const commentsLength = commentsToDisplay && commentsToDisplay.length; const commentsLength = commentsToDisplay && commentsToDisplay.length;
const pinnedComment = pinnedComments.length > 0 ? pinnedComments[0] : null; const pinnedComment = pinnedComments.length > 0 ? pinnedComments[0] : null;
const { superChatsChannelUrls, superChatsFiatAmount, superChatsLBCAmount } = getTipValues(superChatsByAmount); const { superChatsChannelUrls, superChatsFiatAmount, superChatsLBCAmount } = getTipValues(
superChatsByChronologicalOrder
);
const scrolledPastRecent = Boolean( const scrolledPastRecent = Boolean(
viewMode === VIEW_MODES.CHAT && !isMobile ? scrollPos < 0 : scrollPos < minScrollHeight (viewMode !== VIEW_MODES.SUPERCHAT || !resolvingSuperChats) &&
(!isMobile ? scrollPos < 0 : scrollPos < minScrollHeight)
); );
const restoreScrollPos = React.useCallback(() => { const restoreScrollPos = React.useCallback(() => {
@ -107,16 +111,26 @@ export default function LivestreamChatLayout(props: Props) {
} }
}, [discussionElement, isMobile, lastCommentElem, minScrollHeight]); }, [discussionElement, isMobile, lastCommentElem, minScrollHeight]);
const commentsRef = React.createRef(); function toggleClick(toggleMode: string) {
if (toggleMode === VIEW_MODES.SUPERCHAT) {
toggleSuperChat();
} else {
setViewMode(VIEW_MODES.CHAT);
}
if (discussionElement) {
discussionElement.scrollTop = 0;
}
}
function toggleSuperChat() { function toggleSuperChat() {
if (superChatsChannelUrls && superChatsChannelUrls.length > 0) { const hasNewSuperchats = !superchatsAmount || superChatsChannelUrls.length !== superchatsAmount;
doResolveUris(superChatsChannelUrls, true);
if (superChatsByAmount.length > LARGE_SUPER_CHAT_LIST_THRESHOLD) { if (superChatsChannelUrls && hasNewSuperchats) {
setResolvingSuperChats(true); setSuperchatsAmount(superChatsChannelUrls.length);
} doResolveUris(superChatsChannelUrls, false);
} }
setViewMode(VIEW_MODES.SUPERCHAT); setViewMode(VIEW_MODES.SUPERCHAT);
if (setCustomViewMode) setCustomViewMode(VIEW_MODES.SUPERCHAT); if (setCustomViewMode) setCustomViewMode(VIEW_MODES.SUPERCHAT);
} }
@ -135,17 +149,21 @@ export default function LivestreamChatLayout(props: Props) {
}, [claimId, uri, doCommentList, doSuperChatList]); }, [claimId, uri, doCommentList, doSuperChatList]);
React.useEffect(() => { React.useEffect(() => {
if (isMobile && viewMode === VIEW_MODES.CHAT && !didInitialScroll) { if (isMobile && !didInitialScroll) {
restoreScrollPos(); restoreScrollPos();
setDidInitialScroll(true); setDidInitialScroll(true);
} }
}, [didInitialScroll, isMobile, restoreScrollPos, viewMode]); }, [didInitialScroll, isMobile, restoreScrollPos, viewMode]);
React.useEffect(() => {
if (discussionElement && !openedPopoutWindow) setChatElement(discussionElement);
}, [discussionElement, openedPopoutWindow]);
// Register scroll handler (TODO: Should throttle/debounce) // Register scroll handler (TODO: Should throttle/debounce)
React.useEffect(() => { React.useEffect(() => {
function handleScroll() { function handleScroll() {
if (discussionElement) { if (chatElement) {
const scrollTop = discussionElement.scrollTop; const scrollTop = chatElement.scrollTop;
if (scrollTop !== scrollPos) { if (scrollTop !== scrollPos) {
setScrollPos(scrollTop); setScrollPos(scrollTop);
@ -153,11 +171,12 @@ export default function LivestreamChatLayout(props: Props) {
} }
} }
if (discussionElement) { if (chatElement) {
discussionElement.addEventListener('scroll', handleScroll); chatElement.addEventListener('scroll', handleScroll);
return () => discussionElement.removeEventListener('scroll', handleScroll);
return () => chatElement.removeEventListener('scroll', handleScroll);
} }
}, [discussionElement, scrollPos]); }, [chatElement, scrollPos]);
// Retain scrollPos=0 when receiving new messages. // Retain scrollPos=0 when receiving new messages.
React.useEffect(() => { React.useEffect(() => {
@ -193,45 +212,7 @@ export default function LivestreamChatLayout(props: Props) {
} }
}, [keyboardOpened, restoreScrollPos]); }, [keyboardOpened, restoreScrollPos]);
// Stop spinner for resolving superchats if (!claimId) return null;
React.useEffect(() => {
if (resolvingSuperChats) {
// The real solution to the sluggishness is to fix the claim store/selectors
// and to paginate the long superchat list. This serves as a band-aid,
// showing a spinner while we batch-resolve. The duration is just a rough
// estimate -- the lag will handle the remaining time.
const timer = setTimeout(() => {
setResolvingSuperChats(false);
// Scroll to the top:
if (discussionElement) {
const divHeight = discussionElement.scrollHeight;
discussionElement.scrollTop = divHeight * -1;
}
}, 1000);
return () => clearTimeout(timer);
}
}, [discussionElement, resolvingSuperChats]);
if (!claim) return null;
const chatContentToggle = (toggleMode: string, label: any) => (
<Button
className={classnames('button-toggle', { 'button-toggle--active': viewMode === toggleMode })}
label={label}
onClick={() => {
if (toggleMode === VIEW_MODES.SUPERCHAT) {
toggleSuperChat();
} else {
setViewMode(VIEW_MODES.CHAT);
}
if (discussionElement) {
const divHeight = discussionElement.scrollHeight;
discussionElement.scrollTop = toggleMode === VIEW_MODES.CHAT ? divHeight : divHeight * -1;
}
}}
/>
);
if (openedPopoutWindow || chatHidden) { if (openedPopoutWindow || chatHidden) {
return ( return (
@ -266,6 +247,8 @@ export default function LivestreamChatLayout(props: Props) {
); );
} }
const toggleProps = { viewMode, onClick: (toggleMode) => toggleClick(toggleMode) };
return ( return (
<div className={classnames('card livestream__chat', { 'livestream__chat--popout': isPopoutWindow })}> <div className={classnames('card livestream__chat', { 'livestream__chat--popout': isPopoutWindow })}>
{!hideHeader && ( {!hideHeader && (
@ -281,37 +264,40 @@ export default function LivestreamChatLayout(props: Props) {
/> />
</div> </div>
{superChatsByAmount && ( {superChatsByChronologicalOrder && (
<div className="recommended-content__toggles"> <div className="recommended-content__toggles">
{/* the superchats in chronological order button */} {/* the superchats in chronological order button */}
{chatContentToggle(VIEW_MODES.CHAT, __('Chat'))} <ChatContentToggle {...toggleProps} toggleMode={VIEW_MODES.CHAT} label={__('Chat')} />
{/* the button to show superchats listed by most to least support amount */} {/* the button to show superchats listed by most to least support amount */}
{chatContentToggle( <ChatContentToggle
VIEW_MODES.SUPERCHAT, {...toggleProps}
toggleMode={VIEW_MODES.SUPERCHAT}
label={
<> <>
<CreditAmount amount={superChatsLBCAmount || 0} size={8} /> /&nbsp; <CreditAmount amount={superChatsLBCAmount || 0} size={8} /> /&nbsp;
<CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')} <CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')}
</> </>
)} }
/>
</div> </div>
)} )}
</div> </div>
)} )}
<div ref={commentsRef} className="livestreamComments__wrapper"> <div className="livestreamComments__wrapper">
<div <div
className={classnames('livestream-comments__top-actions', { className={classnames('livestream-comments__top-actions', {
'livestream-comments__top-actions--mobile': isMobile, 'livestream-comments__top-actions--mobile': isMobile,
})} })}
> >
{isMobile && ((pinnedComment && showPinned) || (superChatsByAmount && !superchatsHidden)) && ( {isMobile && ((pinnedComment && showPinned) || (superChatsByChronologicalOrder && !superchatsHidden)) && (
<MobileDrawerTopGradient theme={theme} /> <MobileDrawerTopGradient theme={theme} />
)} )}
{viewMode === VIEW_MODES.CHAT && superChatsByAmount && ( {viewMode === VIEW_MODES.CHAT && superChatsByChronologicalOrder && (
<LivestreamSuperchats <LivestreamSuperchats
superChats={superChatsByAmount} superChats={superChatsByChronologicalOrder}
toggleSuperChat={toggleSuperChat} toggleSuperChat={toggleSuperChat}
superchatsHidden={superchatsHidden} superchatsHidden={superchatsHidden}
isMobile={isMobile} isMobile={isMobile}
@ -329,6 +315,7 @@ export default function LivestreamChatLayout(props: Props) {
uri={uri} uri={uri}
handleDismissPin={() => setShowPinned(false)} handleDismissPin={() => setShowPinned(false)}
isMobile isMobile
setResolvingSuperChats={setResolvingSuperChats}
/> />
</div> </div>
</Slide> </Slide>
@ -349,20 +336,15 @@ export default function LivestreamChatLayout(props: Props) {
))} ))}
</div> </div>
{viewMode === VIEW_MODES.SUPERCHAT && resolvingSuperChats ? (
<div className="main--empty">
<Spinner />
</div>
) : (
<LivestreamComments <LivestreamComments
uri={uri} uri={uri}
commentsToDisplay={commentsToDisplay} viewMode={viewMode}
comments={commentsToDisplay}
isMobile={isMobile} isMobile={isMobile}
restoreScrollPos={!scrolledPastRecent && isMobile && restoreScrollPos} restoreScrollPos={!scrolledPastRecent && isMobile && restoreScrollPos}
/> />
)}
{scrolledPastRecent ? ( {scrolledPastRecent && (
<Button <Button
button="secondary" button="secondary"
className="livestream-comments__scroll-to-recent" className="livestream-comments__scroll-to-recent"
@ -370,7 +352,7 @@ export default function LivestreamChatLayout(props: Props) {
onClick={restoreScrollPos} onClick={restoreScrollPos}
iconRight={ICONS.DOWN} iconRight={ICONS.DOWN}
/> />
) : null} )}
<div className="livestream__comment-create"> <div className="livestream__comment-create">
<CommentCreate <CommentCreate
@ -387,6 +369,25 @@ export default function LivestreamChatLayout(props: Props) {
); );
} }
type ToggleProps = {
viewMode: string,
toggleMode: string,
label: string | any,
onClick: (string) => void,
};
const ChatContentToggle = (props: ToggleProps) => {
const { viewMode, toggleMode, label, onClick } = props;
return (
<Button
className={classnames('button-toggle', { 'button-toggle--active': viewMode === toggleMode })}
label={label}
onClick={() => onClick(toggleMode)}
/>
);
};
type GradientProps = { type GradientProps = {
theme: string, theme: string,
}; };
@ -394,20 +395,12 @@ type GradientProps = {
const MobileDrawerTopGradient = (gradientProps: GradientProps) => { const MobileDrawerTopGradient = (gradientProps: GradientProps) => {
const { theme } = gradientProps; const { theme } = gradientProps;
const DrawerGlobalStyles = () => ( return (
<Global <div
styles={{ style={{
'.livestream__top-gradient::after': {
background: `linear-gradient(180deg, ${theme === 'light' ? grey[300] : grey[900]} 0, transparent 65%)`, background: `linear-gradient(180deg, ${theme === 'light' ? grey[300] : grey[900]} 0, transparent 65%)`,
},
}} }}
className="livestream__top-gradient"
/> />
); );
return (
<>
<DrawerGlobalStyles />
<div className="livestream__top-gradient" />
</>
);
}; };

View file

@ -1,9 +1,20 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectIsFetchingComments } from 'redux/selectors/comments'; import { selectIsFetchingComments } from 'redux/selectors/comments';
import { selectIsUriResolving } from 'redux/selectors/claims';
import { VIEW_MODES } from 'ui/component/livestreamChatLayout/view';
import LivestreamComments from './view'; import LivestreamComments from './view';
const select = (state) => ({ const select = (state, props) => {
const { comments, viewMode } = props;
return {
fetchingComments: selectIsFetchingComments(state), fetchingComments: selectIsFetchingComments(state),
}); resolvingSuperchats: Boolean(
viewMode === VIEW_MODES.SUPERCHAT &&
comments &&
comments.some(({ channel_url }) => selectIsUriResolving(state, channel_url))
),
};
};
export default connect(select)(LivestreamComments); export default connect(select)(LivestreamComments);

View file

@ -1,29 +1,46 @@
// @flow // @flow
import 'scss/component/_livestream-chat.scss'; import 'scss/component/_livestream-chat.scss';
import LivestreamComment from 'component/livestreamComment';
import React from 'react'; import React from 'react';
import LivestreamComment from 'component/livestreamComment';
import Spinner from 'component/spinner';
// 30 sec timestamp refresh timer // 30 sec timestamp refresh timer
const UPDATE_TIMESTAMP_MS = 30 * 1000; const UPDATE_TIMESTAMP_MS = 30 * 1000;
type Props = { type Props = {
commentsToDisplay: Array<Comment>, comments: Array<Comment>,
fetchingComments: boolean,
uri: string, uri: string,
isMobile?: boolean, isMobile?: boolean,
viewMode: string,
restoreScrollPos?: () => void, restoreScrollPos?: () => void,
setResolvingSuperChats?: (boolean) => void,
// redux
fetchingComments: boolean,
resolvingSuperchats: boolean,
}; };
export default function LivestreamComments(props: Props) { export default function LivestreamComments(props: Props) {
const { commentsToDisplay, fetchingComments, uri, isMobile, restoreScrollPos } = props; const {
comments,
uri,
isMobile,
restoreScrollPos,
setResolvingSuperChats,
fetchingComments,
resolvingSuperchats,
} = props;
const [forceUpdate, setForceUpdate] = React.useState(0); const [forceUpdate, setForceUpdate] = React.useState(0);
React.useEffect(() => {
if (setResolvingSuperChats) setResolvingSuperChats(resolvingSuperchats);
}, [resolvingSuperchats, setResolvingSuperChats]);
const now = new Date(); const now = new Date();
const shouldRefreshTimestamp = const shouldRefreshTimestamp =
commentsToDisplay && comments &&
commentsToDisplay.some((comment) => { comments.some((comment) => {
const { timestamp } = comment; const { timestamp } = comment;
const timePosted = timestamp * 1000; const timePosted = timestamp * 1000;
@ -43,19 +60,28 @@ export default function LivestreamComments(props: Props) {
// forceUpdate will re-activate the timer or else it will only refresh once // forceUpdate will re-activate the timer or else it will only refresh once
}, [shouldRefreshTimestamp, forceUpdate]); }, [shouldRefreshTimestamp, forceUpdate]);
if (resolvingSuperchats) {
return (
<div className="main--empty">
<Spinner />
</div>
);
}
/* top to bottom comment display */ /* top to bottom comment display */
if (!fetchingComments && commentsToDisplay && commentsToDisplay.length > 0) { if (!fetchingComments && comments && comments.length > 0) {
const commentProps = { uri, forceUpdate };
return isMobile ? ( return isMobile ? (
<div className="livestream__comments--mobile"> <div className="livestream__comments--mobile">
{commentsToDisplay {comments
.slice(0) .slice(0)
.reverse() .reverse()
.map((comment) => ( .map((comment) => (
<LivestreamComment <LivestreamComment
{...commentProps}
comment={comment} comment={comment}
key={comment.comment_id} key={comment.comment_id}
uri={uri}
forceUpdate={forceUpdate}
isMobile isMobile
restoreScrollPos={restoreScrollPos} restoreScrollPos={restoreScrollPos}
/> />
@ -63,8 +89,8 @@ export default function LivestreamComments(props: Props) {
</div> </div>
) : ( ) : (
<div className="livestream__comments"> <div className="livestream__comments">
{commentsToDisplay.map((comment) => ( {comments.map((comment) => (
<LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} /> <LivestreamComment {...commentProps} comment={comment} key={comment.comment_id} />
))} ))}
</div> </div>
); );