[Live Chat] Break down componets for Page Layout + Add ability to Pop Out chat window + Hide chat option (#681)

* Refactor CommentBadge

* Refactor livestreamComment component

* Refactor and split livestreamComment CSS

* Refactor livestreamComments component

* Refactor and split livestreamComments CSS

* Remove never used spinner

* Refactor livestream Page

* Refactor page component

* Refactor livestreamLayout component

* Break apart livestreamComments into separate sibling components

- This helps separating LivestreamComments to deal with only the comments, and the LivestreamLayout to be used for its own Page as a Popout option, and also for a layered approach for mobile

* Create Popout Chat Page, Add Popout Chat Menu Option

* Add Hide Chat option

* sockety improvements

* Websocket changes

Co-authored-by: Thomas Zarebczan <thomas.zarebczan@gmail.com>
This commit is contained in:
saltrafael 2022-01-14 17:24:16 -03:00 committed by GitHub
parent b810e07053
commit ea9c7a4a27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1365 additions and 1123 deletions

View file

@ -12,7 +12,7 @@ import DateTime from 'component/dateTime';
import Button from 'component/button'; import Button from 'component/button';
import Expandable from 'component/expandable'; import Expandable from 'component/expandable';
import MarkdownPreview from 'component/common/markdown-preview'; import MarkdownPreview from 'component/common/markdown-preview';
import Tooltip from 'component/common/tooltip'; import CommentBadge from 'component/common/comment-badge';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuButton } from '@reach/menu-button'; import { Menu, MenuButton } from '@reach/menu-button';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
@ -249,21 +249,8 @@ function Comment(props: Props) {
<div className="comment__body-container"> <div className="comment__body-container">
<div className="comment__meta"> <div className="comment__meta">
<div className="comment__meta-information"> <div className="comment__meta-information">
{isGlobalMod && ( {isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} />}
<Tooltip title={__('Admin')}> {isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
<span className="comment__badge comment__badge--global-mod">
<Icon icon={ICONS.BADGE_MOD} size={20} />
</span>
</Tooltip>
)}
{isModerator && (
<Tooltip title={__('Moderator')}>
<span className="comment__badge comment__badge--mod">
<Icon icon={ICONS.BADGE_MOD} size={20} />
</span>
</Tooltip>
)}
{!author ? ( {!author ? (
<span className="comment__author">{__('Anonymous')}</span> <span className="comment__author">{__('Anonymous')}</span>

View file

@ -56,6 +56,7 @@ type Props = {
shouldFetchComment: boolean, shouldFetchComment: boolean,
supportDisabled: boolean, supportDisabled: boolean,
uri: string, uri: string,
disableInput?: boolean,
createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>, createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>,
doFetchCreatorSettings: (channelId: string) => Promise<any>, doFetchCreatorSettings: (channelId: string) => Promise<any>,
doToast: ({ message: string }) => void, doToast: ({ message: string }) => void,
@ -84,6 +85,7 @@ export function CommentCreate(props: Props) {
settingsByChannelId, settingsByChannelId,
shouldFetchComment, shouldFetchComment,
supportDisabled, supportDisabled,
disableInput,
createComment, createComment,
doFetchCreatorSettings, doFetchCreatorSettings,
doToast, doToast,
@ -125,7 +127,7 @@ export function CommentCreate(props: Props) {
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
const charCount = commentValue ? commentValue.length : 0; const charCount = commentValue ? commentValue.length : 0;
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length; const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || disableInput;
const channelId = getChannelIdFromClaim(claim); const channelId = getChannelIdFromClaim(claim);
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0; const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0;
@ -255,7 +257,7 @@ export function CommentCreate(props: Props) {
* @param {string} [environment] Optional environment for Stripe (test|live) * @param {string} [environment] Optional environment for Stripe (test|live)
*/ */
function handleCreateComment(txid, payment_intent_id, environment) { function handleCreateComment(txid, payment_intent_id, environment) {
if (isSubmitting) return; if (isSubmitting || disableInput) return;
setShowEmotes(false); setShowEmotes(false);
setSubmitting(true); setSubmitting(true);
@ -468,7 +470,7 @@ export function CommentCreate(props: Props) {
autoFocus={isReply} autoFocus={isReply}
charCount={charCount} charCount={charCount}
className={isReply ? 'create__reply' : 'create__comment'} className={isReply ? 'create__reply' : 'create__comment'}
disabled={isFetchingChannels} disabled={isFetchingChannels || disableInput}
isLivestream={isLivestream} isLivestream={isLivestream}
label={ label={
<div className="commentCreate__labelWrapper"> <div className="commentCreate__labelWrapper">
@ -532,7 +534,7 @@ export function CommentCreate(props: Props) {
<Button <Button
button="primary" button="primary"
label={__('Send')} label={__('Send')}
disabled={isSupportComment && (tipError || disableReviewButton)} disabled={(isSupportComment && (tipError || disableReviewButton)) || disableInput}
onClick={() => { onClick={() => {
if (isSupportComment) { if (isSupportComment) {
handleSupportComment(); handleSupportComment();

View file

@ -0,0 +1,35 @@
// @flow
import 'scss/component/_comment-badge.scss';
import classnames from 'classnames';
import Icon from 'component/common/icon';
import React from 'react';
import Tooltip from 'component/common/tooltip';
const LABEL_TYPES = {
ADMIN: 'Admin',
MOD: 'Moderator',
};
type Props = {
icon: string,
label: string,
size?: number,
};
export default function CommentBadge(props: Props) {
const { icon, label, size = 20 } = props;
return (
<Tooltip title={label} placement="top">
<span
className={classnames('comment__badge', {
'comment__badge--globalMod': label === LABEL_TYPES.ADMIN,
'comment__badge--mod': label === LABEL_TYPES.MOD,
})}
>
<Icon icon={icon} size={size} />
</span>
</Tooltip>
);
}

View file

@ -1,4 +1,6 @@
// @flow // @flow
import 'scss/component/_superchat.scss';
import { formatCredits, formatFullPrice } from 'util/format-credits'; import { formatCredits, formatFullPrice } from 'util/format-credits';
import classnames from 'classnames'; import classnames from 'classnames';
import LbcSymbol from 'component/common/lbc-symbol'; import LbcSymbol from 'component/common/lbc-symbol';
@ -97,10 +99,7 @@ class CreditAmount extends React.PureComponent<Props> {
return ( return (
<span <span
title={amount && !hideTitle ? formatFullPrice(amount, 2) : ''} title={amount && !hideTitle ? formatFullPrice(amount, 2) : ''}
className={classnames(className, { className={classnames(className, { superChat: superChat, 'superChat--light': superChatLight })}
'super-chat': superChat,
'super-chat--light': superChatLight,
})}
> >
{customAmounts {customAmounts
? Object.values(customAmounts).map((amount, index) => ( ? Object.values(customAmounts).map((amount, index) => (

View file

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
import { doResolveUris } from 'redux/actions/claims';
import { selectClaimForUri } from 'redux/selectors/claims';
import { doCommentList, doSuperChatList } from 'redux/actions/comments';
import {
selectTopLevelCommentsForUri,
selectSuperChatsForUri,
selectSuperChatTotalAmountForUri,
selectPinnedCommentsForUri,
} from 'redux/selectors/comments';
import LivestreamChatLayout from './view';
const select = (state, props) => {
const { uri } = props;
return {
claim: selectClaimForUri(state, uri),
comments: selectTopLevelCommentsForUri(state, uri, MAX_LIVESTREAM_COMMENTS),
pinnedComments: selectPinnedCommentsForUri(state, uri),
superChats: selectSuperChatsForUri(state, uri),
superChatsTotalAmount: selectSuperChatTotalAmountForUri(state, uri),
};
};
export default connect(select, {
doCommentList,
doSuperChatList,
doResolveUris,
})(LivestreamChatLayout);

View file

@ -0,0 +1,82 @@
// @flow
import 'scss/component/_livestream-chat.scss';
import { parseSticker, getStickerUrl } from 'util/comments';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import ChannelThumbnail from 'component/channelThumbnail';
import classnames from 'classnames';
import CreditAmount from 'component/common/credit-amount';
import OptimizedImage from 'component/optimizedImage';
import React from 'react';
import Tooltip from 'component/common/tooltip';
import UriIndicator from 'component/uriIndicator';
type Props = {
superChats: Array<Comment>,
toggleSuperChat: () => void,
};
export default function LivestreamSuperchats(props: Props) {
const { superChats: superChatsByAmount, toggleSuperChat } = props;
const superChatTopTen = React.useMemo(() => {
return superChatsByAmount ? superChatsByAmount.slice(0, 10) : superChatsByAmount;
}, [superChatsByAmount]);
const stickerSuperChats = superChatsByAmount && superChatsByAmount.filter(({ comment }) => !!parseSticker(comment));
const showMore = superChatTopTen && superChatsByAmount && superChatTopTen.length < superChatsByAmount.length;
return !superChatTopTen ? null : (
<div className="livestreamSuperchats__wrapper">
<div className="livestreamSuperchats__inner">
{superChatTopTen.map((superChat: Comment) => {
const { comment, comment_id, channel_url, support_amount, is_fiat } = superChat;
const isSticker = stickerSuperChats && stickerSuperChats.includes(superChat);
const stickerImg = <OptimizedImage src={getStickerUrl(comment)} waitLoad loading="lazy" />;
return (
<Tooltip title={isSticker ? stickerImg : comment} key={comment_id}>
<div className="livestream__superchat">
<ChannelThumbnail uri={channel_url} xsmall />
<div
className={classnames('livestreamSuperchat__info', {
'livestreamSuperchat__info--sticker': isSticker,
'livestreamSuperchat__info--notSticker': stickerSuperChats && !isSticker,
})}
>
<div className="livestreamSuperchat__info--user">
<UriIndicator uri={channel_url} link />
<CreditAmount
hideTitle
size={10}
className="livestreamSuperchat__amount--large"
amount={support_amount}
isFiat={is_fiat}
/>
</div>
{isSticker && <div className="livestreamSuperchat__info--image">{stickerImg}</div>}
</div>
</div>
</Tooltip>
);
})}
{showMore && (
<Button
title={__('Show More...')}
label={__('Show More')}
button="inverse"
className="close-button"
onClick={toggleSuperChat}
iconRight={ICONS.MORE}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,356 @@
// @flow
import 'scss/component/_livestream-chat.scss';
import { formatLbryUrlForWeb } from 'util/url';
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import { useHistory } from 'react-router-dom';
import { useIsMobile } from 'effects/use-screensize';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import classnames from 'classnames';
import CommentCreate from 'component/commentCreate';
import CreditAmount from 'component/common/credit-amount';
import Icon from 'component/common/icon';
import LivestreamComment from 'component/livestreamComment';
import LivestreamComments from 'component/livestreamComments';
import LivestreamSuperchats from './livestream-superchats';
import React from 'react';
import Spinner from 'component/spinner';
import Yrbl from 'component/yrbl';
const IS_TIMESTAMP_VISIBLE = () =>
// $FlowFixMe
document.documentElement.style.getPropertyValue('--live-timestamp-opacity') === '0.5';
const TOGGLE_TIMESTAMP_OPACITY = () =>
// $FlowFixMe
document.documentElement.style.setProperty('--live-timestamp-opacity', IS_TIMESTAMP_VISIBLE() ? '0' : '0.5');
const VIEW_MODES = {
CHAT: 'chat',
SUPERCHAT: 'sc',
};
const COMMENT_SCROLL_TIMEOUT = 25;
const LARGE_SUPER_CHAT_LIST_THRESHOLD = 20;
type Props = {
claim: ?StreamClaim,
comments: Array<Comment>,
embed?: boolean,
isPopoutWindow?: boolean,
pinnedComments: Array<Comment>,
superChats: Array<Comment>,
uri: string,
doCommentList: (string, string, number, number) => void,
doResolveUris: (Array<string>, boolean) => void,
doSuperChatList: (string) => void,
};
export default function LivestreamChatLayout(props: Props) {
const {
claim,
comments: commentsByChronologicalOrder,
embed,
isPopoutWindow,
pinnedComments,
superChats: superChatsByAmount,
uri,
doCommentList,
doResolveUris,
doSuperChatList,
} = props;
const {
location: { pathname },
} = useHistory();
const isMobile = useIsMobile();
const discussionElement = document.querySelector('.livestream__comments');
const restoreScrollPos = React.useCallback(() => {
if (discussionElement) discussionElement.scrollTop = 0;
}, [discussionElement]);
const commentsRef = React.createRef();
const [viewMode, setViewMode] = React.useState(VIEW_MODES.CHAT);
const [scrollPos, setScrollPos] = React.useState(0);
const [showPinned, setShowPinned] = React.useState(true);
const [resolvingSuperChats, setResolvingSuperChats] = React.useState(false);
const [mention, setMention] = React.useState();
const [openedPopoutWindow, setPopoutWindow] = React.useState(undefined);
const [chatHidden, setChatHidden] = React.useState(false);
const quickMention =
mention && formatLbryUrlForWeb(mention).substring(1, formatLbryUrlForWeb(mention).indexOf(':') + 3);
const claimId = claim && claim.claim_id;
const commentsToDisplay = viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByAmount;
const commentsLength = commentsToDisplay && commentsToDisplay.length;
const pinnedComment = pinnedComments.length > 0 ? pinnedComments[0] : null;
let superChatsChannelUrls = [];
let superChatsFiatAmount = 0;
let superChatsLBCAmount = 0;
if (superChatsByAmount) {
superChatsByAmount.forEach((superChat) => {
const { is_fiat: isFiat, support_amount: tipAmount, channel_url: uri } = superChat;
if (isFiat) {
superChatsFiatAmount = superChatsFiatAmount + tipAmount;
} else {
superChatsLBCAmount = superChatsLBCAmount + tipAmount;
}
superChatsChannelUrls.push(uri || '0');
});
}
function toggleSuperChat() {
if (superChatsChannelUrls && superChatsChannelUrls.length > 0) {
doResolveUris(superChatsChannelUrls, true);
if (superChatsByAmount.length > LARGE_SUPER_CHAT_LIST_THRESHOLD) {
setResolvingSuperChats(true);
}
}
setViewMode(VIEW_MODES.SUPERCHAT);
}
function handlePopout() {
const newWindow = window.open('/$/popout' + pathname, 'Popout Chat', 'height=700,width=400');
// Add function to newWindow when closed (either manually or from button component)
newWindow.onbeforeunload = () => setPopoutWindow(undefined);
if (window.focus) newWindow.focus();
setPopoutWindow(newWindow);
}
React.useEffect(() => {
if (claimId) {
doCommentList(uri, '', 1, 75);
doSuperChatList(uri);
}
}, [claimId, uri, doCommentList, doSuperChatList]);
// Register scroll handler (TODO: Should throttle/debounce)
React.useEffect(() => {
function handleScroll() {
if (discussionElement) {
const scrollTop = discussionElement.scrollTop;
if (scrollTop !== scrollPos) {
setScrollPos(scrollTop);
}
}
}
if (discussionElement) {
discussionElement.addEventListener('scroll', handleScroll);
return () => discussionElement.removeEventListener('scroll', handleScroll);
}
}, [discussionElement, scrollPos, viewMode]);
// Retain scrollPos=0 when receiving new messages.
React.useEffect(() => {
if (discussionElement && commentsLength > 0) {
// Only update comment scroll if the user hasn't scrolled up to view old comments
if (scrollPos >= 0) {
// +ve scrollPos: not scrolled (Usually, there'll be a few pixels beyond 0).
// -ve scrollPos: user scrolled.
const timer = setTimeout(() => {
// Use a timer here to ensure we reset after the new comment has been rendered.
discussionElement.scrollTop = 0;
}, COMMENT_SCROLL_TIMEOUT);
return () => clearTimeout(timer);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commentsLength]); // (Just respond to 'commentsLength' updates and nothing else)
// Stop spinner for resolving superchats
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) {
return (
<div className="card livestream__chat">
<div className="card__header--between livestreamDiscussion__header">
<div className="card__title-section--small livestreamDiscussion__title">{__('Live Chat')}</div>
</div>
<div className="livestreamComments__wrapper">
<div className="main--empty">
<Yrbl
title={__('Chat Hidden')}
actions={
<div className="section__actions">
{openedPopoutWindow && (
<Button button="secondary" label={__('Close Popout')} onClick={() => openedPopoutWindow.close()} />
)}
{chatHidden && (
<Button button="secondary" label={__('Show Chat')} onClick={() => setChatHidden(false)} />
)}
</div>
}
/>
</div>
<div className="livestream__commentCreate">
<CommentCreate isLivestream bottom uri={uri} disableInput />
</div>
</div>
</div>
);
}
return (
<div className={classnames('card livestream__chat', { 'livestream__chat--popout': isPopoutWindow })}>
<div className="card__header--between livestreamDiscussion__header">
<div className="card__title-section--small livestreamDiscussion__title">
{__('Live Chat')}
<Menu>
<MenuButton className="menu__button">
<Icon size={18} icon={ICONS.SETTINGS} />
</MenuButton>
<MenuList className="menu__list">
<MenuItem className="comment__menu-option" onSelect={TOGGLE_TIMESTAMP_OPACITY}>
<span className="menu__link">
<Icon aria-hidden icon={ICONS.TIME} />
{__('Toggle Timestamps')}
</span>
</MenuItem>
{!isPopoutWindow && !isMobile && (
<>
<MenuItem className="comment__menu-option" onSelect={handlePopout}>
<span className="menu__link">
<Icon aria-hidden icon={ICONS.EXTERNAL} />
{__('Popout Chat')}
</span>
</MenuItem>
<MenuItem className="comment__menu-option" onSelect={() => setChatHidden(true)}>
<span className="menu__link">
<Icon aria-hidden icon={ICONS.EYE} />
{__('Hide Chat')}
</span>
</MenuItem>
</>
)}
</MenuList>
</Menu>
</div>
{superChatsByAmount && (
<div className="recommended-content__toggles">
{/* the superchats in chronological order button */}
{chatContentToggle(VIEW_MODES.CHAT, __('Chat'))}
{/* the button to show superchats listed by most to least support amount */}
{chatContentToggle(
VIEW_MODES.SUPERCHAT,
<>
<CreditAmount amount={superChatsLBCAmount || 0} size={8} /> /
<CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')}
</>
)}
</div>
)}
</div>
<div ref={commentsRef} className="livestreamComments__wrapper">
{viewMode === VIEW_MODES.CHAT && superChatsByAmount && (
<LivestreamSuperchats superChats={superChatsByAmount} toggleSuperChat={toggleSuperChat} />
)}
{pinnedComment && showPinned && viewMode === VIEW_MODES.CHAT && (
<div className="livestreamPinned__wrapper">
<LivestreamComment
comment={pinnedComment}
key={pinnedComment.comment_id}
uri={uri}
pushMention={setMention}
/>
<Button
title={__('Dismiss pinned comment')}
button="inverse"
className="close-button"
onClick={() => setShowPinned(false)}
icon={ICONS.REMOVE}
/>
</div>
)}
{viewMode === VIEW_MODES.SUPERCHAT && resolvingSuperChats ? (
<div className="main--empty">
<Spinner />
</div>
) : (
<LivestreamComments uri={uri} commentsToDisplay={commentsToDisplay} pushMention={setMention} />
)}
{scrollPos < 0 && (
<Button
button="secondary"
className="livestreamComments__scrollToRecent"
label={viewMode === VIEW_MODES.CHAT ? __('Recent Comments') : __('Recent Tips')}
onClick={restoreScrollPos}
iconRight={ICONS.DOWN}
/>
)}
<div className="livestream__commentCreate">
<CommentCreate
isLivestream
bottom
embed={embed}
uri={uri}
onDoneReplying={restoreScrollPos}
pushedMention={quickMention}
setPushedMention={setMention}
/>
</div>
</div>
</div>
);
}

View file

@ -1,20 +1,22 @@
// @flow // @flow
import * as ICONS from 'constants/icons'; import 'scss/component/_livestream-comment.scss';
import React from 'react';
import { parseURI } from 'util/lbryURI'; import { getStickerUrl } from 'util/comments';
import Empty from 'component/common/empty';
import MarkdownPreview from 'component/common/markdown-preview';
import Tooltip from 'component/common/tooltip';
import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuButton } from '@reach/menu-button'; import { Menu, MenuButton } from '@reach/menu-button';
import Icon from 'component/common/icon'; import { parseURI } from 'util/lbryURI';
import classnames from 'classnames'; import * as ICONS from 'constants/icons';
import CommentMenuList from 'component/commentMenuList';
import Button from 'component/button'; import Button from 'component/button';
import ChannelThumbnail from 'component/channelThumbnail';
import classnames from 'classnames';
import CommentBadge from 'component/common/comment-badge';
import CommentMenuList from 'component/commentMenuList';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import DateTime from 'component/dateTime'; import DateTime from 'component/dateTime';
import Empty from 'component/common/empty';
import Icon from 'component/common/icon';
import MarkdownPreview from 'component/common/markdown-preview';
import OptimizedImage from 'component/optimizedImage'; import OptimizedImage from 'component/optimizedImage';
import { parseSticker } from 'util/comments'; import React from 'react';
type Props = { type Props = {
comment: Comment, comment: Comment,
@ -22,21 +24,34 @@ type Props = {
uri: string, uri: string,
// --- redux: // --- redux:
claim: StreamClaim, claim: StreamClaim,
stakedLevel: number,
myChannelIds: ?Array<string>, myChannelIds: ?Array<string>,
stakedLevel: number,
}; };
function LivestreamComment(props: Props) { export default function LivestreamComment(props: Props) {
const { comment, forceUpdate, uri, claim, stakedLevel, myChannelIds } = props; const { comment, forceUpdate, uri, claim, myChannelIds, stakedLevel } = props;
const { channel_url: authorUri, comment: message, support_amount: supportAmount, timestamp } = comment;
const {
channel_url: authorUri,
comment_id: commentId,
comment: message,
is_fiat: isFiat,
is_global_mod: isGlobalMod,
is_moderator: isModerator,
is_pinned: isPinned,
removed,
support_amount: supportAmount,
timestamp,
} = comment;
const [hasUserMention, setUserMention] = React.useState(false); const [hasUserMention, setUserMention] = React.useState(false);
const commentIsMine = comment.channel_id && isMyComment(comment.channel_id);
const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri; const isStreamer = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
const { claimName } = parseURI(authorUri || ''); const { claimName } = parseURI(authorUri || '');
const stickerFromMessage = parseSticker(message); const stickerUrlFromMessage = getStickerUrl(message);
const isSticker = Boolean(stickerUrlFromMessage);
const timePosted = timestamp * 1000; const timePosted = timestamp * 1000;
const commentIsMine = comment.channel_id && isMyComment(comment.channel_id);
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine // todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
function isMyComment(channelId: string) { function isMyComment(channelId: string) {
@ -45,62 +60,36 @@ function LivestreamComment(props: Props) {
return ( return (
<li <li
className={classnames('livestream-comment', { className={classnames('livestream__comment', {
'livestream-comment--superchat': supportAmount > 0, 'livestream__comment--superchat': supportAmount > 0,
'livestream-comment--sticker': Boolean(stickerFromMessage), 'livestream__comment--sticker': isSticker,
'livestream-comment--mentioned': hasUserMention, 'livestream__comment--mentioned': hasUserMention,
})} })}
> >
{supportAmount > 0 && ( {supportAmount > 0 && (
<div className="super-chat livestream-superchat__banner"> <div className="livestreamComment__superchatBanner">
<div className="livestream-superchat__banner-corner" /> <div className="livestreamComment__superchatBanner--corner" />
<CreditAmount <CreditAmount isFiat={isFiat} amount={supportAmount} superChat />
isFiat={comment.is_fiat}
amount={supportAmount}
superChat
className="livestream-superchat__amount"
/>
</div> </div>
)} )}
<div className="livestream-comment__body"> <div className="livestreamComment__body">
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />} {supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />}
<div className="livestream-comment__info">
{comment.is_global_mod && (
<Tooltip title={__('Admin')}>
<span className="comment__badge comment__badge--global-mod">
<Icon icon={ICONS.BADGE_MOD} size={16} />
</span>
</Tooltip>
)}
{comment.is_moderator && ( <div className="livestreamComment__info">
<Tooltip title={__('Moderator')}> {isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} size={16} />}
<span className="comment__badge comment__badge--mod"> {isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} size={16} />}
<Icon icon={ICONS.BADGE_MOD} size={16} /> {isStreamer && <CommentBadge label={__('Streamer')} icon={ICONS.BADGE_STREAMER} size={16} />}
</span>
</Tooltip>
)}
{commentByOwnerOfContent && (
<Tooltip title={__('Streamer')}>
<span className="comment__badge">
<Icon icon={ICONS.BADGE_STREAMER} size={16} />
</span>
</Tooltip>
)}
<Button <Button
className={classnames('button--uri-indicator comment__author', { className={classnames('button--uri-indicator comment__author', { 'comment__author--creator': isStreamer })}
'comment__author--creator': commentByOwnerOfContent,
})}
target="_blank" target="_blank"
navigate={authorUri} navigate={authorUri}
> >
{claimName} {claimName}
</Button> </Button>
{comment.is_pinned && ( {isPinned && (
<span className="comment__pin"> <span className="comment__pin">
<Icon icon={ICONS.PIN} size={14} /> <Icon icon={ICONS.PIN} size={14} />
{__('Pinned')} {__('Pinned')}
@ -110,16 +99,15 @@ function LivestreamComment(props: Props) {
{/* Use key to force timestamp update */} {/* Use key to force timestamp update */}
<DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds /> <DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds />
{comment.removed ? ( {isSticker ? (
<div className="livestream-comment__text">
<Empty text={__('[Removed]')} />
</div>
) : stickerFromMessage ? (
<div className="sticker__comment"> <div className="sticker__comment">
<OptimizedImage src={stickerFromMessage.url} waitLoad loading="lazy" /> <OptimizedImage src={stickerUrlFromMessage} waitLoad loading="lazy" />
</div> </div>
) : ( ) : (
<div className="livestream-comment__text"> <div className="livestreamComment__text">
{removed ? (
<Empty text={__('[Removed]')} />
) : (
<MarkdownPreview <MarkdownPreview
content={message} content={message}
promptLinks promptLinks
@ -127,22 +115,24 @@ function LivestreamComment(props: Props) {
disableTimestamps disableTimestamps
setUserMention={setUserMention} setUserMention={setUserMention}
/> />
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="livestream-comment__menu"> <div className="livestreamComment__menu">
<Menu> <Menu>
<MenuButton className="menu__button"> <MenuButton className="menu__button">
<Icon size={18} icon={ICONS.MORE_VERTICAL} /> <Icon size={18} icon={ICONS.MORE_VERTICAL} />
</MenuButton> </MenuButton>
<CommentMenuList <CommentMenuList
uri={uri} uri={uri}
commentId={comment.comment_id} commentId={commentId}
authorUri={authorUri} authorUri={authorUri}
commentIsMine={commentIsMine} commentIsMine={commentIsMine}
isPinned={comment.is_pinned} isPinned={isPinned}
isTopLevel isTopLevel
disableEdit disableEdit
isLiveComment isLiveComment
@ -152,5 +142,3 @@ function LivestreamComment(props: Props) {
</li> </li>
); );
} }
export default LivestreamComment;

View file

@ -1,29 +1,9 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream'; import { selectIsFetchingComments } from 'redux/selectors/comments';
import { doResolveUris } from 'redux/actions/claims';
import { selectClaimForUri } from 'redux/selectors/claims';
import { doCommentList, doSuperChatList } from 'redux/actions/comments';
import {
selectTopLevelCommentsForUri,
selectIsFetchingComments,
selectSuperChatsForUri,
selectSuperChatTotalAmountForUri,
selectPinnedCommentsForUri,
} from 'redux/selectors/comments';
import LivestreamComments from './view'; import LivestreamComments from './view';
const select = (state, props) => ({ const select = (state) => ({
claim: selectClaimForUri(state, props.uri),
comments: selectTopLevelCommentsForUri(state, props.uri, MAX_LIVESTREAM_COMMENTS),
pinnedComments: selectPinnedCommentsForUri(state, props.uri),
fetchingComments: selectIsFetchingComments(state), fetchingComments: selectIsFetchingComments(state),
superChats: selectSuperChatsForUri(state, props.uri),
superChatsTotalAmount: selectSuperChatTotalAmountForUri(state, props.uri),
}); });
export default connect(select, { export default connect(select)(LivestreamComments);
doCommentList,
doSuperChatList,
doResolveUris,
})(LivestreamComments);

View file

@ -1,89 +1,27 @@
// @flow // @flow
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button'; import 'scss/component/_livestream-chat.scss';
import React from 'react';
import classnames from 'classnames';
import Spinner from 'component/spinner';
import CommentCreate from 'component/commentCreate';
import LivestreamComment from 'component/livestreamComment'; import LivestreamComment from 'component/livestreamComment';
import Button from 'component/button'; import React from 'react';
import UriIndicator from 'component/uriIndicator';
import CreditAmount from 'component/common/credit-amount';
import ChannelThumbnail from 'component/channelThumbnail';
import Tooltip from 'component/common/tooltip';
import * as ICONS from 'constants/icons';
import Icon from 'component/common/icon';
import OptimizedImage from 'component/optimizedImage';
import { parseSticker } from 'util/comments';
// 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 = {
uri: string, commentsToDisplay: Array<Comment>,
claim: ?StreamClaim,
embed?: boolean,
doCommentList: (string, string, number, number) => void,
comments: Array<Comment>,
pinnedComments: Array<Comment>,
fetchingComments: boolean, fetchingComments: boolean,
doSuperChatList: (string) => void, uri: string,
superChats: Array<Comment>,
doResolveUris: (Array<string>, boolean) => void,
}; };
const IS_TIMESTAMP_VISIBLE = () =>
// $FlowFixMe
document.documentElement.style.getPropertyValue('--live-timestamp-opacity') === '0.5';
const TOGGLE_TIMESTAMP_OPACITY = () =>
// $FlowFixMe
document.documentElement.style.setProperty('--live-timestamp-opacity', IS_TIMESTAMP_VISIBLE() ? '0' : '0.5');
const VIEW_MODES = {
CHAT: 'chat',
SUPERCHAT: 'sc',
};
const COMMENT_SCROLL_TIMEOUT = 25;
const LARGE_SUPER_CHAT_LIST_THRESHOLD = 20;
export default function LivestreamComments(props: Props) { export default function LivestreamComments(props: Props) {
const { const { commentsToDisplay, fetchingComments, uri } = props;
claim,
uri,
embed,
comments: commentsByChronologicalOrder,
pinnedComments,
doCommentList,
fetchingComments,
doSuperChatList,
superChats: superChatsByAmount,
doResolveUris,
} = props;
let superChatsFiatAmount, superChatsLBCAmount, superChatsTotalAmount, hasSuperChats;
const commentsRef = React.createRef();
const [viewMode, setViewMode] = React.useState(VIEW_MODES.CHAT);
const [scrollPos, setScrollPos] = React.useState(0);
const [showPinned, setShowPinned] = React.useState(true);
const [resolvingSuperChat, setResolvingSuperChat] = React.useState(false);
const [forceUpdate, setForceUpdate] = React.useState(0); const [forceUpdate, setForceUpdate] = React.useState(0);
const claimId = claim && claim.claim_id;
const commentsLength = commentsByChronologicalOrder && commentsByChronologicalOrder.length;
const commentsToDisplay = viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByAmount;
const stickerSuperChats = superChatsByAmount && superChatsByAmount.filter(({ comment }) => !!parseSticker(comment));
const discussionElement = document.querySelector('.livestream__comments');
const pinnedComment = pinnedComments.length > 0 ? pinnedComments[0] : null;
const now = new Date(); const now = new Date();
const shouldRefreshTimestamp = const shouldRefreshTimestamp =
commentsByChronologicalOrder && commentsToDisplay &&
commentsByChronologicalOrder.some((comment) => { commentsToDisplay.some((comment) => {
const { timestamp } = comment; const { timestamp } = comment;
const timePosted = timestamp * 1000; const timePosted = timestamp * 1000;
@ -91,32 +29,6 @@ export default function LivestreamComments(props: Props) {
return now - timePosted < 1000 * 60 * 60; return now - timePosted < 1000 * 60 * 60;
}); });
const restoreScrollPos = React.useCallback(() => {
if (discussionElement) {
discussionElement.scrollTop = 0;
}
}, [discussionElement]);
const superChatTopTen = React.useMemo(() => {
return superChatsByAmount ? superChatsByAmount.slice(0, 10) : superChatsByAmount;
}, [superChatsByAmount]);
const showMoreSuperChatsButton =
superChatTopTen && superChatsByAmount && superChatTopTen.length < superChatsByAmount.length;
function resolveSuperChat() {
if (superChatsByAmount && superChatsByAmount.length > 0) {
doResolveUris(
superChatsByAmount.map((comment) => comment.channel_url || '0'),
true
);
if (superChatsByAmount.length > LARGE_SUPER_CHAT_LIST_THRESHOLD) {
setResolvingSuperChat(true);
}
}
}
// Refresh timestamp on timer // Refresh timestamp on timer
React.useEffect(() => { React.useEffect(() => {
if (shouldRefreshTimestamp) { if (shouldRefreshTimestamp) {
@ -129,277 +41,16 @@ 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]);
React.useEffect(() => { /* top to bottom comment display */
if (claimId) { if (!fetchingComments && commentsToDisplay && commentsToDisplay.length > 0) {
doCommentList(uri, '', 1, 75);
doSuperChatList(uri);
}
}, [claimId, uri, doCommentList, doSuperChatList]);
// Register scroll handler (TODO: Should throttle/debounce)
React.useEffect(() => {
function handleScroll() {
if (discussionElement && viewMode === VIEW_MODES.CHAT) {
const scrollTop = discussionElement.scrollTop;
if (scrollTop !== scrollPos) {
setScrollPos(scrollTop);
}
}
}
if (discussionElement && viewMode === VIEW_MODES.CHAT) {
discussionElement.addEventListener('scroll', handleScroll);
return () => discussionElement.removeEventListener('scroll', handleScroll);
}
}, [discussionElement, scrollPos, viewMode]);
// Retain scrollPos=0 when receiving new messages.
React.useEffect(() => {
if (discussionElement && commentsLength > 0) {
// Only update comment scroll if the user hasn't scrolled up to view old comments
if (scrollPos >= 0) {
// +ve scrollPos: not scrolled (Usually, there'll be a few pixels beyond 0).
// -ve scrollPos: user scrolled.
const timer = setTimeout(() => {
// Use a timer here to ensure we reset after the new comment has been rendered.
discussionElement.scrollTop = 0;
}, COMMENT_SCROLL_TIMEOUT);
return () => clearTimeout(timer);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commentsLength]); // (Just respond to 'commentsLength' updates and nothing else)
// Stop spinner for resolving superchats
React.useEffect(() => {
if (resolvingSuperChat) {
// 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(() => {
setResolvingSuperChat(false);
// Scroll to the top:
const livestreamCommentsDiv = document.getElementsByClassName('livestream__comments')[0];
const divHeight = livestreamCommentsDiv.scrollHeight;
livestreamCommentsDiv.scrollTop = divHeight * -1;
}, 1000);
return () => clearTimeout(timer);
}
}, [resolvingSuperChat]);
// sum total amounts for fiat tips and lbc tips
if (superChatsByAmount) {
let fiatAmount = 0;
let LBCAmount = 0;
for (const superChat of superChatsByAmount) {
if (superChat.is_fiat) {
fiatAmount = fiatAmount + superChat.support_amount;
} else {
LBCAmount = LBCAmount + superChat.support_amount;
}
}
superChatsFiatAmount = fiatAmount;
superChatsLBCAmount = LBCAmount;
superChatsTotalAmount = superChatsFiatAmount + superChatsLBCAmount;
hasSuperChats = (superChatsTotalAmount || 0) > 0;
}
let superChatsReversed;
// array of superchats organized by fiat or not first, then support amount
if (superChatsByAmount) {
const clonedSuperchats = JSON.parse(JSON.stringify(superChatsByAmount));
// for top to bottom display, oldest superchat on top most recent on bottom
superChatsReversed = clonedSuperchats.sort((a, b) => {
return b.timestamp - a.timestamp;
});
}
if (!claim) {
return null;
}
function getStickerUrl(comment: string) {
const stickerFromComment = parseSticker(comment);
return stickerFromComment && stickerFromComment.url;
}
return ( return (
<div className="card livestream__discussion">
<div className="card__header--between livestream-discussion__header">
<div className="livestream-discussion__title">
{__('Live Chat')}
<Menu>
<MenuButton className="menu__button">
<Icon size={18} icon={ICONS.SETTINGS} />
</MenuButton>
<MenuList className="menu__list">
<MenuItem className="comment__menu-option" onSelect={TOGGLE_TIMESTAMP_OPACITY}>
<span className="menu__link">
<Icon aria-hidden icon={ICONS.TIME} />
{__('Toggle Timestamps')}
</span>
</MenuItem>
</MenuList>
</Menu>
</div>
{hasSuperChats && (
<div className="recommended-content__toggles">
{/* the superchats in chronological order button */}
<Button
className={classnames('button-toggle', { 'button-toggle--active': viewMode === VIEW_MODES.CHAT })}
label={__('Chat')}
onClick={() => {
setViewMode(VIEW_MODES.CHAT);
const livestreamCommentsDiv = document.getElementsByClassName('livestream__comments')[0];
livestreamCommentsDiv.scrollTop = livestreamCommentsDiv.scrollHeight;
}}
/>
{/* the button to show superchats listed by most to least support amount */}
<Button
className={classnames('button-toggle', { 'button-toggle--active': viewMode === VIEW_MODES.SUPERCHAT })}
label={
<>
<CreditAmount amount={superChatsLBCAmount || 0} size={8} /> /
<CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')}
</>
}
onClick={() => {
resolveSuperChat();
setViewMode(VIEW_MODES.SUPERCHAT);
}}
/>
</div>
)}
</div>
<>
{fetchingComments && !commentsByChronologicalOrder && (
<div className="main--empty">
<Spinner />
</div>
)}
<div ref={commentsRef} className="livestream__comments-wrapper">
{viewMode === VIEW_MODES.CHAT && superChatsByAmount && hasSuperChats && (
<div className="livestream-superchats__wrapper">
<div className="livestream-superchats__inner">
{superChatTopTen.map((superChat: Comment) => {
const { comment, comment_id, channel_url, support_amount, is_fiat } = superChat;
const isSticker = stickerSuperChats && stickerSuperChats.includes(superChat);
const stickerImg = <OptimizedImage src={getStickerUrl(comment)} waitLoad loading="lazy" />;
return (
<Tooltip title={isSticker ? stickerImg : comment} key={comment_id}>
<div className="livestream-superchat">
<div className="livestream-superchat__thumbnail">
<ChannelThumbnail uri={channel_url} xsmall />
</div>
<div
className={classnames('livestream-superchat__info', {
'livestream-superchat__info--sticker': isSticker,
'livestream-superchat__info--not-sticker': stickerSuperChats && !isSticker,
})}
>
<div className="livestream-superchat__info--user">
<UriIndicator uri={channel_url} link />
<CreditAmount
hideTitle
size={10}
className="livestream-superchat__amount-large"
amount={support_amount}
isFiat={is_fiat}
/>
</div>
{isSticker && <div className="livestream-superchat__info--image">{stickerImg}</div>}
</div>
</div>
</Tooltip>
);
})}
{showMoreSuperChatsButton && (
<Button
title={__('Show More...')}
label={__('Show More')}
button="inverse"
className="close-button"
onClick={() => {
resolveSuperChat();
setViewMode(VIEW_MODES.SUPERCHAT);
}}
iconRight={ICONS.MORE}
/>
)}
</div>
</div>
)}
{pinnedComment && showPinned && viewMode === VIEW_MODES.CHAT && (
<div className="livestream-pinned__wrapper">
<LivestreamComment
comment={pinnedComment}
key={pinnedComment.comment_id}
uri={uri}
forceUpdate={forceUpdate}
/>
<Button
title={__('Dismiss pinned comment')}
button="inverse"
className="close-button"
onClick={() => setShowPinned(false)}
icon={ICONS.REMOVE}
/>
</div>
)}
{/* top to bottom comment display */}
{!fetchingComments && commentsByChronologicalOrder.length > 0 ? (
<div className="livestream__comments"> <div className="livestream__comments">
{viewMode === VIEW_MODES.CHAT && {commentsToDisplay.map((comment) => (
commentsToDisplay.map((comment) => (
<LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} /> <LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} />
))} ))}
{viewMode === VIEW_MODES.SUPERCHAT && resolvingSuperChat && (
<div className="main--empty">
<Spinner />
</div>
)}
{viewMode === VIEW_MODES.SUPERCHAT &&
!resolvingSuperChat &&
superChatsReversed &&
superChatsReversed.map((comment) => (
<LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} />
))}
</div>
) : (
<div className="main--empty" style={{ flex: 1 }} />
)}
{scrollPos < 0 && viewMode === VIEW_MODES.CHAT && (
<Button
button="secondary"
className="livestream__comments__scroll-to-recent"
label={__('Recent Comments')}
onClick={restoreScrollPos}
iconRight={ICONS.DOWN}
/>
)}
<div className="livestream__comment-create">
<CommentCreate isLivestream bottom embed={embed} uri={uri} onDoneReplying={restoreScrollPos} />
</div>
</div>
</>
</div> </div>
); );
} }
return <div className="main--empty" style={{ flex: 1 }} />;
}

View file

@ -1,16 +1,16 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import { selectClaimForUri, makeSelectTagInClaimOrChannelForUri, selectThumbnailForUri } from 'redux/selectors/claims';
makeSelectClaimForUri,
makeSelectTagInClaimOrChannelForUri,
selectThumbnailForUri,
} from 'redux/selectors/claims';
import LivestreamLayout from './view'; import LivestreamLayout from './view';
import { DISABLE_COMMENTS_TAG } from 'constants/tags'; import { DISABLE_COMMENTS_TAG } from 'constants/tags';
const select = (state, props) => ({ const select = (state, props) => {
claim: makeSelectClaimForUri(props.uri)(state), const { uri } = props;
thumbnail: selectThumbnailForUri(state, props.uri),
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state), return {
}); claim: selectClaimForUri(state, uri),
thumbnail: selectThumbnailForUri(state, uri),
chatDisabled: makeSelectTagInClaimOrChannelForUri(uri, DISABLE_COMMENTS_TAG)(state),
};
};
export default connect(select)(LivestreamLayout); export default connect(select)(LivestreamLayout);

View file

@ -1,46 +1,43 @@
// @flow // @flow
import { LIVESTREAM_EMBED_URL } from 'constants/livestream';
import React from 'react';
import FileTitleSection from 'component/fileTitleSection';
import { useIsMobile } from 'effects/use-screensize';
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
import classnames from 'classnames';
import { lazyImport } from 'util/lazyImport'; import { lazyImport } from 'util/lazyImport';
import { LIVESTREAM_EMBED_URL } from 'constants/livestream';
import { useIsMobile } from 'effects/use-screensize';
import classnames from 'classnames';
import FileTitleSection from 'component/fileTitleSection';
import LivestreamLink from 'component/livestreamLink'; import LivestreamLink from 'component/livestreamLink';
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
import React from 'react';
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */)); const LivestreamChatLayout = lazyImport(() => import('component/livestreamChatLayout' /* webpackChunkName: "chat" */));
type Props = { type Props = {
uri: string, activeStreamUri: boolean | string,
claim: ?StreamClaim, claim: ?StreamClaim,
hideComments: boolean, hideComments: boolean,
isCurrentClaimLive: boolean,
release: any, release: any,
showLivestream: boolean, showLivestream: boolean,
showScheduledInfo: boolean, showScheduledInfo: boolean,
isCurrentClaimLive: boolean, uri: string,
activeStreamUri: boolean | string,
}; };
export default function LivestreamLayout(props: Props) { export default function LivestreamLayout(props: Props) {
const { const {
activeStreamUri,
claim, claim,
uri,
hideComments, hideComments,
isCurrentClaimLive,
release, release,
showLivestream, showLivestream,
showScheduledInfo, showScheduledInfo,
isCurrentClaimLive, uri,
activeStreamUri,
} = props; } = props;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
if (!claim || !claim.signing_channel) { if (!claim || !claim.signing_channel) return null;
return null;
}
const channelName = claim.signing_channel.name; const { name: channelName, claim_id: channelClaimId } = claim.signing_channel;
const channelClaimId = claim.signing_channel.claim_id;
return ( return (
<> <>
@ -58,6 +55,7 @@ export default function LivestreamLayout(props: Props) {
allowFullScreen allowFullScreen
/> />
)} )}
{showScheduledInfo && <LivestreamScheduledInfo release={release} />} {showScheduledInfo && <LivestreamScheduledInfo release={release} />}
</div> </div>
</div> </div>
@ -87,7 +85,11 @@ export default function LivestreamLayout(props: Props) {
/> />
)} )}
<React.Suspense fallback={null}>{isMobile && !hideComments && <LivestreamComments uri={uri} />}</React.Suspense> {isMobile && !hideComments && (
<React.Suspense fallback={null}>
<LivestreamChatLayout uri={uri} />
</React.Suspense>
)}
<FileTitleSection uri={uri} livestream isLive={showLivestream} /> <FileTitleSection uri={uri} livestream isLive={showLivestream} />
</div> </div>

View file

@ -1,109 +1,89 @@
// @flow // @flow
import type { Node } from 'react';
import React, { Fragment } from 'react';
import classnames from 'classnames';
import { MAIN_CLASS } from 'constants/classnames';
import { lazyImport } from 'util/lazyImport'; import { lazyImport } from 'util/lazyImport';
import SideNavigation from 'component/sideNavigation'; import { MAIN_CLASS } from 'constants/classnames';
import SettingsSideNavigation from 'component/settingsSideNavigation'; import { parseURI } from 'util/lbryURI';
import Header from 'component/header';
/* @if TARGET='app' */
import StatusBar from 'component/common/status-bar';
/* @endif */
import usePersistedState from 'effects/use-persisted-state';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import { parseURI } from 'util/lbryURI'; import classnames from 'classnames';
import Header from 'component/header';
import React from 'react';
import SettingsSideNavigation from 'component/settingsSideNavigation';
import SideNavigation from 'component/sideNavigation';
import type { Node } from 'react';
import usePersistedState from 'effects/use-persisted-state';
const Footer = lazyImport(() => import('web/component/footer' /* webpackChunkName: "footer" */)); const Footer = lazyImport(() => import('web/component/footer' /* webpackChunkName: "footer" */));
type Props = { type Props = {
children: Node | Array<Node>,
className: ?string,
authPage: boolean, authPage: boolean,
filePage: boolean,
settingsPage?: boolean,
noHeader: boolean,
noFooter: boolean,
noSideNavigation: boolean,
fullWidthPage: boolean,
videoTheaterMode: boolean,
isMarkdown?: boolean,
livestream?: boolean,
chatDisabled: boolean,
rightSide?: Node,
backout: { backout: {
backLabel?: string, backLabel?: string,
backNavDefault?: string, backNavDefault?: string,
title: string, title: string,
simpleTitle: string, // Just use the same value as `title` if `title` is already short (~< 10 chars), unless you have a better idea for title overlfow on mobile simpleTitle: string, // Just use the same value as `title` if `title` is already short (~< 10 chars), unless you have a better idea for title overlfow on mobile
}, },
chatDisabled: boolean,
children: Node | Array<Node>,
className: ?string,
filePage: boolean,
fullWidthPage: boolean,
isMarkdown?: boolean,
livestream?: boolean,
noFooter: boolean,
noHeader: boolean,
noSideNavigation: boolean,
rightSide?: Node,
settingsPage?: boolean,
videoTheaterMode: boolean,
isPopoutWindow?: boolean,
}; };
function Page(props: Props) { function Page(props: Props) {
const { const {
authPage = false,
backout,
chatDisabled,
children, children,
className, className,
filePage = false, filePage = false,
settingsPage,
authPage = false,
fullWidthPage = false, fullWidthPage = false,
noHeader = false,
noFooter = false,
noSideNavigation = false,
backout,
videoTheaterMode,
isMarkdown = false, isMarkdown = false,
livestream, livestream,
noFooter = false,
noHeader = false,
noSideNavigation = false,
rightSide, rightSide,
chatDisabled, settingsPage,
videoTheaterMode,
isPopoutWindow,
} = props; } = props;
const { const {
location: { pathname }, location: { pathname },
} = useHistory(); } = useHistory();
const [sidebarOpen, setSidebarOpen] = usePersistedState('sidebar', false);
const isMediumScreen = useIsMediumScreen(); const isMediumScreen = useIsMediumScreen();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [sidebarOpen, setSidebarOpen] = usePersistedState('sidebar', false);
let isOnFilePage = false; let isOnFilePage = false;
try { try {
const url = pathname.slice(1).replace(/:/g, '#'); const url = pathname.slice(1).replace(/:/g, '#');
const { isChannel } = parseURI(url); const { isChannel } = parseURI(url);
if (!isChannel) {
isOnFilePage = true; if (!isChannel) isOnFilePage = true;
}
} catch (e) {} } catch (e) {}
const isAbsoluteSideNavHidden = (isOnFilePage || isMobile) && !sidebarOpen; const isAbsoluteSideNavHidden = (isOnFilePage || isMobile) && !sidebarOpen;
function getSideNavElem() {
if (!authPage) {
if (settingsPage) {
return <SettingsSideNavigation />;
} else if (!noSideNavigation) {
return (
<SideNavigation
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
isMediumScreen={isMediumScreen}
isOnFilePage={isOnFilePage}
/>
);
}
}
return null;
}
React.useEffect(() => { React.useEffect(() => {
if (isOnFilePage || isMediumScreen) { if (isOnFilePage || isMediumScreen) setSidebarOpen(false);
setSidebarOpen(false);
}
// TODO: make sure setState callback for usePersistedState uses useCallback to it doesn't cause effect to re-run // TODO: make sure setState callback for usePersistedState uses useCallback to it doesn't cause effect to re-run
}, [isOnFilePage, isMediumScreen]); }, [isOnFilePage, isMediumScreen, setSidebarOpen]);
return ( return (
<Fragment> <>
{!noHeader && ( {!noHeader && (
<Header <Header
authHeader={authPage} authHeader={authPage}
@ -113,14 +93,28 @@ function Page(props: Props) {
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
/> />
)} )}
<div <div
className={classnames('main-wrapper__inner', { className={classnames('main-wrapper__inner', {
'main-wrapper__inner--filepage': isOnFilePage, 'main-wrapper__inner--filepage': isOnFilePage,
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode, 'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode,
'main-wrapper__inner--auth': authPage, 'main-wrapper__inner--auth': authPage,
'main--popout-chat': isPopoutWindow,
})} })}
> >
{getSideNavElem()} {!authPage &&
(settingsPage ? (
<SettingsSideNavigation />
) : (
!noSideNavigation && (
<SideNavigation
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
isMediumScreen={isMediumScreen}
isOnFilePage={isOnFilePage}
/>
)
))}
<div <div
className={classnames({ className={classnames({
@ -139,25 +133,24 @@ function Page(props: Props) {
'main--markdown': isMarkdown, 'main--markdown': isMarkdown,
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream, 'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream,
'main--livestream': livestream && !chatDisabled, 'main--livestream': livestream && !chatDisabled,
'main--popout-chat': isPopoutWindow,
})} })}
> >
{children} {children}
{!isMobile && rightSide && <div className="main__right-side">{rightSide}</div>} {!isMobile && rightSide && (!livestream || !chatDisabled) && (
<div className="main__right-side">{rightSide}</div>
)}
</main> </main>
{/* @if TARGET='web' */}
{!noFooter && ( {!noFooter && (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<Footer /> <Footer />
</React.Suspense> </React.Suspense>
)} )}
{/* @endif */}
</div> </div>
{/* @if TARGET='app' */}
<StatusBar />
{/* @endif */}
</div> </div>
</Fragment> </>
); );
} }

View file

@ -52,6 +52,7 @@ const CheckoutPage = lazyImport(() => import('page/checkoutPage' /* webpackChunk
const CreatorDashboard = lazyImport(() => import('page/creatorDashboard' /* webpackChunkName: "creatorDashboard" */)); const CreatorDashboard = lazyImport(() => import('page/creatorDashboard' /* webpackChunkName: "creatorDashboard" */));
const DiscoverPage = lazyImport(() => import('page/discover' /* webpackChunkName: "discover" */)); const DiscoverPage = lazyImport(() => import('page/discover' /* webpackChunkName: "discover" */));
const EmbedWrapperPage = lazyImport(() => import('page/embedWrapper' /* webpackChunkName: "embedWrapper" */)); const EmbedWrapperPage = lazyImport(() => import('page/embedWrapper' /* webpackChunkName: "embedWrapper" */));
const PopoutChatPage = lazyImport(() => import('page/popoutChatWrapper' /* webpackChunkName: "popoutChat" */));
const FileListPublished = lazyImport(() => const FileListPublished = lazyImport(() =>
import('page/fileListPublished' /* webpackChunkName: "fileListPublished" */) import('page/fileListPublished' /* webpackChunkName: "fileListPublished" */)
); );
@ -350,6 +351,8 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} /> <PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} /> <PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} />
<Route path={`/$/${PAGES.POPOUT}/:channelName/:streamName`} component={PopoutChatPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} /> <Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} /> <Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} />

View file

@ -83,3 +83,4 @@ exports.LIVESTREAM = 'livestream';
exports.LIVESTREAM_CURRENT = 'live'; exports.LIVESTREAM_CURRENT = 'live';
exports.GENERAL = 'general'; exports.GENERAL = 'general';
exports.LIST = 'list'; exports.LIST = 'list';
exports.POPOUT = 'popout';

View file

@ -11,11 +11,13 @@ import { doFetchChannelLiveStatus } from 'redux/actions/livestream';
import LivestreamPage from './view'; import LivestreamPage from './view';
const select = (state, props) => { const select = (state, props) => {
const channelClaimId = getChannelIdFromClaim(selectClaimForUri(state, props.uri)); const { uri } = props;
const channelClaimId = getChannelIdFromClaim(selectClaimForUri(state, uri));
return { return {
isAuthenticated: selectUserVerifiedEmail(state), isAuthenticated: selectUserVerifiedEmail(state),
channelClaimId, channelClaimId,
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state), chatDisabled: makeSelectTagInClaimOrChannelForUri(uri, DISABLE_COMMENTS_TAG)(state),
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId), activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId),
activeLivestreamInitialized: selectActiveLivestreamInitialized(state), activeLivestreamInitialized: selectActiveLivestreamInitialized(state),
}; };

View file

@ -1,69 +1,83 @@
// @flow // @flow
import React from 'react'; import { formatLbryChannelName } from 'util/url';
import { lazyImport } from 'util/lazyImport'; import { lazyImport } from 'util/lazyImport';
import Page from 'component/page';
import LivestreamLayout from 'component/livestreamLayout';
import analytics from 'analytics';
import moment from 'moment';
import { LIVESTREAM_STARTS_SOON_BUFFER, LIVESTREAM_STARTED_RECENTLY_BUFFER } from 'constants/livestream'; import { LIVESTREAM_STARTS_SOON_BUFFER, LIVESTREAM_STARTED_RECENTLY_BUFFER } from 'constants/livestream';
import analytics from 'analytics';
import LivestreamLayout from 'component/livestreamLayout';
import moment from 'moment';
import Page from 'component/page';
import React from 'react';
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */)); const LivestreamChatLayout = lazyImport(() => import('component/livestreamChatLayout' /* webpackChunkName: "chat" */));
type Props = { type Props = {
uri: string,
claim: StreamClaim,
doSetPlayingUri: ({ uri: ?string }) => void,
isAuthenticated: boolean,
doUserSetReferrer: (string) => void,
channelClaimId: ?string,
chatDisabled: boolean,
doCommentSocketConnect: (string, string) => void,
doCommentSocketDisconnect: (string) => void,
doFetchChannelLiveStatus: (string) => void,
activeLivestreamForChannel: any, activeLivestreamForChannel: any,
activeLivestreamInitialized: boolean, activeLivestreamInitialized: boolean,
channelClaimId: ?string,
chatDisabled: boolean,
claim: StreamClaim,
isAuthenticated: boolean,
uri: string,
doSetPlayingUri: ({ uri: ?string }) => void,
doCommentSocketConnect: (string, string, string) => void,
doCommentSocketDisconnect: (string, string) => void,
doFetchChannelLiveStatus: (string) => void,
doUserSetReferrer: (string) => void,
}; };
export default function LivestreamPage(props: Props) { export default function LivestreamPage(props: Props) {
const { const {
uri, activeLivestreamForChannel,
claim, activeLivestreamInitialized,
doSetPlayingUri,
isAuthenticated,
doUserSetReferrer,
channelClaimId, channelClaimId,
chatDisabled, chatDisabled,
claim,
isAuthenticated,
uri,
doSetPlayingUri,
doCommentSocketConnect, doCommentSocketConnect,
doCommentSocketDisconnect, doCommentSocketDisconnect,
doFetchChannelLiveStatus, doFetchChannelLiveStatus,
activeLivestreamForChannel, doUserSetReferrer,
activeLivestreamInitialized,
} = props; } = props;
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
const [showLivestream, setShowLivestream] = React.useState(false);
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
const [hideComments, setHideComments] = React.useState(false);
const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized;
const isChannelBroadcasting = Boolean(activeLivestreamForChannel);
const claimId = claim && claim.claim_id;
const isCurrentClaimLive = isChannelBroadcasting && activeLivestreamForChannel.claimId === claimId;
const livestreamChannelId = channelClaimId || '';
// $FlowFixMe
const release = moment.unix(claim.value.release_time);
const stringifiedClaim = JSON.stringify(claim);
React.useEffect(() => { React.useEffect(() => {
// TODO: This should not be needed one we unify the livestream player (?) // TODO: This should not be needed one we unify the livestream player (?)
analytics.playerLoadedEvent('livestream', false); analytics.playerLoadedEvent('livestream', false);
}, []); }, []);
const claimId = claim && claim.claim_id;
// Establish web socket connection for viewer count. // Establish web socket connection for viewer count.
React.useEffect(() => { React.useEffect(() => {
if (claimId) { if (!claim) return;
doCommentSocketConnect(uri, claimId);
const { claim_id: claimId, signing_channel: channelClaim } = claim;
const channelName = channelClaim && formatLbryChannelName(channelClaim.canonical_url);
if (claimId && channelName) {
doCommentSocketConnect(uri, channelName, claimId);
} }
return () => { return () => {
if (claimId) { if (claimId && channelName) {
doCommentSocketDisconnect(claimId); doCommentSocketDisconnect(claimId, channelName);
} }
}; };
}, [claimId, uri, doCommentSocketConnect, doCommentSocketDisconnect]); }, [claim, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized;
const isChannelBroadcasting = Boolean(activeLivestreamForChannel);
const isCurrentClaimLive = isChannelBroadcasting && activeLivestreamForChannel.claimId === claimId;
const livestreamChannelId = channelClaimId || '';
// Find out current channels status + active live claim. // Find out current channels status + active live claim.
React.useEffect(() => { React.useEffect(() => {
@ -72,19 +86,10 @@ export default function LivestreamPage(props: Props) {
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [livestreamChannelId, doFetchChannelLiveStatus]); }, [livestreamChannelId, doFetchChannelLiveStatus]);
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setActiveStreamUri(!isCurrentClaimLive && isChannelBroadcasting ? activeLivestreamForChannel.claimUri : false); setActiveStreamUri(!isCurrentClaimLive && isChannelBroadcasting ? activeLivestreamForChannel.claimUri : false);
}, [isCurrentClaimLive, isChannelBroadcasting]); // eslint-disable-line react-hooks/exhaustive-deps }, [isCurrentClaimLive, isChannelBroadcasting]); // eslint-disable-line react-hooks/exhaustive-deps
// $FlowFixMe
const release = moment.unix(claim.value.release_time);
const [showLivestream, setShowLivestream] = React.useState(false);
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
const [hideComments, setHideComments] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (!isInitialized) return; if (!isInitialized) return;
@ -125,19 +130,15 @@ export default function LivestreamPage(props: Props) {
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [chatDisabled, isChannelBroadcasting, release, isCurrentClaimLive, isInitialized]); }, [chatDisabled, isChannelBroadcasting, release, isCurrentClaimLive, isInitialized]);
const stringifiedClaim = JSON.stringify(claim);
React.useEffect(() => { React.useEffect(() => {
if (uri && stringifiedClaim) { if (uri && stringifiedClaim) {
const jsonClaim = JSON.parse(stringifiedClaim); const jsonClaim = JSON.parse(stringifiedClaim);
if (!isAuthenticated) { if (!isAuthenticated) {
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url; const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
if (uri) { if (uri) doUserSetReferrer(uri.replace('lbry://', ''));
doUserSetReferrer(uri.replace('lbry://', '')); //
} }
} }
} }, [uri, stringifiedClaim, isAuthenticated, doUserSetReferrer]);
}, [uri, stringifiedClaim, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect(() => { React.useEffect(() => {
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back // Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
@ -155,7 +156,7 @@ export default function LivestreamPage(props: Props) {
!hideComments && !hideComments &&
isInitialized && ( isInitialized && (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<LivestreamComments uri={uri} /> <LivestreamChatLayout uri={uri} />
</React.Suspense> </React.Suspense>
) )
} }

View file

@ -0,0 +1,27 @@
import { buildURI } from 'util/lbryURI';
import { connect } from 'react-redux';
import { doCommentSocketConnectAsCommenter, doCommentSocketDisconnectAsCommenter } from 'redux/actions/websocket';
import { doResolveUri } from 'redux/actions/claims';
import { selectClaimForUri } from 'redux/selectors/claims';
import PopoutChatPage from './view';
const select = (state, props) => {
const { match } = props;
const { params } = match;
const { channelName, streamName } = params;
const uri = buildURI({ channelName: channelName.replace(':', '#'), streamName: streamName.replace(':', '#') }) || '';
return {
claim: selectClaimForUri(state, uri),
uri,
};
};
const perform = {
doCommentSocketConnectAsCommenter,
doCommentSocketDisconnectAsCommenter,
doResolveUri,
};
export default connect(select, perform)(PopoutChatPage);

View file

@ -0,0 +1,40 @@
// @flow
import { formatLbryChannelName } from 'util/url';
import LivestreamChatLayout from 'component/livestreamChatLayout';
import Page from 'component/page';
import React from 'react';
type Props = {
claim: StreamClaim,
uri: string,
doCommentSocketConnectAsCommenter: (string, string, string) => void,
doCommentSocketDisconnectAsCommenter: (string, string) => void,
doResolveUri: (string, boolean) => void,
};
export default function PopoutChatPage(props: Props) {
const { claim, uri, doCommentSocketConnectAsCommenter, doCommentSocketDisconnectAsCommenter, doResolveUri } = props;
React.useEffect(() => {
if (!claim) doResolveUri(uri, true);
}, [claim, doResolveUri, uri]);
React.useEffect(() => {
if (!claim) return;
const { claim_id: claimId, signing_channel: channelClaim } = claim;
const channelName = channelClaim && formatLbryChannelName(channelClaim.canonical_url);
if (claimId && channelName) doCommentSocketConnectAsCommenter(uri, channelName, claimId);
return () => {
if (claimId && channelName) doCommentSocketDisconnectAsCommenter(claimId, channelName);
};
}, [claim, doCommentSocketConnectAsCommenter, doCommentSocketDisconnectAsCommenter, uri]);
return (
<Page noSideNavigation noFooter noHeader isPopoutWindow>
<LivestreamChatLayout uri={uri} isPopoutWindow />
</Page>
);
}

View file

@ -5,16 +5,24 @@ import { SOCKETY_SERVER_API } from 'config';
const NOTIFICATION_WS_URL = `${SOCKETY_SERVER_API}/internal?id=`; const NOTIFICATION_WS_URL = `${SOCKETY_SERVER_API}/internal?id=`;
const COMMENT_WS_URL = `${SOCKETY_SERVER_API}/commentron?id=`; const COMMENT_WS_URL = `${SOCKETY_SERVER_API}/commentron?id=`;
const COMMENT_WS_SUBCATEGORIES = {
COMMENTER: 'commenter',
VIEWER: 'viewer',
};
let sockets = {}; let sockets = {};
let closingSockets = {}; let closingSockets = {};
let retryCount = 0; let retryCount = 0;
const getCommentSocketUrl = (claimId) => { const getCommentSocketUrl = (claimId, channelName) => {
return `${COMMENT_WS_URL}${claimId}&category=${claimId}`; return `${COMMENT_WS_URL}${claimId}&category=${channelName}&sub_category=viewer`;
}; };
export const doSocketConnect = (url, cb) => { const getCommentSocketUrlForCommenter = (claimId, channelName) => {
return `${COMMENT_WS_URL}${claimId}&category=${channelName}&sub_category=commenter`;
};
export const doSocketConnect = (url, cb, type) => {
function connectToSocket() { function connectToSocket() {
if (sockets[url] !== undefined && sockets[url] !== null) { if (sockets[url] !== undefined && sockets[url] !== null) {
sockets[url].close(); sockets[url].close();
@ -26,7 +34,7 @@ export const doSocketConnect = (url, cb) => {
sockets[url] = new WebSocket(url); sockets[url] = new WebSocket(url);
sockets[url].onopen = (e) => { sockets[url].onopen = (e) => {
retryCount = 0; retryCount = 0;
console.log('\nConnected to WS \n\n'); // eslint-disable-line console.log(`\nConnected to ${type} WS \n\n`); // eslint-disable-line
}; };
sockets[url].onmessage = (e) => { sockets[url].onmessage = (e) => {
@ -35,12 +43,12 @@ export const doSocketConnect = (url, cb) => {
}; };
sockets[url].onerror = (e) => { sockets[url].onerror = (e) => {
console.error('websocket onerror', e); // eslint-disable-line console.log(`${type} websocket onerror`, e); // eslint-disable-line
// onerror and onclose will both fire, so nothing is needed here // onerror and onclose will both fire, so nothing is needed here
}; };
sockets[url].onclose = () => { sockets[url].onclose = () => {
console.log('\n Disconnected from WS\n\n'); // eslint-disable-line console.log(`\n Disconnected from ${type} WS \n\n`); // eslint-disable-line
if (!closingSockets[url]) { if (!closingSockets[url]) {
retryCount += 1; retryCount += 1;
connectToSocket(); connectToSocket();
@ -75,7 +83,9 @@ export const doNotificationSocketConnect = (enableNotifications) => (dispatch) =
const url = `${NOTIFICATION_WS_URL}${authToken}`; const url = `${NOTIFICATION_WS_URL}${authToken}`;
doSocketConnect(url, (data) => { doSocketConnect(
url,
(data) => {
switch (data.type) { switch (data.type) {
case 'pending_notification': case 'pending_notification':
if (enableNotifications) { if (enableNotifications) {
@ -89,13 +99,20 @@ export const doNotificationSocketConnect = (enableNotifications) => (dispatch) =
}); });
break; break;
} }
}); },
'notification'
);
}; };
export const doCommentSocketConnect = (uri, claimId) => (dispatch) => { export const doCommentSocketConnect = (uri, channelName, claimId, subCategory) => (dispatch) => {
const url = getCommentSocketUrl(claimId); const url =
subCategory === COMMENT_WS_SUBCATEGORIES.COMMENTER
? getCommentSocketUrlForCommenter(claimId, channelName)
: getCommentSocketUrl(claimId, channelName);
doSocketConnect(url, (response) => { doSocketConnect(
url,
(response) => {
if (response.type === 'delta') { if (response.type === 'delta') {
const newComment = response.data.comment; const newComment = response.data.comment;
dispatch({ dispatch({
@ -128,11 +145,23 @@ export const doCommentSocketConnect = (uri, claimId) => (dispatch) => {
data: { comment_id }, data: { comment_id },
}); });
} }
}); },
'comment'
);
}; };
export const doCommentSocketDisconnect = (claimId) => (dispatch) => { export const doCommentSocketDisconnect = (claimId, channelName) => (dispatch) => {
const url = getCommentSocketUrl(claimId); const url = getCommentSocketUrl(claimId, channelName);
dispatch(doSocketDisconnect(url));
};
export const doCommentSocketConnectAsCommenter = (uri, channelName, claimId) => (dispatch) => {
dispatch(doCommentSocketConnect(uri, channelName, claimId, COMMENT_WS_SUBCATEGORIES.COMMENTER));
};
export const doCommentSocketDisconnectAsCommenter = (claimId, channelName) => (dispatch) => {
const url = getCommentSocketUrlForCommenter(claimId, channelName);
dispatch(doSocketDisconnect(url)); dispatch(doSocketDisconnect(url));
}; };

View file

@ -54,7 +54,6 @@
@import 'component/spinner'; @import 'component/spinner';
@import 'component/splash'; @import 'component/splash';
@import 'component/status-bar'; @import 'component/status-bar';
@import 'component/superchat';
@import 'component/syntax-highlighter'; @import 'component/syntax-highlighter';
@import 'component/table'; @import 'component/table';
@import 'component/livestream'; @import 'component/livestream';

View file

@ -0,0 +1,21 @@
.comment__badge {
padding-right: var(--spacing-xxs);
.icon {
margin-bottom: -5px;
}
}
.comment__badge--globalMod {
.st0 {
// @see: ICONS.BADGE_MOD
fill: #fe7500;
}
}
.comment__badge--mod {
.st0 {
// @see: ICONS.BADGE_MOD
fill: #ff3850;
}
}

View file

@ -180,28 +180,6 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.comment__badge {
padding-right: var(--spacing-xxs);
.icon {
margin-bottom: -3px;
}
}
.comment__badge--global-mod {
.st0 {
// @see: ICONS.BADGE_MOD
fill: #fe7500;
}
}
.comment__badge--mod {
.st0 {
// @see: ICONS.BADGE_MOD
fill: #ff3850;
}
}
.comment__message { .comment__message {
word-break: break-word; word-break: break-word;
max-width: 35rem; max-width: 35rem;

View file

@ -0,0 +1,274 @@
@import '../init/breakpoints';
@import '../init/mixins';
$discussion-header__height: 3rem;
$recent-msg-button__height: 2rem;
.livestream__chat {
width: 100%;
@media (min-width: $breakpoint-medium) {
margin: 0;
width: var(--livestream-comments-width);
height: calc(100vh - var(--header-height));
position: fixed;
right: 0;
top: var(--header-height);
bottom: 0;
border-radius: 0;
border-top: none;
border-bottom: none;
border-right: none;
.card__main-actions {
padding: 0;
}
}
}
.livestream__chat--popout {
margin: 0 !important;
padding: 0 !important;
width: 100vw !important;
height: 100vh !important;
top: 0 !important;
.livestreamComments__wrapper {
height: 95vh !important;
}
.livestreamSuperchats__wrapper {
width: 100%;
}
.livestreamPinned__wrapper {
width: 100%;
}
}
.livestreamDiscussion__header {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-s);
margin-bottom: 0;
align-items: center;
.recommended-content__toggles {
button {
height: unset;
padding: 5px;
}
.button__label {
max-height: 18px;
}
}
@media (min-width: $breakpoint-small) {
height: $discussion-header__height;
padding: 0 var(--spacing-s);
padding-right: 0;
}
@media (max-width: $breakpoint-small) {
padding: var(--spacing-xxs);
}
}
.livestreamDiscussion__title {
display: flex;
align-items: center;
padding: 0;
.menu__button {
margin-left: var(--spacing-xxs);
}
@media (max-width: $breakpoint-small) {
.menu__button {
margin-left: 5px;
}
}
}
.livestreamComments__wrapper {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height) - #{$discussion-header__height});
.main--empty {
.yrbl__wrap {
flex-direction: column !important;
align-items: center;
img {
margin: 0;
}
}
}
}
.livestream__comments {
display: flex;
flex-direction: column-reverse;
font-size: var(--font-small);
overflow-y: scroll;
overflow-x: visible;
padding-top: var(--spacing-s);
width: 100%;
}
.livestreamComments__scrollToRecent {
margin-top: -$recent-msg-button__height;
align-self: center;
margin-bottom: var(--spacing-xs);
font-size: var(--font-xsmall);
padding: var(--spacing-xxs) var(--spacing-s);
opacity: 0.9;
&:hover {
opacity: 1;
}
}
.livestream__commentCreate {
padding: var(--spacing-s);
border-top: 1px solid var(--color-border);
margin-top: auto;
}
.livestreamSuperchats__wrapper {
flex-shrink: 0;
position: relative;
overflow-x: scroll;
padding: var(--spacing-s) var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-small);
background-color: var(--color-card-background);
@media (min-width: $breakpoint-small) {
padding: var(--spacing-xs);
width: var(--livestream-comments-width);
}
}
.livestreamPinned__wrapper {
display: flex;
flex-shrink: 0;
position: relative;
padding: var(--spacing-s) var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-small);
background-color: var(--color-card-background-highlighted);
width: 100%;
.livestream__comment {
width: 100%;
padding-top: var(--spacing-xs);
max-height: 6rem;
overflow-y: scroll;
}
.close-button {
border-left: 1px solid var(--color-border);
padding: 0 calc(var(--spacing-m) - var(--spacing-xs)) 0 var(--spacing-m);
color: var(--color-text-subtitle);
}
@media (min-width: $breakpoint-small) {
padding: var(--spacing-xs);
width: var(--livestream-comments-width);
}
}
.livestreamSuperchat__amount--large {
.credit-amount {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
}
.livestreamSuperchats__inner {
display: flex;
.close-button {
color: var(--color-text-subtitle);
}
}
.livestream__superchat {
display: flex;
margin-right: var(--spacing-xs);
padding: var(--spacing-xxs);
border-radius: var(--border-radius);
.channel-thumbnail {
margin-right: var(--spacing-xs);
@include handleChannelGif(2rem);
}
&:first-of-type {
background-color: var(--color-superchat);
.channel-name {
max-width: 8rem;
}
}
&:nth-of-type(2) {
background-color: var(--color-superchat-2);
}
&:nth-of-type(3) {
background-color: var(--color-superchat-3);
}
&:nth-of-type(-n + 3) {
.channel-name,
.credit-amount {
color: var(--color-black);
}
}
.channel-name {
max-width: 5rem;
}
}
.livestreamSuperchat__info {
display: flex;
flex-direction: column;
justify-content: center;
font-size: var(--font-xsmall);
.button {
margin-top: calc(var(--spacing-xxs) / 2);
}
}
.livestreamSuperchat__info--sticker {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-direction: row;
width: 8rem;
height: 3rem;
.livestreamSuperchat__info--user {
.channel-name {
max-width: 5rem;
}
}
.livestreamSuperchat__info--image {
width: 100%;
height: 100%;
}
}
.livestreamSuperchat__info--notSticker {
flex-direction: row;
}
.livestreamSuperchat__amount--large {
min-width: 2.5rem;
}

View file

@ -0,0 +1,141 @@
@import '../init/breakpoints';
@import '../init/mixins';
@import '../component/superchat';
.livestream__comment {
list-style-type: none;
position: relative;
.date_time {
color: var(--color-text-subtitle);
opacity: var(--live-timestamp-opacity);
}
&:hover {
background-color: var(--color-card-background-highlighted);
.date_time {
opacity: 0.5;
}
}
@media (min-width: $breakpoint-small) {
&:not(:hover) {
.menu__button:not(:focus):not([aria-expanded='true']) {
opacity: 0;
}
}
}
.channel-name {
font-size: var(--font-xsmall);
}
}
.livestream__comment--mentioned {
background-color: var(--color-card-background-highlighted);
}
.livestreamComment__info {
overflow: hidden;
}
.livestream__comment--superchat {
background-color: var(--color-card-background-highlighted);
+ .livestream__comment--superchat {
margin-bottom: var(--spacing-xxs);
}
.livestreamComment__body {
display: flex;
align-items: flex-start;
flex-direction: unset;
flex: unset;
}
.livestreamComment__info {
margin-top: calc(var(--spacing-xxs) / 2);
}
&::before {
position: absolute;
left: 0;
height: 100%;
max-height: 4rem;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
width: 5px;
background-color: var(--color-superchat);
content: '';
}
}
.livestreamComment__body {
display: flex;
align-items: flex-start;
margin-left: var(--spacing-s);
overflow: hidden;
.channel-thumbnail {
@include handleChannelGif(2rem);
margin-top: var(--spacing-xxs);
flex-shrink: 0;
}
}
.livestreamComment__menu {
position: absolute;
right: var(--spacing-xs);
top: var(--spacing-xs);
}
.livestreamComment__superchatBanner {
@extend .superChat;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 0.25rem var(--spacing-s);
display: inline-block;
position: relative;
}
// This is just a two small circles that overlap to make it look like
// the banner and the left border are connected
.livestreamComment__superchatBanner--corner {
height: calc(var(--border-radius) * 2);
width: calc(var(--border-radius) * 2);
border-radius: 50%;
position: absolute;
background-color: var(--color-superchat);
bottom: 0;
left: 0;
transform: translateX(0) translateY(50%);
&::after {
content: '';
height: calc(var(--border-radius) * 2);
width: calc(var(--border-radius) * 2);
border-top-left-radius: var(--border-radius);
background-color: var(--color-card-background);
position: absolute;
bottom: 0;
left: 0;
transform: translateX(25%) translateY(50%);
}
}
.livestreamComment__text {
padding-right: var(--spacing-xl);
padding-bottom: var(--spacing-xxs);
.markdown-preview {
p {
word-break: break-word;
}
.channel-name {
font-size: var(--font-small);
}
}
}

View file

@ -1,6 +1,3 @@
$discussion-header__height: 3rem;
$recent-msg-button__height: 2rem;
.livestream { .livestream {
flex: 1; flex: 1;
width: 100%; width: 100%;
@ -26,199 +23,6 @@ $recent-msg-button__height: 2rem;
} }
} }
.livestream__discussion {
width: 100%;
@media (min-width: $breakpoint-medium) {
margin: 0;
width: var(--livestream-comments-width);
height: calc(100vh - var(--header-height));
position: fixed;
right: 0;
top: var(--header-height);
bottom: 0;
border-radius: 0;
border-top: none;
border-bottom: none;
border-right: none;
.card__main-actions {
padding: 0;
}
}
}
.livestream-discussion__header {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-s);
margin-bottom: 0;
align-items: center;
.recommended-content__toggles {
button {
height: unset;
padding: 5px;
}
.button__label {
max-height: 18px;
}
}
@media (min-width: $breakpoint-small) {
height: $discussion-header__height;
padding: 0 var(--spacing-s);
padding-right: 0;
}
@media (max-width: $breakpoint-small) {
padding: var(--spacing-xxs);
}
}
.livestream-discussion__title {
@extend .card__title-section;
@extend .card__title-section--small;
display: flex;
align-items: center;
padding: 0;
.menu__button {
margin-left: var(--spacing-xxs);
}
@media (max-width: $breakpoint-small) {
.menu__button {
margin-left: 5px;
}
}
}
.livestream__comments-wrapper {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height) - #{$discussion-header__height});
}
.livestream__comments {
display: flex;
flex-direction: column-reverse;
font-size: var(--font-small);
overflow-y: scroll;
overflow-x: visible;
padding-top: var(--spacing-s);
width: 100%;
}
.livestream-comment {
list-style-type: none;
position: relative;
.date_time {
color: var(--color-text-subtitle);
opacity: var(--live-timestamp-opacity);
}
&:hover {
.date_time {
opacity: 0.5;
}
}
@media (min-width: $breakpoint-small) {
&:hover {
background-color: var(--color-card-background-highlighted);
}
&:not(:hover) {
.menu__button:not(:focus):not([aria-expanded='true']) {
opacity: 0;
}
}
}
.channel-name {
font-size: var(--font-xsmall);
}
}
.livestream-comment--mentioned {
background-color: var(--color-card-background-highlighted);
}
.livestream-comment__info {
overflow: hidden;
}
.livestream-comment--superchat {
background-color: var(--color-card-background-highlighted);
+ .livestream-comment--superchat {
margin-bottom: var(--spacing-xxs);
}
.livestream-comment__body {
display: flex;
align-items: flex-start;
flex-direction: unset;
flex: unset;
}
.livestream-comment__info {
margin-top: calc(var(--spacing-xxs) / 2);
}
&::before {
position: absolute;
left: 0;
height: 100%;
max-height: 4rem;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
width: 5px;
background-color: var(--color-superchat);
content: '';
}
}
.livestream-comment__body {
display: flex;
align-items: flex-start;
margin-left: var(--spacing-s);
overflow: hidden;
.channel-thumbnail {
@include handleChannelGif(2rem);
margin-top: var(--spacing-xxs);
flex-shrink: 0;
}
}
.livestream-comment__menu {
position: absolute;
right: var(--spacing-xs);
top: var(--spacing-xs);
}
.livestream__comments__scroll-to-recent {
margin-top: -$recent-msg-button__height;
align-self: center;
margin-bottom: var(--spacing-xs);
font-size: var(--font-xsmall);
padding: var(--spacing-xxs) var(--spacing-s);
opacity: 0.9;
&:hover {
opacity: 1;
}
}
.livestream__comment-create {
padding: var(--spacing-s);
border-top: 1px solid var(--color-border);
margin-top: auto;
}
.livestream__channel-link { .livestream__channel-link {
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-xl);
box-shadow: 0 0 0 rgba(246, 72, 83, 0.4); box-shadow: 0 0 0 rgba(246, 72, 83, 0.4);
@ -280,204 +84,6 @@ $recent-msg-button__height: 2rem;
} }
} }
.livestream-superchats__wrapper {
flex-shrink: 0;
position: relative;
overflow-x: scroll;
padding: var(--spacing-s) var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-small);
background-color: var(--color-card-background);
@media (min-width: $breakpoint-small) {
padding: var(--spacing-xs);
width: var(--livestream-comments-width);
}
}
.livestream-pinned__wrapper {
display: flex;
flex-shrink: 0;
position: relative;
padding: var(--spacing-s) var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-small);
background-color: var(--color-card-background-highlighted);
width: 100%;
.livestream-comment {
width: 100%;
padding-top: var(--spacing-xs);
max-height: 6rem;
overflow-y: scroll;
}
.close-button {
border-left: 1px solid var(--color-border);
padding: 0 calc(var(--spacing-m) - var(--spacing-xs)) 0 var(--spacing-m);
color: var(--color-text-subtitle);
}
@media (min-width: $breakpoint-small) {
padding: var(--spacing-xs);
width: var(--livestream-comments-width);
}
}
.livestream-superchat__amount-large {
.credit-amount {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
}
.livestream-superchats__inner {
display: flex;
.close-button {
padding-left: var(--spacing-m);
padding-right: var(--spacing-l);
}
}
.livestream-superchat {
display: flex;
margin-right: var(--spacing-xs);
padding: var(--spacing-xxs);
border-radius: var(--border-radius);
.channel-thumbnail {
margin-right: var(--spacing-xs);
@include handleChannelGif(2rem);
}
&:first-of-type {
background-color: var(--color-superchat);
.channel-name {
max-width: 8rem;
}
}
&:nth-of-type(2) {
background-color: var(--color-superchat-2);
}
&:nth-of-type(3) {
background-color: var(--color-superchat-3);
}
&:nth-of-type(-n + 3) {
.channel-name,
.credit-amount {
color: var(--color-black);
}
}
.channel-name {
max-width: 5rem;
}
}
.livestream-superchat__info {
display: flex;
flex-direction: column;
justify-content: center;
font-size: var(--font-xsmall);
.button {
margin-top: calc(var(--spacing-xxs) / 2);
}
}
.livestream-superchat__info--sticker {
display: flex;
align-items: flex-start;
flex-direction: row;
width: 8rem;
height: 3rem;
.livestream-superchat__info--user {
.channel-name {
max-width: 5rem;
}
}
.livestream-superchat__info--image {
width: 100%;
height: 100%;
}
}
.livestream-superchat__info--not-sticker {
flex-direction: row;
}
.livestream-superchat__banner {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 0.25rem var(--spacing-s);
display: inline-block;
position: relative;
}
// This is just a two small circles that overlap to make it look like
// the banner and the left border are connected
.livestream-superchat__banner-corner {
height: calc(var(--border-radius) * 2);
width: calc(var(--border-radius) * 2);
border-radius: 50%;
position: absolute;
background-color: var(--color-superchat);
bottom: 0;
left: 0;
transform: translateX(0) translateY(50%);
&::after {
content: '';
height: calc(var(--border-radius) * 2);
width: calc(var(--border-radius) * 2);
border-top-left-radius: var(--border-radius);
background-color: var(--color-card-background);
position: absolute;
bottom: 0;
left: 0;
transform: translateX(25%) translateY(50%);
}
}
.livestream-comment__text {
padding-right: var(--spacing-xl);
padding-bottom: var(--spacing-xxs);
.markdown-preview {
p {
word-break: break-word;
}
.channel-name {
font-size: var(--font-small);
}
}
}
.livestream-superchat__tooltip-amount {
margin-top: var(--spacing-xs);
margin-left: 0;
background-color: transparent;
padding: 0;
}
.livestream__superchat-comment {
margin-top: var(--spacing-s);
max-width: 5rem;
overflow-wrap: break-word;
}
.livestream-superchat__amount-large {
min-width: 2.5rem;
}
.table--livestream-data { .table--livestream-data {
td:nth-of-type(1) { td:nth-of-type(1) {
max-width: 4rem; max-width: 4rem;

View file

@ -311,6 +311,14 @@
max-width: none; max-width: none;
} }
.main--popout-chat {
@extend .main;
margin: 0 !important;
padding: 0 !important;
width: 100vw !important;
height: 100vh !important;
}
.main--auth-page { .main--auth-page {
width: 100%; width: 100%;
max-width: 70rem; max-width: 70rem;

View file

@ -74,7 +74,7 @@
margin-bottom: var(--spacing-s); margin-bottom: var(--spacing-s);
} }
.super-chat--light { .superChat--light {
position: absolute; position: absolute;
display: inline; display: inline;
bottom: 0; bottom: 0;

View file

@ -1,4 +1,4 @@
.super-chat { .superChat {
border-radius: var(--border-radius); border-radius: var(--border-radius);
background: linear-gradient(to right, var(--color-superchat), var(--color-superchat-3)); background: linear-gradient(to right, var(--color-superchat), var(--color-superchat-3));
padding: 0.2rem var(--spacing-xs); padding: 0.2rem var(--spacing-xs);
@ -10,8 +10,8 @@
} }
} }
.super-chat--light { .superChat--light {
@extend .super-chat; @extend .superChat;
background: linear-gradient(to right, var(--color-superchat__light), var(--color-superchat-3__light)); background: linear-gradient(to right, var(--color-superchat__light), var(--color-superchat-3__light));
.credit-amount { .credit-amount {

View file

@ -101,5 +101,10 @@ export function parseSticker(comment: string) {
const stickerName = stickerValue && stickerValue.replace(/<stkr>/g, ''); const stickerName = stickerValue && stickerValue.replace(/<stkr>/g, '');
const commentIsSticker = stickerValue && stickerValue.length === comment.length; const commentIsSticker = stickerValue && stickerValue.length === comment.length;
return commentIsSticker && ALL_VALID_STICKERS.find((sticker) => sticker.name === stickerName); return commentIsSticker && ALL_VALID_STICKERS.find(({ name }) => name === stickerName);
}
export function getStickerUrl(comment: string) {
const stickerFromComment = parseSticker(comment);
return stickerFromComment && stickerFromComment.url;
} }

View file

@ -20,6 +20,8 @@ export const formatLbryUrlForWeb = (uri) => {
return newUrl; return newUrl;
}; };
export const formatLbryChannelName = (uri) => uri.replace('lbry://', '').replace(/#/g, ':');
export const formatFileSystemPath = (path) => { export const formatFileSystemPath = (path) => {
if (!path) { if (!path) {
return; return;