[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:
parent
b810e07053
commit
ea9c7a4a27
31 changed files with 1365 additions and 1123 deletions
|
@ -12,7 +12,7 @@ import DateTime from 'component/dateTime';
|
|||
import Button from 'component/button';
|
||||
import Expandable from 'component/expandable';
|
||||
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 { Menu, MenuButton } from '@reach/menu-button';
|
||||
import Icon from 'component/common/icon';
|
||||
|
@ -249,21 +249,8 @@ function Comment(props: Props) {
|
|||
<div className="comment__body-container">
|
||||
<div className="comment__meta">
|
||||
<div className="comment__meta-information">
|
||||
{isGlobalMod && (
|
||||
<Tooltip title={__('Admin')}>
|
||||
<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>
|
||||
)}
|
||||
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} />}
|
||||
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
|
||||
|
||||
{!author ? (
|
||||
<span className="comment__author">{__('Anonymous')}</span>
|
||||
|
|
|
@ -56,6 +56,7 @@ type Props = {
|
|||
shouldFetchComment: boolean,
|
||||
supportDisabled: boolean,
|
||||
uri: string,
|
||||
disableInput?: boolean,
|
||||
createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>,
|
||||
doFetchCreatorSettings: (channelId: string) => Promise<any>,
|
||||
doToast: ({ message: string }) => void,
|
||||
|
@ -84,6 +85,7 @@ export function CommentCreate(props: Props) {
|
|||
settingsByChannelId,
|
||||
shouldFetchComment,
|
||||
supportDisabled,
|
||||
disableInput,
|
||||
createComment,
|
||||
doFetchCreatorSettings,
|
||||
doToast,
|
||||
|
@ -125,7 +127,7 @@ export function CommentCreate(props: Props) {
|
|||
|
||||
const claimId = claim && claim.claim_id;
|
||||
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 channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
|
||||
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)
|
||||
*/
|
||||
function handleCreateComment(txid, payment_intent_id, environment) {
|
||||
if (isSubmitting) return;
|
||||
if (isSubmitting || disableInput) return;
|
||||
|
||||
setShowEmotes(false);
|
||||
setSubmitting(true);
|
||||
|
@ -468,7 +470,7 @@ export function CommentCreate(props: Props) {
|
|||
autoFocus={isReply}
|
||||
charCount={charCount}
|
||||
className={isReply ? 'create__reply' : 'create__comment'}
|
||||
disabled={isFetchingChannels}
|
||||
disabled={isFetchingChannels || disableInput}
|
||||
isLivestream={isLivestream}
|
||||
label={
|
||||
<div className="commentCreate__labelWrapper">
|
||||
|
@ -532,7 +534,7 @@ export function CommentCreate(props: Props) {
|
|||
<Button
|
||||
button="primary"
|
||||
label={__('Send')}
|
||||
disabled={isSupportComment && (tipError || disableReviewButton)}
|
||||
disabled={(isSupportComment && (tipError || disableReviewButton)) || disableInput}
|
||||
onClick={() => {
|
||||
if (isSupportComment) {
|
||||
handleSupportComment();
|
||||
|
|
35
ui/component/common/comment-badge.jsx
Normal file
35
ui/component/common/comment-badge.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
// @flow
|
||||
import 'scss/component/_superchat.scss';
|
||||
|
||||
import { formatCredits, formatFullPrice } from 'util/format-credits';
|
||||
import classnames from 'classnames';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
|
@ -97,10 +99,7 @@ class CreditAmount extends React.PureComponent<Props> {
|
|||
return (
|
||||
<span
|
||||
title={amount && !hideTitle ? formatFullPrice(amount, 2) : ''}
|
||||
className={classnames(className, {
|
||||
'super-chat': superChat,
|
||||
'super-chat--light': superChatLight,
|
||||
})}
|
||||
className={classnames(className, { superChat: superChat, 'superChat--light': superChatLight })}
|
||||
>
|
||||
{customAmounts
|
||||
? Object.values(customAmounts).map((amount, index) => (
|
||||
|
|
30
ui/component/livestreamChatLayout/index.js
Normal file
30
ui/component/livestreamChatLayout/index.js
Normal 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);
|
82
ui/component/livestreamChatLayout/livestream-superchats.jsx
Normal file
82
ui/component/livestreamChatLayout/livestream-superchats.jsx
Normal 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>
|
||||
);
|
||||
}
|
356
ui/component/livestreamChatLayout/view.jsx
Normal file
356
ui/component/livestreamChatLayout/view.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,20 +1,22 @@
|
|||
// @flow
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import { parseURI } from 'util/lbryURI';
|
||||
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 'scss/component/_livestream-comment.scss';
|
||||
|
||||
import { getStickerUrl } from 'util/comments';
|
||||
import { Menu, MenuButton } from '@reach/menu-button';
|
||||
import Icon from 'component/common/icon';
|
||||
import classnames from 'classnames';
|
||||
import CommentMenuList from 'component/commentMenuList';
|
||||
import { parseURI } from 'util/lbryURI';
|
||||
import * as ICONS from 'constants/icons';
|
||||
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 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 { parseSticker } from 'util/comments';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
comment: Comment,
|
||||
|
@ -22,21 +24,34 @@ type Props = {
|
|||
uri: string,
|
||||
// --- redux:
|
||||
claim: StreamClaim,
|
||||
stakedLevel: number,
|
||||
myChannelIds: ?Array<string>,
|
||||
stakedLevel: number,
|
||||
};
|
||||
|
||||
function LivestreamComment(props: Props) {
|
||||
const { comment, forceUpdate, uri, claim, stakedLevel, myChannelIds } = props;
|
||||
const { channel_url: authorUri, comment: message, support_amount: supportAmount, timestamp } = comment;
|
||||
export default function LivestreamComment(props: Props) {
|
||||
const { comment, forceUpdate, uri, claim, myChannelIds, stakedLevel } = props;
|
||||
|
||||
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 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 stickerFromMessage = parseSticker(message);
|
||||
const stickerUrlFromMessage = getStickerUrl(message);
|
||||
const isSticker = Boolean(stickerUrlFromMessage);
|
||||
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
|
||||
function isMyComment(channelId: string) {
|
||||
|
@ -45,62 +60,36 @@ function LivestreamComment(props: Props) {
|
|||
|
||||
return (
|
||||
<li
|
||||
className={classnames('livestream-comment', {
|
||||
'livestream-comment--superchat': supportAmount > 0,
|
||||
'livestream-comment--sticker': Boolean(stickerFromMessage),
|
||||
'livestream-comment--mentioned': hasUserMention,
|
||||
className={classnames('livestream__comment', {
|
||||
'livestream__comment--superchat': supportAmount > 0,
|
||||
'livestream__comment--sticker': isSticker,
|
||||
'livestream__comment--mentioned': hasUserMention,
|
||||
})}
|
||||
>
|
||||
{supportAmount > 0 && (
|
||||
<div className="super-chat livestream-superchat__banner">
|
||||
<div className="livestream-superchat__banner-corner" />
|
||||
<CreditAmount
|
||||
isFiat={comment.is_fiat}
|
||||
amount={supportAmount}
|
||||
superChat
|
||||
className="livestream-superchat__amount"
|
||||
/>
|
||||
<div className="livestreamComment__superchatBanner">
|
||||
<div className="livestreamComment__superchatBanner--corner" />
|
||||
<CreditAmount isFiat={isFiat} amount={supportAmount} superChat />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="livestream-comment__body">
|
||||
<div className="livestreamComment__body">
|
||||
{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 && (
|
||||
<Tooltip title={__('Moderator')}>
|
||||
<span className="comment__badge comment__badge--mod">
|
||||
<Icon icon={ICONS.BADGE_MOD} size={16} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{commentByOwnerOfContent && (
|
||||
<Tooltip title={__('Streamer')}>
|
||||
<span className="comment__badge">
|
||||
<Icon icon={ICONS.BADGE_STREAMER} size={16} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="livestreamComment__info">
|
||||
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} size={16} />}
|
||||
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} size={16} />}
|
||||
{isStreamer && <CommentBadge label={__('Streamer')} icon={ICONS.BADGE_STREAMER} size={16} />}
|
||||
|
||||
<Button
|
||||
className={classnames('button--uri-indicator comment__author', {
|
||||
'comment__author--creator': commentByOwnerOfContent,
|
||||
})}
|
||||
className={classnames('button--uri-indicator comment__author', { 'comment__author--creator': isStreamer })}
|
||||
target="_blank"
|
||||
navigate={authorUri}
|
||||
>
|
||||
{claimName}
|
||||
</Button>
|
||||
|
||||
{comment.is_pinned && (
|
||||
{isPinned && (
|
||||
<span className="comment__pin">
|
||||
<Icon icon={ICONS.PIN} size={14} />
|
||||
{__('Pinned')}
|
||||
|
@ -110,16 +99,15 @@ function LivestreamComment(props: Props) {
|
|||
{/* Use key to force timestamp update */}
|
||||
<DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds />
|
||||
|
||||
{comment.removed ? (
|
||||
<div className="livestream-comment__text">
|
||||
<Empty text={__('[Removed]')} />
|
||||
</div>
|
||||
) : stickerFromMessage ? (
|
||||
{isSticker ? (
|
||||
<div className="sticker__comment">
|
||||
<OptimizedImage src={stickerFromMessage.url} waitLoad loading="lazy" />
|
||||
<OptimizedImage src={stickerUrlFromMessage} waitLoad loading="lazy" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="livestream-comment__text">
|
||||
<div className="livestreamComment__text">
|
||||
{removed ? (
|
||||
<Empty text={__('[Removed]')} />
|
||||
) : (
|
||||
<MarkdownPreview
|
||||
content={message}
|
||||
promptLinks
|
||||
|
@ -127,22 +115,24 @@ function LivestreamComment(props: Props) {
|
|||
disableTimestamps
|
||||
setUserMention={setUserMention}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="livestream-comment__menu">
|
||||
<div className="livestreamComment__menu">
|
||||
<Menu>
|
||||
<MenuButton className="menu__button">
|
||||
<Icon size={18} icon={ICONS.MORE_VERTICAL} />
|
||||
</MenuButton>
|
||||
|
||||
<CommentMenuList
|
||||
uri={uri}
|
||||
commentId={comment.comment_id}
|
||||
commentId={commentId}
|
||||
authorUri={authorUri}
|
||||
commentIsMine={commentIsMine}
|
||||
isPinned={comment.is_pinned}
|
||||
isPinned={isPinned}
|
||||
isTopLevel
|
||||
disableEdit
|
||||
isLiveComment
|
||||
|
@ -152,5 +142,3 @@ function LivestreamComment(props: Props) {
|
|||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default LivestreamComment;
|
||||
|
|
|
@ -1,29 +1,9 @@
|
|||
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,
|
||||
selectIsFetchingComments,
|
||||
selectSuperChatsForUri,
|
||||
selectSuperChatTotalAmountForUri,
|
||||
selectPinnedCommentsForUri,
|
||||
} from 'redux/selectors/comments';
|
||||
|
||||
import { selectIsFetchingComments } from 'redux/selectors/comments';
|
||||
import LivestreamComments from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: selectClaimForUri(state, props.uri),
|
||||
comments: selectTopLevelCommentsForUri(state, props.uri, MAX_LIVESTREAM_COMMENTS),
|
||||
pinnedComments: selectPinnedCommentsForUri(state, props.uri),
|
||||
const select = (state) => ({
|
||||
fetchingComments: selectIsFetchingComments(state),
|
||||
superChats: selectSuperChatsForUri(state, props.uri),
|
||||
superChatsTotalAmount: selectSuperChatTotalAmountForUri(state, props.uri),
|
||||
});
|
||||
|
||||
export default connect(select, {
|
||||
doCommentList,
|
||||
doSuperChatList,
|
||||
doResolveUris,
|
||||
})(LivestreamComments);
|
||||
export default connect(select)(LivestreamComments);
|
||||
|
|
|
@ -1,89 +1,27 @@
|
|||
// @flow
|
||||
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Spinner from 'component/spinner';
|
||||
import CommentCreate from 'component/commentCreate';
|
||||
import 'scss/component/_livestream-chat.scss';
|
||||
|
||||
import LivestreamComment from 'component/livestreamComment';
|
||||
import Button from 'component/button';
|
||||
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';
|
||||
import React from 'react';
|
||||
|
||||
// 30 sec timestamp refresh timer
|
||||
const UPDATE_TIMESTAMP_MS = 30 * 1000;
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: ?StreamClaim,
|
||||
embed?: boolean,
|
||||
doCommentList: (string, string, number, number) => void,
|
||||
comments: Array<Comment>,
|
||||
pinnedComments: Array<Comment>,
|
||||
commentsToDisplay: Array<Comment>,
|
||||
fetchingComments: boolean,
|
||||
doSuperChatList: (string) => void,
|
||||
superChats: Array<Comment>,
|
||||
doResolveUris: (Array<string>, boolean) => void,
|
||||
uri: string,
|
||||
};
|
||||
|
||||
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) {
|
||||
const {
|
||||
claim,
|
||||
uri,
|
||||
embed,
|
||||
comments: commentsByChronologicalOrder,
|
||||
pinnedComments,
|
||||
doCommentList,
|
||||
fetchingComments,
|
||||
doSuperChatList,
|
||||
superChats: superChatsByAmount,
|
||||
doResolveUris,
|
||||
} = props;
|
||||
const { commentsToDisplay, fetchingComments, uri } = 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 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 shouldRefreshTimestamp =
|
||||
commentsByChronologicalOrder &&
|
||||
commentsByChronologicalOrder.some((comment) => {
|
||||
commentsToDisplay &&
|
||||
commentsToDisplay.some((comment) => {
|
||||
const { timestamp } = comment;
|
||||
const timePosted = timestamp * 1000;
|
||||
|
||||
|
@ -91,32 +29,6 @@ export default function LivestreamComments(props: Props) {
|
|||
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
|
||||
React.useEffect(() => {
|
||||
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
|
||||
}, [shouldRefreshTimestamp, forceUpdate]);
|
||||
|
||||
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 && 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;
|
||||
}
|
||||
|
||||
/* top to bottom comment display */
|
||||
if (!fetchingComments && commentsToDisplay && commentsToDisplay.length > 0) {
|
||||
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">
|
||||
{viewMode === VIEW_MODES.CHAT &&
|
||||
commentsToDisplay.map((comment) => (
|
||||
{commentsToDisplay.map((comment) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="main--empty" style={{ flex: 1 }} />;
|
||||
}
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { connect } from 'react-redux';
|
||||
import {
|
||||
makeSelectClaimForUri,
|
||||
makeSelectTagInClaimOrChannelForUri,
|
||||
selectThumbnailForUri,
|
||||
} from 'redux/selectors/claims';
|
||||
import { selectClaimForUri, makeSelectTagInClaimOrChannelForUri, selectThumbnailForUri } from 'redux/selectors/claims';
|
||||
import LivestreamLayout from './view';
|
||||
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
thumbnail: selectThumbnailForUri(state, props.uri),
|
||||
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
|
||||
});
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
||||
return {
|
||||
claim: selectClaimForUri(state, uri),
|
||||
thumbnail: selectThumbnailForUri(state, uri),
|
||||
chatDisabled: makeSelectTagInClaimOrChannelForUri(uri, DISABLE_COMMENTS_TAG)(state),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(select)(LivestreamLayout);
|
||||
|
|
|
@ -1,46 +1,43 @@
|
|||
// @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 { 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 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 = {
|
||||
uri: string,
|
||||
activeStreamUri: boolean | string,
|
||||
claim: ?StreamClaim,
|
||||
hideComments: boolean,
|
||||
isCurrentClaimLive: boolean,
|
||||
release: any,
|
||||
showLivestream: boolean,
|
||||
showScheduledInfo: boolean,
|
||||
isCurrentClaimLive: boolean,
|
||||
activeStreamUri: boolean | string,
|
||||
uri: string,
|
||||
};
|
||||
|
||||
export default function LivestreamLayout(props: Props) {
|
||||
const {
|
||||
activeStreamUri,
|
||||
claim,
|
||||
uri,
|
||||
hideComments,
|
||||
isCurrentClaimLive,
|
||||
release,
|
||||
showLivestream,
|
||||
showScheduledInfo,
|
||||
isCurrentClaimLive,
|
||||
activeStreamUri,
|
||||
uri,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!claim || !claim.signing_channel) {
|
||||
return null;
|
||||
}
|
||||
if (!claim || !claim.signing_channel) return null;
|
||||
|
||||
const channelName = claim.signing_channel.name;
|
||||
const channelClaimId = claim.signing_channel.claim_id;
|
||||
const { name: channelName, claim_id: channelClaimId } = claim.signing_channel;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -58,6 +55,7 @@ export default function LivestreamLayout(props: Props) {
|
|||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScheduledInfo && <LivestreamScheduledInfo release={release} />}
|
||||
</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} />
|
||||
</div>
|
||||
|
|
|
@ -1,109 +1,89 @@
|
|||
// @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 SideNavigation from 'component/sideNavigation';
|
||||
import SettingsSideNavigation from 'component/settingsSideNavigation';
|
||||
import Header from 'component/header';
|
||||
/* @if TARGET='app' */
|
||||
import StatusBar from 'component/common/status-bar';
|
||||
/* @endif */
|
||||
import usePersistedState from 'effects/use-persisted-state';
|
||||
import { MAIN_CLASS } from 'constants/classnames';
|
||||
import { parseURI } from 'util/lbryURI';
|
||||
import { useHistory } from 'react-router';
|
||||
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" */));
|
||||
|
||||
type Props = {
|
||||
children: Node | Array<Node>,
|
||||
className: ?string,
|
||||
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: {
|
||||
backLabel?: string,
|
||||
backNavDefault?: 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
|
||||
},
|
||||
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) {
|
||||
const {
|
||||
authPage = false,
|
||||
backout,
|
||||
chatDisabled,
|
||||
children,
|
||||
className,
|
||||
filePage = false,
|
||||
settingsPage,
|
||||
authPage = false,
|
||||
fullWidthPage = false,
|
||||
noHeader = false,
|
||||
noFooter = false,
|
||||
noSideNavigation = false,
|
||||
backout,
|
||||
videoTheaterMode,
|
||||
isMarkdown = false,
|
||||
livestream,
|
||||
noFooter = false,
|
||||
noHeader = false,
|
||||
noSideNavigation = false,
|
||||
rightSide,
|
||||
chatDisabled,
|
||||
settingsPage,
|
||||
videoTheaterMode,
|
||||
isPopoutWindow,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
location: { pathname },
|
||||
} = useHistory();
|
||||
const [sidebarOpen, setSidebarOpen] = usePersistedState('sidebar', false);
|
||||
|
||||
const isMediumScreen = useIsMediumScreen();
|
||||
const isMobile = useIsMobile();
|
||||
const [sidebarOpen, setSidebarOpen] = usePersistedState('sidebar', false);
|
||||
|
||||
let isOnFilePage = false;
|
||||
try {
|
||||
const url = pathname.slice(1).replace(/:/g, '#');
|
||||
const { isChannel } = parseURI(url);
|
||||
if (!isChannel) {
|
||||
isOnFilePage = true;
|
||||
}
|
||||
|
||||
if (!isChannel) isOnFilePage = true;
|
||||
} catch (e) {}
|
||||
|
||||
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(() => {
|
||||
if (isOnFilePage || isMediumScreen) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
if (isOnFilePage || isMediumScreen) setSidebarOpen(false);
|
||||
|
||||
// TODO: make sure setState callback for usePersistedState uses useCallback to it doesn't cause effect to re-run
|
||||
}, [isOnFilePage, isMediumScreen]);
|
||||
}, [isOnFilePage, isMediumScreen, setSidebarOpen]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
{!noHeader && (
|
||||
<Header
|
||||
authHeader={authPage}
|
||||
|
@ -113,14 +93,28 @@ function Page(props: Props) {
|
|||
setSidebarOpen={setSidebarOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classnames('main-wrapper__inner', {
|
||||
'main-wrapper__inner--filepage': isOnFilePage,
|
||||
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode,
|
||||
'main-wrapper__inner--auth': authPage,
|
||||
'main--popout-chat': isPopoutWindow,
|
||||
})}
|
||||
>
|
||||
{getSideNavElem()}
|
||||
{!authPage &&
|
||||
(settingsPage ? (
|
||||
<SettingsSideNavigation />
|
||||
) : (
|
||||
!noSideNavigation && (
|
||||
<SideNavigation
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
isMediumScreen={isMediumScreen}
|
||||
isOnFilePage={isOnFilePage}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
|
||||
<div
|
||||
className={classnames({
|
||||
|
@ -139,25 +133,24 @@ function Page(props: Props) {
|
|||
'main--markdown': isMarkdown,
|
||||
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream,
|
||||
'main--livestream': livestream && !chatDisabled,
|
||||
'main--popout-chat': isPopoutWindow,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
{!isMobile && rightSide && <div className="main__right-side">{rightSide}</div>}
|
||||
{!isMobile && rightSide && (!livestream || !chatDisabled) && (
|
||||
<div className="main__right-side">{rightSide}</div>
|
||||
)}
|
||||
</main>
|
||||
{/* @if TARGET='web' */}
|
||||
|
||||
{!noFooter && (
|
||||
<React.Suspense fallback={null}>
|
||||
<Footer />
|
||||
</React.Suspense>
|
||||
)}
|
||||
{/* @endif */}
|
||||
</div>
|
||||
{/* @if TARGET='app' */}
|
||||
<StatusBar />
|
||||
{/* @endif */}
|
||||
</div>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -52,6 +52,7 @@ const CheckoutPage = lazyImport(() => import('page/checkoutPage' /* webpackChunk
|
|||
const CreatorDashboard = lazyImport(() => import('page/creatorDashboard' /* webpackChunkName: "creatorDashboard" */));
|
||||
const DiscoverPage = lazyImport(() => import('page/discover' /* webpackChunkName: "discover" */));
|
||||
const EmbedWrapperPage = lazyImport(() => import('page/embedWrapper' /* webpackChunkName: "embedWrapper" */));
|
||||
const PopoutChatPage = lazyImport(() => import('page/popoutChatWrapper' /* webpackChunkName: "popoutChat" */));
|
||||
const FileListPublished = lazyImport(() =>
|
||||
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.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/:claimId`} exact component={EmbedWrapperPage} />
|
||||
|
||||
|
|
|
@ -83,3 +83,4 @@ exports.LIVESTREAM = 'livestream';
|
|||
exports.LIVESTREAM_CURRENT = 'live';
|
||||
exports.GENERAL = 'general';
|
||||
exports.LIST = 'list';
|
||||
exports.POPOUT = 'popout';
|
||||
|
|
|
@ -11,11 +11,13 @@ import { doFetchChannelLiveStatus } from 'redux/actions/livestream';
|
|||
import LivestreamPage from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
const channelClaimId = getChannelIdFromClaim(selectClaimForUri(state, props.uri));
|
||||
const { uri } = props;
|
||||
const channelClaimId = getChannelIdFromClaim(selectClaimForUri(state, uri));
|
||||
|
||||
return {
|
||||
isAuthenticated: selectUserVerifiedEmail(state),
|
||||
channelClaimId,
|
||||
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
|
||||
chatDisabled: makeSelectTagInClaimOrChannelForUri(uri, DISABLE_COMMENTS_TAG)(state),
|
||||
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId),
|
||||
activeLivestreamInitialized: selectActiveLivestreamInitialized(state),
|
||||
};
|
||||
|
|
|
@ -1,69 +1,83 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import { formatLbryChannelName } from 'util/url';
|
||||
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 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 = {
|
||||
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,
|
||||
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) {
|
||||
const {
|
||||
uri,
|
||||
claim,
|
||||
doSetPlayingUri,
|
||||
isAuthenticated,
|
||||
doUserSetReferrer,
|
||||
activeLivestreamForChannel,
|
||||
activeLivestreamInitialized,
|
||||
channelClaimId,
|
||||
chatDisabled,
|
||||
claim,
|
||||
isAuthenticated,
|
||||
uri,
|
||||
doSetPlayingUri,
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
doFetchChannelLiveStatus,
|
||||
activeLivestreamForChannel,
|
||||
activeLivestreamInitialized,
|
||||
doUserSetReferrer,
|
||||
} = 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(() => {
|
||||
// TODO: This should not be needed one we unify the livestream player (?)
|
||||
analytics.playerLoadedEvent('livestream', false);
|
||||
}, []);
|
||||
|
||||
const claimId = claim && claim.claim_id;
|
||||
|
||||
// Establish web socket connection for viewer count.
|
||||
React.useEffect(() => {
|
||||
if (claimId) {
|
||||
doCommentSocketConnect(uri, claimId);
|
||||
if (!claim) return;
|
||||
|
||||
const { claim_id: claimId, signing_channel: channelClaim } = claim;
|
||||
const channelName = channelClaim && formatLbryChannelName(channelClaim.canonical_url);
|
||||
|
||||
if (claimId && channelName) {
|
||||
doCommentSocketConnect(uri, channelName, claimId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (claimId) {
|
||||
doCommentSocketDisconnect(claimId);
|
||||
if (claimId && channelName) {
|
||||
doCommentSocketDisconnect(claimId, channelName);
|
||||
}
|
||||
};
|
||||
}, [claimId, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
|
||||
|
||||
const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized;
|
||||
const isChannelBroadcasting = Boolean(activeLivestreamForChannel);
|
||||
const isCurrentClaimLive = isChannelBroadcasting && activeLivestreamForChannel.claimId === claimId;
|
||||
const livestreamChannelId = channelClaimId || '';
|
||||
}, [claim, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
|
||||
|
||||
// Find out current channels status + active live claim.
|
||||
React.useEffect(() => {
|
||||
|
@ -72,19 +86,10 @@ export default function LivestreamPage(props: Props) {
|
|||
return () => clearInterval(intervalId);
|
||||
}, [livestreamChannelId, doFetchChannelLiveStatus]);
|
||||
|
||||
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveStreamUri(!isCurrentClaimLive && isChannelBroadcasting ? activeLivestreamForChannel.claimUri : false);
|
||||
}, [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(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
|
@ -125,19 +130,15 @@ export default function LivestreamPage(props: Props) {
|
|||
return () => clearInterval(intervalId);
|
||||
}, [chatDisabled, isChannelBroadcasting, release, isCurrentClaimLive, isInitialized]);
|
||||
|
||||
const stringifiedClaim = JSON.stringify(claim);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (uri && stringifiedClaim) {
|
||||
const jsonClaim = JSON.parse(stringifiedClaim);
|
||||
if (!isAuthenticated) {
|
||||
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
|
||||
if (uri) {
|
||||
doUserSetReferrer(uri.replace('lbry://', '')); //
|
||||
if (uri) doUserSetReferrer(uri.replace('lbry://', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [uri, stringifiedClaim, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [uri, stringifiedClaim, isAuthenticated, doUserSetReferrer]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// 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 &&
|
||||
isInitialized && (
|
||||
<React.Suspense fallback={null}>
|
||||
<LivestreamComments uri={uri} />
|
||||
<LivestreamChatLayout uri={uri} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
|
27
ui/page/popoutChatWrapper/index.js
Normal file
27
ui/page/popoutChatWrapper/index.js
Normal 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);
|
40
ui/page/popoutChatWrapper/view.jsx
Normal file
40
ui/page/popoutChatWrapper/view.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -5,16 +5,24 @@ import { SOCKETY_SERVER_API } from 'config';
|
|||
|
||||
const NOTIFICATION_WS_URL = `${SOCKETY_SERVER_API}/internal?id=`;
|
||||
const COMMENT_WS_URL = `${SOCKETY_SERVER_API}/commentron?id=`;
|
||||
const COMMENT_WS_SUBCATEGORIES = {
|
||||
COMMENTER: 'commenter',
|
||||
VIEWER: 'viewer',
|
||||
};
|
||||
|
||||
let sockets = {};
|
||||
let closingSockets = {};
|
||||
let retryCount = 0;
|
||||
|
||||
const getCommentSocketUrl = (claimId) => {
|
||||
return `${COMMENT_WS_URL}${claimId}&category=${claimId}`;
|
||||
const getCommentSocketUrl = (claimId, channelName) => {
|
||||
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() {
|
||||
if (sockets[url] !== undefined && sockets[url] !== null) {
|
||||
sockets[url].close();
|
||||
|
@ -26,7 +34,7 @@ export const doSocketConnect = (url, cb) => {
|
|||
sockets[url] = new WebSocket(url);
|
||||
sockets[url].onopen = (e) => {
|
||||
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) => {
|
||||
|
@ -35,12 +43,12 @@ export const doSocketConnect = (url, cb) => {
|
|||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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]) {
|
||||
retryCount += 1;
|
||||
connectToSocket();
|
||||
|
@ -75,7 +83,9 @@ export const doNotificationSocketConnect = (enableNotifications) => (dispatch) =
|
|||
|
||||
const url = `${NOTIFICATION_WS_URL}${authToken}`;
|
||||
|
||||
doSocketConnect(url, (data) => {
|
||||
doSocketConnect(
|
||||
url,
|
||||
(data) => {
|
||||
switch (data.type) {
|
||||
case 'pending_notification':
|
||||
if (enableNotifications) {
|
||||
|
@ -89,13 +99,20 @@ export const doNotificationSocketConnect = (enableNotifications) => (dispatch) =
|
|||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
'notification'
|
||||
);
|
||||
};
|
||||
|
||||
export const doCommentSocketConnect = (uri, claimId) => (dispatch) => {
|
||||
const url = getCommentSocketUrl(claimId);
|
||||
export const doCommentSocketConnect = (uri, channelName, claimId, subCategory) => (dispatch) => {
|
||||
const url =
|
||||
subCategory === COMMENT_WS_SUBCATEGORIES.COMMENTER
|
||||
? getCommentSocketUrlForCommenter(claimId, channelName)
|
||||
: getCommentSocketUrl(claimId, channelName);
|
||||
|
||||
doSocketConnect(url, (response) => {
|
||||
doSocketConnect(
|
||||
url,
|
||||
(response) => {
|
||||
if (response.type === 'delta') {
|
||||
const newComment = response.data.comment;
|
||||
dispatch({
|
||||
|
@ -128,11 +145,23 @@ export const doCommentSocketConnect = (uri, claimId) => (dispatch) => {
|
|||
data: { comment_id },
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
'comment'
|
||||
);
|
||||
};
|
||||
|
||||
export const doCommentSocketDisconnect = (claimId) => (dispatch) => {
|
||||
const url = getCommentSocketUrl(claimId);
|
||||
export const doCommentSocketDisconnect = (claimId, channelName) => (dispatch) => {
|
||||
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));
|
||||
};
|
||||
|
|
|
@ -54,7 +54,6 @@
|
|||
@import 'component/spinner';
|
||||
@import 'component/splash';
|
||||
@import 'component/status-bar';
|
||||
@import 'component/superchat';
|
||||
@import 'component/syntax-highlighter';
|
||||
@import 'component/table';
|
||||
@import 'component/livestream';
|
||||
|
|
21
ui/scss/component/_comment-badge.scss
Normal file
21
ui/scss/component/_comment-badge.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
word-break: break-word;
|
||||
max-width: 35rem;
|
||||
|
|
274
ui/scss/component/_livestream-chat.scss
Normal file
274
ui/scss/component/_livestream-chat.scss
Normal 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;
|
||||
}
|
141
ui/scss/component/_livestream-comment.scss
Normal file
141
ui/scss/component/_livestream-comment.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,3 @@
|
|||
$discussion-header__height: 3rem;
|
||||
$recent-msg-button__height: 2rem;
|
||||
|
||||
.livestream {
|
||||
flex: 1;
|
||||
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 {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
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 {
|
||||
td:nth-of-type(1) {
|
||||
max-width: 4rem;
|
||||
|
|
|
@ -311,6 +311,14 @@
|
|||
max-width: none;
|
||||
}
|
||||
|
||||
.main--popout-chat {
|
||||
@extend .main;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
.main--auth-page {
|
||||
width: 100%;
|
||||
max-width: 70rem;
|
||||
|
|
|
@ -74,7 +74,7 @@
|
|||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.super-chat--light {
|
||||
.superChat--light {
|
||||
position: absolute;
|
||||
display: inline;
|
||||
bottom: 0;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.super-chat {
|
||||
.superChat {
|
||||
border-radius: var(--border-radius);
|
||||
background: linear-gradient(to right, var(--color-superchat), var(--color-superchat-3));
|
||||
padding: 0.2rem var(--spacing-xs);
|
||||
|
@ -10,8 +10,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.super-chat--light {
|
||||
@extend .super-chat;
|
||||
.superChat--light {
|
||||
@extend .superChat;
|
||||
background: linear-gradient(to right, var(--color-superchat__light), var(--color-superchat-3__light));
|
||||
|
||||
.credit-amount {
|
||||
|
|
|
@ -101,5 +101,10 @@ export function parseSticker(comment: string) {
|
|||
const stickerName = stickerValue && stickerValue.replace(/<stkr>/g, '');
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,8 @@ export const formatLbryUrlForWeb = (uri) => {
|
|||
return newUrl;
|
||||
};
|
||||
|
||||
export const formatLbryChannelName = (uri) => uri.replace('lbry://', '').replace(/#/g, ':');
|
||||
|
||||
export const formatFileSystemPath = (path) => {
|
||||
if (!path) {
|
||||
return;
|
||||
|
|
Loading…
Reference in a new issue