[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 Button from 'component/button';
|
||||||
import Expandable from 'component/expandable';
|
import Expandable from 'component/expandable';
|
||||||
import MarkdownPreview from 'component/common/markdown-preview';
|
import MarkdownPreview from 'component/common/markdown-preview';
|
||||||
import Tooltip from 'component/common/tooltip';
|
import CommentBadge from 'component/common/comment-badge';
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
import ChannelThumbnail from 'component/channelThumbnail';
|
||||||
import { Menu, MenuButton } from '@reach/menu-button';
|
import { Menu, MenuButton } from '@reach/menu-button';
|
||||||
import Icon from 'component/common/icon';
|
import Icon from 'component/common/icon';
|
||||||
|
@ -249,21 +249,8 @@ function Comment(props: Props) {
|
||||||
<div className="comment__body-container">
|
<div className="comment__body-container">
|
||||||
<div className="comment__meta">
|
<div className="comment__meta">
|
||||||
<div className="comment__meta-information">
|
<div className="comment__meta-information">
|
||||||
{isGlobalMod && (
|
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} />}
|
||||||
<Tooltip title={__('Admin')}>
|
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
|
||||||
<span className="comment__badge comment__badge--global-mod">
|
|
||||||
<Icon icon={ICONS.BADGE_MOD} size={20} />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isModerator && (
|
|
||||||
<Tooltip title={__('Moderator')}>
|
|
||||||
<span className="comment__badge comment__badge--mod">
|
|
||||||
<Icon icon={ICONS.BADGE_MOD} size={20} />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!author ? (
|
{!author ? (
|
||||||
<span className="comment__author">{__('Anonymous')}</span>
|
<span className="comment__author">{__('Anonymous')}</span>
|
||||||
|
|
|
@ -56,6 +56,7 @@ type Props = {
|
||||||
shouldFetchComment: boolean,
|
shouldFetchComment: boolean,
|
||||||
supportDisabled: boolean,
|
supportDisabled: boolean,
|
||||||
uri: string,
|
uri: string,
|
||||||
|
disableInput?: boolean,
|
||||||
createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>,
|
createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>,
|
||||||
doFetchCreatorSettings: (channelId: string) => Promise<any>,
|
doFetchCreatorSettings: (channelId: string) => Promise<any>,
|
||||||
doToast: ({ message: string }) => void,
|
doToast: ({ message: string }) => void,
|
||||||
|
@ -84,6 +85,7 @@ export function CommentCreate(props: Props) {
|
||||||
settingsByChannelId,
|
settingsByChannelId,
|
||||||
shouldFetchComment,
|
shouldFetchComment,
|
||||||
supportDisabled,
|
supportDisabled,
|
||||||
|
disableInput,
|
||||||
createComment,
|
createComment,
|
||||||
doFetchCreatorSettings,
|
doFetchCreatorSettings,
|
||||||
doToast,
|
doToast,
|
||||||
|
@ -125,7 +127,7 @@ export function CommentCreate(props: Props) {
|
||||||
|
|
||||||
const claimId = claim && claim.claim_id;
|
const claimId = claim && claim.claim_id;
|
||||||
const charCount = commentValue ? commentValue.length : 0;
|
const charCount = commentValue ? commentValue.length : 0;
|
||||||
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length;
|
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || disableInput;
|
||||||
const channelId = getChannelIdFromClaim(claim);
|
const channelId = getChannelIdFromClaim(claim);
|
||||||
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
|
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
|
||||||
const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0;
|
const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0;
|
||||||
|
@ -255,7 +257,7 @@ export function CommentCreate(props: Props) {
|
||||||
* @param {string} [environment] Optional environment for Stripe (test|live)
|
* @param {string} [environment] Optional environment for Stripe (test|live)
|
||||||
*/
|
*/
|
||||||
function handleCreateComment(txid, payment_intent_id, environment) {
|
function handleCreateComment(txid, payment_intent_id, environment) {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting || disableInput) return;
|
||||||
|
|
||||||
setShowEmotes(false);
|
setShowEmotes(false);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
@ -468,7 +470,7 @@ export function CommentCreate(props: Props) {
|
||||||
autoFocus={isReply}
|
autoFocus={isReply}
|
||||||
charCount={charCount}
|
charCount={charCount}
|
||||||
className={isReply ? 'create__reply' : 'create__comment'}
|
className={isReply ? 'create__reply' : 'create__comment'}
|
||||||
disabled={isFetchingChannels}
|
disabled={isFetchingChannels || disableInput}
|
||||||
isLivestream={isLivestream}
|
isLivestream={isLivestream}
|
||||||
label={
|
label={
|
||||||
<div className="commentCreate__labelWrapper">
|
<div className="commentCreate__labelWrapper">
|
||||||
|
@ -532,7 +534,7 @@ export function CommentCreate(props: Props) {
|
||||||
<Button
|
<Button
|
||||||
button="primary"
|
button="primary"
|
||||||
label={__('Send')}
|
label={__('Send')}
|
||||||
disabled={isSupportComment && (tipError || disableReviewButton)}
|
disabled={(isSupportComment && (tipError || disableReviewButton)) || disableInput}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isSupportComment) {
|
if (isSupportComment) {
|
||||||
handleSupportComment();
|
handleSupportComment();
|
||||||
|
|
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
|
// @flow
|
||||||
|
import 'scss/component/_superchat.scss';
|
||||||
|
|
||||||
import { formatCredits, formatFullPrice } from 'util/format-credits';
|
import { formatCredits, formatFullPrice } from 'util/format-credits';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import LbcSymbol from 'component/common/lbc-symbol';
|
import LbcSymbol from 'component/common/lbc-symbol';
|
||||||
|
@ -97,10 +99,7 @@ class CreditAmount extends React.PureComponent<Props> {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={amount && !hideTitle ? formatFullPrice(amount, 2) : ''}
|
title={amount && !hideTitle ? formatFullPrice(amount, 2) : ''}
|
||||||
className={classnames(className, {
|
className={classnames(className, { superChat: superChat, 'superChat--light': superChatLight })}
|
||||||
'super-chat': superChat,
|
|
||||||
'super-chat--light': superChatLight,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{customAmounts
|
{customAmounts
|
||||||
? Object.values(customAmounts).map((amount, index) => (
|
? Object.values(customAmounts).map((amount, index) => (
|
||||||
|
|
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
|
// @flow
|
||||||
import * as ICONS from 'constants/icons';
|
import 'scss/component/_livestream-comment.scss';
|
||||||
import React from 'react';
|
|
||||||
import { parseURI } from 'util/lbryURI';
|
import { getStickerUrl } from 'util/comments';
|
||||||
import Empty from 'component/common/empty';
|
|
||||||
import MarkdownPreview from 'component/common/markdown-preview';
|
|
||||||
import Tooltip from 'component/common/tooltip';
|
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
|
||||||
import { Menu, MenuButton } from '@reach/menu-button';
|
import { Menu, MenuButton } from '@reach/menu-button';
|
||||||
import Icon from 'component/common/icon';
|
import { parseURI } from 'util/lbryURI';
|
||||||
import classnames from 'classnames';
|
import * as ICONS from 'constants/icons';
|
||||||
import CommentMenuList from 'component/commentMenuList';
|
|
||||||
import Button from 'component/button';
|
import Button from 'component/button';
|
||||||
|
import ChannelThumbnail from 'component/channelThumbnail';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import CommentBadge from 'component/common/comment-badge';
|
||||||
|
import CommentMenuList from 'component/commentMenuList';
|
||||||
import CreditAmount from 'component/common/credit-amount';
|
import CreditAmount from 'component/common/credit-amount';
|
||||||
import DateTime from 'component/dateTime';
|
import DateTime from 'component/dateTime';
|
||||||
|
import Empty from 'component/common/empty';
|
||||||
|
import Icon from 'component/common/icon';
|
||||||
|
import MarkdownPreview from 'component/common/markdown-preview';
|
||||||
import OptimizedImage from 'component/optimizedImage';
|
import OptimizedImage from 'component/optimizedImage';
|
||||||
import { parseSticker } from 'util/comments';
|
import React from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
comment: Comment,
|
comment: Comment,
|
||||||
|
@ -22,21 +24,34 @@ type Props = {
|
||||||
uri: string,
|
uri: string,
|
||||||
// --- redux:
|
// --- redux:
|
||||||
claim: StreamClaim,
|
claim: StreamClaim,
|
||||||
stakedLevel: number,
|
|
||||||
myChannelIds: ?Array<string>,
|
myChannelIds: ?Array<string>,
|
||||||
|
stakedLevel: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
function LivestreamComment(props: Props) {
|
export default function LivestreamComment(props: Props) {
|
||||||
const { comment, forceUpdate, uri, claim, stakedLevel, myChannelIds } = props;
|
const { comment, forceUpdate, uri, claim, myChannelIds, stakedLevel } = props;
|
||||||
const { channel_url: authorUri, comment: message, support_amount: supportAmount, timestamp } = comment;
|
|
||||||
|
const {
|
||||||
|
channel_url: authorUri,
|
||||||
|
comment_id: commentId,
|
||||||
|
comment: message,
|
||||||
|
is_fiat: isFiat,
|
||||||
|
is_global_mod: isGlobalMod,
|
||||||
|
is_moderator: isModerator,
|
||||||
|
is_pinned: isPinned,
|
||||||
|
removed,
|
||||||
|
support_amount: supportAmount,
|
||||||
|
timestamp,
|
||||||
|
} = comment;
|
||||||
|
|
||||||
const [hasUserMention, setUserMention] = React.useState(false);
|
const [hasUserMention, setUserMention] = React.useState(false);
|
||||||
const commentIsMine = comment.channel_id && isMyComment(comment.channel_id);
|
|
||||||
|
|
||||||
const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
|
const isStreamer = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
|
||||||
const { claimName } = parseURI(authorUri || '');
|
const { claimName } = parseURI(authorUri || '');
|
||||||
const stickerFromMessage = parseSticker(message);
|
const stickerUrlFromMessage = getStickerUrl(message);
|
||||||
|
const isSticker = Boolean(stickerUrlFromMessage);
|
||||||
const timePosted = timestamp * 1000;
|
const timePosted = timestamp * 1000;
|
||||||
|
const commentIsMine = comment.channel_id && isMyComment(comment.channel_id);
|
||||||
|
|
||||||
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
|
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
|
||||||
function isMyComment(channelId: string) {
|
function isMyComment(channelId: string) {
|
||||||
|
@ -45,62 +60,36 @@ function LivestreamComment(props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classnames('livestream-comment', {
|
className={classnames('livestream__comment', {
|
||||||
'livestream-comment--superchat': supportAmount > 0,
|
'livestream__comment--superchat': supportAmount > 0,
|
||||||
'livestream-comment--sticker': Boolean(stickerFromMessage),
|
'livestream__comment--sticker': isSticker,
|
||||||
'livestream-comment--mentioned': hasUserMention,
|
'livestream__comment--mentioned': hasUserMention,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{supportAmount > 0 && (
|
{supportAmount > 0 && (
|
||||||
<div className="super-chat livestream-superchat__banner">
|
<div className="livestreamComment__superchatBanner">
|
||||||
<div className="livestream-superchat__banner-corner" />
|
<div className="livestreamComment__superchatBanner--corner" />
|
||||||
<CreditAmount
|
<CreditAmount isFiat={isFiat} amount={supportAmount} superChat />
|
||||||
isFiat={comment.is_fiat}
|
|
||||||
amount={supportAmount}
|
|
||||||
superChat
|
|
||||||
className="livestream-superchat__amount"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="livestream-comment__body">
|
<div className="livestreamComment__body">
|
||||||
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />}
|
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />}
|
||||||
<div className="livestream-comment__info">
|
|
||||||
{comment.is_global_mod && (
|
|
||||||
<Tooltip title={__('Admin')}>
|
|
||||||
<span className="comment__badge comment__badge--global-mod">
|
|
||||||
<Icon icon={ICONS.BADGE_MOD} size={16} />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{comment.is_moderator && (
|
<div className="livestreamComment__info">
|
||||||
<Tooltip title={__('Moderator')}>
|
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} size={16} />}
|
||||||
<span className="comment__badge comment__badge--mod">
|
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} size={16} />}
|
||||||
<Icon icon={ICONS.BADGE_MOD} size={16} />
|
{isStreamer && <CommentBadge label={__('Streamer')} icon={ICONS.BADGE_STREAMER} size={16} />}
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{commentByOwnerOfContent && (
|
|
||||||
<Tooltip title={__('Streamer')}>
|
|
||||||
<span className="comment__badge">
|
|
||||||
<Icon icon={ICONS.BADGE_STREAMER} size={16} />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className={classnames('button--uri-indicator comment__author', {
|
className={classnames('button--uri-indicator comment__author', { 'comment__author--creator': isStreamer })}
|
||||||
'comment__author--creator': commentByOwnerOfContent,
|
|
||||||
})}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
navigate={authorUri}
|
navigate={authorUri}
|
||||||
>
|
>
|
||||||
{claimName}
|
{claimName}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{comment.is_pinned && (
|
{isPinned && (
|
||||||
<span className="comment__pin">
|
<span className="comment__pin">
|
||||||
<Icon icon={ICONS.PIN} size={14} />
|
<Icon icon={ICONS.PIN} size={14} />
|
||||||
{__('Pinned')}
|
{__('Pinned')}
|
||||||
|
@ -110,16 +99,15 @@ function LivestreamComment(props: Props) {
|
||||||
{/* Use key to force timestamp update */}
|
{/* Use key to force timestamp update */}
|
||||||
<DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds />
|
<DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds />
|
||||||
|
|
||||||
{comment.removed ? (
|
{isSticker ? (
|
||||||
<div className="livestream-comment__text">
|
|
||||||
<Empty text={__('[Removed]')} />
|
|
||||||
</div>
|
|
||||||
) : stickerFromMessage ? (
|
|
||||||
<div className="sticker__comment">
|
<div className="sticker__comment">
|
||||||
<OptimizedImage src={stickerFromMessage.url} waitLoad loading="lazy" />
|
<OptimizedImage src={stickerUrlFromMessage} waitLoad loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="livestream-comment__text">
|
<div className="livestreamComment__text">
|
||||||
|
{removed ? (
|
||||||
|
<Empty text={__('[Removed]')} />
|
||||||
|
) : (
|
||||||
<MarkdownPreview
|
<MarkdownPreview
|
||||||
content={message}
|
content={message}
|
||||||
promptLinks
|
promptLinks
|
||||||
|
@ -127,22 +115,24 @@ function LivestreamComment(props: Props) {
|
||||||
disableTimestamps
|
disableTimestamps
|
||||||
setUserMention={setUserMention}
|
setUserMention={setUserMention}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="livestream-comment__menu">
|
<div className="livestreamComment__menu">
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton className="menu__button">
|
<MenuButton className="menu__button">
|
||||||
<Icon size={18} icon={ICONS.MORE_VERTICAL} />
|
<Icon size={18} icon={ICONS.MORE_VERTICAL} />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
<CommentMenuList
|
<CommentMenuList
|
||||||
uri={uri}
|
uri={uri}
|
||||||
commentId={comment.comment_id}
|
commentId={commentId}
|
||||||
authorUri={authorUri}
|
authorUri={authorUri}
|
||||||
commentIsMine={commentIsMine}
|
commentIsMine={commentIsMine}
|
||||||
isPinned={comment.is_pinned}
|
isPinned={isPinned}
|
||||||
isTopLevel
|
isTopLevel
|
||||||
disableEdit
|
disableEdit
|
||||||
isLiveComment
|
isLiveComment
|
||||||
|
@ -152,5 +142,3 @@ function LivestreamComment(props: Props) {
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LivestreamComment;
|
|
||||||
|
|
|
@ -1,29 +1,9 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
|
import { selectIsFetchingComments } from 'redux/selectors/comments';
|
||||||
import { doResolveUris } from 'redux/actions/claims';
|
|
||||||
import { selectClaimForUri } from 'redux/selectors/claims';
|
|
||||||
import { doCommentList, doSuperChatList } from 'redux/actions/comments';
|
|
||||||
import {
|
|
||||||
selectTopLevelCommentsForUri,
|
|
||||||
selectIsFetchingComments,
|
|
||||||
selectSuperChatsForUri,
|
|
||||||
selectSuperChatTotalAmountForUri,
|
|
||||||
selectPinnedCommentsForUri,
|
|
||||||
} from 'redux/selectors/comments';
|
|
||||||
|
|
||||||
import LivestreamComments from './view';
|
import LivestreamComments from './view';
|
||||||
|
|
||||||
const select = (state, props) => ({
|
const select = (state) => ({
|
||||||
claim: selectClaimForUri(state, props.uri),
|
|
||||||
comments: selectTopLevelCommentsForUri(state, props.uri, MAX_LIVESTREAM_COMMENTS),
|
|
||||||
pinnedComments: selectPinnedCommentsForUri(state, props.uri),
|
|
||||||
fetchingComments: selectIsFetchingComments(state),
|
fetchingComments: selectIsFetchingComments(state),
|
||||||
superChats: selectSuperChatsForUri(state, props.uri),
|
|
||||||
superChatsTotalAmount: selectSuperChatTotalAmountForUri(state, props.uri),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(select, {
|
export default connect(select)(LivestreamComments);
|
||||||
doCommentList,
|
|
||||||
doSuperChatList,
|
|
||||||
doResolveUris,
|
|
||||||
})(LivestreamComments);
|
|
||||||
|
|
|
@ -1,89 +1,27 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
|
import 'scss/component/_livestream-chat.scss';
|
||||||
import React from 'react';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import Spinner from 'component/spinner';
|
|
||||||
import CommentCreate from 'component/commentCreate';
|
|
||||||
import LivestreamComment from 'component/livestreamComment';
|
import LivestreamComment from 'component/livestreamComment';
|
||||||
import Button from 'component/button';
|
import React from 'react';
|
||||||
import UriIndicator from 'component/uriIndicator';
|
|
||||||
import CreditAmount from 'component/common/credit-amount';
|
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
|
||||||
import Tooltip from 'component/common/tooltip';
|
|
||||||
import * as ICONS from 'constants/icons';
|
|
||||||
import Icon from 'component/common/icon';
|
|
||||||
import OptimizedImage from 'component/optimizedImage';
|
|
||||||
import { parseSticker } from 'util/comments';
|
|
||||||
|
|
||||||
// 30 sec timestamp refresh timer
|
// 30 sec timestamp refresh timer
|
||||||
const UPDATE_TIMESTAMP_MS = 30 * 1000;
|
const UPDATE_TIMESTAMP_MS = 30 * 1000;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
uri: string,
|
commentsToDisplay: Array<Comment>,
|
||||||
claim: ?StreamClaim,
|
|
||||||
embed?: boolean,
|
|
||||||
doCommentList: (string, string, number, number) => void,
|
|
||||||
comments: Array<Comment>,
|
|
||||||
pinnedComments: Array<Comment>,
|
|
||||||
fetchingComments: boolean,
|
fetchingComments: boolean,
|
||||||
doSuperChatList: (string) => void,
|
uri: string,
|
||||||
superChats: Array<Comment>,
|
|
||||||
doResolveUris: (Array<string>, boolean) => void,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const IS_TIMESTAMP_VISIBLE = () =>
|
|
||||||
// $FlowFixMe
|
|
||||||
document.documentElement.style.getPropertyValue('--live-timestamp-opacity') === '0.5';
|
|
||||||
|
|
||||||
const TOGGLE_TIMESTAMP_OPACITY = () =>
|
|
||||||
// $FlowFixMe
|
|
||||||
document.documentElement.style.setProperty('--live-timestamp-opacity', IS_TIMESTAMP_VISIBLE() ? '0' : '0.5');
|
|
||||||
|
|
||||||
const VIEW_MODES = {
|
|
||||||
CHAT: 'chat',
|
|
||||||
SUPERCHAT: 'sc',
|
|
||||||
};
|
|
||||||
const COMMENT_SCROLL_TIMEOUT = 25;
|
|
||||||
const LARGE_SUPER_CHAT_LIST_THRESHOLD = 20;
|
|
||||||
|
|
||||||
export default function LivestreamComments(props: Props) {
|
export default function LivestreamComments(props: Props) {
|
||||||
const {
|
const { commentsToDisplay, fetchingComments, uri } = props;
|
||||||
claim,
|
|
||||||
uri,
|
|
||||||
embed,
|
|
||||||
comments: commentsByChronologicalOrder,
|
|
||||||
pinnedComments,
|
|
||||||
doCommentList,
|
|
||||||
fetchingComments,
|
|
||||||
doSuperChatList,
|
|
||||||
superChats: superChatsByAmount,
|
|
||||||
doResolveUris,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
let superChatsFiatAmount, superChatsLBCAmount, superChatsTotalAmount, hasSuperChats;
|
|
||||||
|
|
||||||
const commentsRef = React.createRef();
|
|
||||||
|
|
||||||
const [viewMode, setViewMode] = React.useState(VIEW_MODES.CHAT);
|
|
||||||
const [scrollPos, setScrollPos] = React.useState(0);
|
|
||||||
const [showPinned, setShowPinned] = React.useState(true);
|
|
||||||
const [resolvingSuperChat, setResolvingSuperChat] = React.useState(false);
|
|
||||||
const [forceUpdate, setForceUpdate] = React.useState(0);
|
const [forceUpdate, setForceUpdate] = React.useState(0);
|
||||||
|
|
||||||
const claimId = claim && claim.claim_id;
|
|
||||||
const commentsLength = commentsByChronologicalOrder && commentsByChronologicalOrder.length;
|
|
||||||
|
|
||||||
const commentsToDisplay = viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByAmount;
|
|
||||||
const stickerSuperChats = superChatsByAmount && superChatsByAmount.filter(({ comment }) => !!parseSticker(comment));
|
|
||||||
|
|
||||||
const discussionElement = document.querySelector('.livestream__comments');
|
|
||||||
|
|
||||||
const pinnedComment = pinnedComments.length > 0 ? pinnedComments[0] : null;
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const shouldRefreshTimestamp =
|
const shouldRefreshTimestamp =
|
||||||
commentsByChronologicalOrder &&
|
commentsToDisplay &&
|
||||||
commentsByChronologicalOrder.some((comment) => {
|
commentsToDisplay.some((comment) => {
|
||||||
const { timestamp } = comment;
|
const { timestamp } = comment;
|
||||||
const timePosted = timestamp * 1000;
|
const timePosted = timestamp * 1000;
|
||||||
|
|
||||||
|
@ -91,32 +29,6 @@ export default function LivestreamComments(props: Props) {
|
||||||
return now - timePosted < 1000 * 60 * 60;
|
return now - timePosted < 1000 * 60 * 60;
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreScrollPos = React.useCallback(() => {
|
|
||||||
if (discussionElement) {
|
|
||||||
discussionElement.scrollTop = 0;
|
|
||||||
}
|
|
||||||
}, [discussionElement]);
|
|
||||||
|
|
||||||
const superChatTopTen = React.useMemo(() => {
|
|
||||||
return superChatsByAmount ? superChatsByAmount.slice(0, 10) : superChatsByAmount;
|
|
||||||
}, [superChatsByAmount]);
|
|
||||||
|
|
||||||
const showMoreSuperChatsButton =
|
|
||||||
superChatTopTen && superChatsByAmount && superChatTopTen.length < superChatsByAmount.length;
|
|
||||||
|
|
||||||
function resolveSuperChat() {
|
|
||||||
if (superChatsByAmount && superChatsByAmount.length > 0) {
|
|
||||||
doResolveUris(
|
|
||||||
superChatsByAmount.map((comment) => comment.channel_url || '0'),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
if (superChatsByAmount.length > LARGE_SUPER_CHAT_LIST_THRESHOLD) {
|
|
||||||
setResolvingSuperChat(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh timestamp on timer
|
// Refresh timestamp on timer
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (shouldRefreshTimestamp) {
|
if (shouldRefreshTimestamp) {
|
||||||
|
@ -129,277 +41,16 @@ export default function LivestreamComments(props: Props) {
|
||||||
// forceUpdate will re-activate the timer or else it will only refresh once
|
// forceUpdate will re-activate the timer or else it will only refresh once
|
||||||
}, [shouldRefreshTimestamp, forceUpdate]);
|
}, [shouldRefreshTimestamp, forceUpdate]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
/* top to bottom comment display */
|
||||||
if (claimId) {
|
if (!fetchingComments && commentsToDisplay && commentsToDisplay.length > 0) {
|
||||||
doCommentList(uri, '', 1, 75);
|
|
||||||
doSuperChatList(uri);
|
|
||||||
}
|
|
||||||
}, [claimId, uri, doCommentList, doSuperChatList]);
|
|
||||||
|
|
||||||
// Register scroll handler (TODO: Should throttle/debounce)
|
|
||||||
React.useEffect(() => {
|
|
||||||
function handleScroll() {
|
|
||||||
if (discussionElement && viewMode === VIEW_MODES.CHAT) {
|
|
||||||
const scrollTop = discussionElement.scrollTop;
|
|
||||||
if (scrollTop !== scrollPos) {
|
|
||||||
setScrollPos(scrollTop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discussionElement && viewMode === VIEW_MODES.CHAT) {
|
|
||||||
discussionElement.addEventListener('scroll', handleScroll);
|
|
||||||
return () => discussionElement.removeEventListener('scroll', handleScroll);
|
|
||||||
}
|
|
||||||
}, [discussionElement, scrollPos, viewMode]);
|
|
||||||
|
|
||||||
// Retain scrollPos=0 when receiving new messages.
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (discussionElement && commentsLength > 0) {
|
|
||||||
// Only update comment scroll if the user hasn't scrolled up to view old comments
|
|
||||||
if (scrollPos >= 0) {
|
|
||||||
// +ve scrollPos: not scrolled (Usually, there'll be a few pixels beyond 0).
|
|
||||||
// -ve scrollPos: user scrolled.
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
// Use a timer here to ensure we reset after the new comment has been rendered.
|
|
||||||
discussionElement.scrollTop = 0;
|
|
||||||
}, COMMENT_SCROLL_TIMEOUT);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [commentsLength]); // (Just respond to 'commentsLength' updates and nothing else)
|
|
||||||
|
|
||||||
// Stop spinner for resolving superchats
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (resolvingSuperChat) {
|
|
||||||
// The real solution to the sluggishness is to fix the claim store/selectors
|
|
||||||
// and to paginate the long superchat list. This serves as a band-aid,
|
|
||||||
// showing a spinner while we batch-resolve. The duration is just a rough
|
|
||||||
// estimate -- the lag will handle the remaining time.
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setResolvingSuperChat(false);
|
|
||||||
// Scroll to the top:
|
|
||||||
const livestreamCommentsDiv = document.getElementsByClassName('livestream__comments')[0];
|
|
||||||
const divHeight = livestreamCommentsDiv.scrollHeight;
|
|
||||||
livestreamCommentsDiv.scrollTop = divHeight * -1;
|
|
||||||
}, 1000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [resolvingSuperChat]);
|
|
||||||
|
|
||||||
// sum total amounts for fiat tips and lbc tips
|
|
||||||
if (superChatsByAmount) {
|
|
||||||
let fiatAmount = 0;
|
|
||||||
let LBCAmount = 0;
|
|
||||||
for (const superChat of superChatsByAmount) {
|
|
||||||
if (superChat.is_fiat) {
|
|
||||||
fiatAmount = fiatAmount + superChat.support_amount;
|
|
||||||
} else {
|
|
||||||
LBCAmount = LBCAmount + superChat.support_amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
superChatsFiatAmount = fiatAmount;
|
|
||||||
superChatsLBCAmount = LBCAmount;
|
|
||||||
superChatsTotalAmount = superChatsFiatAmount + superChatsLBCAmount;
|
|
||||||
hasSuperChats = (superChatsTotalAmount || 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let superChatsReversed;
|
|
||||||
// array of superchats organized by fiat or not first, then support amount
|
|
||||||
if (superChatsByAmount) {
|
|
||||||
const clonedSuperchats = JSON.parse(JSON.stringify(superChatsByAmount));
|
|
||||||
|
|
||||||
// for top to bottom display, oldest superchat on top most recent on bottom
|
|
||||||
superChatsReversed = clonedSuperchats.sort((a, b) => {
|
|
||||||
return b.timestamp - a.timestamp;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!claim) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStickerUrl(comment: string) {
|
|
||||||
const stickerFromComment = parseSticker(comment);
|
|
||||||
return stickerFromComment && stickerFromComment.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card livestream__discussion">
|
|
||||||
<div className="card__header--between livestream-discussion__header">
|
|
||||||
<div className="livestream-discussion__title">
|
|
||||||
{__('Live Chat')}
|
|
||||||
|
|
||||||
<Menu>
|
|
||||||
<MenuButton className="menu__button">
|
|
||||||
<Icon size={18} icon={ICONS.SETTINGS} />
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
<MenuList className="menu__list">
|
|
||||||
<MenuItem className="comment__menu-option" onSelect={TOGGLE_TIMESTAMP_OPACITY}>
|
|
||||||
<span className="menu__link">
|
|
||||||
<Icon aria-hidden icon={ICONS.TIME} />
|
|
||||||
{__('Toggle Timestamps')}
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasSuperChats && (
|
|
||||||
<div className="recommended-content__toggles">
|
|
||||||
{/* the superchats in chronological order button */}
|
|
||||||
<Button
|
|
||||||
className={classnames('button-toggle', { 'button-toggle--active': viewMode === VIEW_MODES.CHAT })}
|
|
||||||
label={__('Chat')}
|
|
||||||
onClick={() => {
|
|
||||||
setViewMode(VIEW_MODES.CHAT);
|
|
||||||
const livestreamCommentsDiv = document.getElementsByClassName('livestream__comments')[0];
|
|
||||||
livestreamCommentsDiv.scrollTop = livestreamCommentsDiv.scrollHeight;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* the button to show superchats listed by most to least support amount */}
|
|
||||||
<Button
|
|
||||||
className={classnames('button-toggle', { 'button-toggle--active': viewMode === VIEW_MODES.SUPERCHAT })}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
<CreditAmount amount={superChatsLBCAmount || 0} size={8} /> /
|
|
||||||
<CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
resolveSuperChat();
|
|
||||||
setViewMode(VIEW_MODES.SUPERCHAT);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<>
|
|
||||||
{fetchingComments && !commentsByChronologicalOrder && (
|
|
||||||
<div className="main--empty">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={commentsRef} className="livestream__comments-wrapper">
|
|
||||||
{viewMode === VIEW_MODES.CHAT && superChatsByAmount && hasSuperChats && (
|
|
||||||
<div className="livestream-superchats__wrapper">
|
|
||||||
<div className="livestream-superchats__inner">
|
|
||||||
{superChatTopTen.map((superChat: Comment) => {
|
|
||||||
const { comment, comment_id, channel_url, support_amount, is_fiat } = superChat;
|
|
||||||
const isSticker = stickerSuperChats && stickerSuperChats.includes(superChat);
|
|
||||||
const stickerImg = <OptimizedImage src={getStickerUrl(comment)} waitLoad loading="lazy" />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title={isSticker ? stickerImg : comment} key={comment_id}>
|
|
||||||
<div className="livestream-superchat">
|
|
||||||
<div className="livestream-superchat__thumbnail">
|
|
||||||
<ChannelThumbnail uri={channel_url} xsmall />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={classnames('livestream-superchat__info', {
|
|
||||||
'livestream-superchat__info--sticker': isSticker,
|
|
||||||
'livestream-superchat__info--not-sticker': stickerSuperChats && !isSticker,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="livestream-superchat__info--user">
|
|
||||||
<UriIndicator uri={channel_url} link />
|
|
||||||
<CreditAmount
|
|
||||||
hideTitle
|
|
||||||
size={10}
|
|
||||||
className="livestream-superchat__amount-large"
|
|
||||||
amount={support_amount}
|
|
||||||
isFiat={is_fiat}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSticker && <div className="livestream-superchat__info--image">{stickerImg}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{showMoreSuperChatsButton && (
|
|
||||||
<Button
|
|
||||||
title={__('Show More...')}
|
|
||||||
label={__('Show More')}
|
|
||||||
button="inverse"
|
|
||||||
className="close-button"
|
|
||||||
onClick={() => {
|
|
||||||
resolveSuperChat();
|
|
||||||
setViewMode(VIEW_MODES.SUPERCHAT);
|
|
||||||
}}
|
|
||||||
iconRight={ICONS.MORE}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pinnedComment && showPinned && viewMode === VIEW_MODES.CHAT && (
|
|
||||||
<div className="livestream-pinned__wrapper">
|
|
||||||
<LivestreamComment
|
|
||||||
comment={pinnedComment}
|
|
||||||
key={pinnedComment.comment_id}
|
|
||||||
uri={uri}
|
|
||||||
forceUpdate={forceUpdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
title={__('Dismiss pinned comment')}
|
|
||||||
button="inverse"
|
|
||||||
className="close-button"
|
|
||||||
onClick={() => setShowPinned(false)}
|
|
||||||
icon={ICONS.REMOVE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* top to bottom comment display */}
|
|
||||||
{!fetchingComments && commentsByChronologicalOrder.length > 0 ? (
|
|
||||||
<div className="livestream__comments">
|
<div className="livestream__comments">
|
||||||
{viewMode === VIEW_MODES.CHAT &&
|
{commentsToDisplay.map((comment) => (
|
||||||
commentsToDisplay.map((comment) => (
|
|
||||||
<LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} />
|
<LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{viewMode === VIEW_MODES.SUPERCHAT && resolvingSuperChat && (
|
|
||||||
<div className="main--empty">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === VIEW_MODES.SUPERCHAT &&
|
|
||||||
!resolvingSuperChat &&
|
|
||||||
superChatsReversed &&
|
|
||||||
superChatsReversed.map((comment) => (
|
|
||||||
<LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="main--empty" style={{ flex: 1 }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{scrollPos < 0 && viewMode === VIEW_MODES.CHAT && (
|
|
||||||
<Button
|
|
||||||
button="secondary"
|
|
||||||
className="livestream__comments__scroll-to-recent"
|
|
||||||
label={__('Recent Comments')}
|
|
||||||
onClick={restoreScrollPos}
|
|
||||||
iconRight={ICONS.DOWN}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="livestream__comment-create">
|
|
||||||
<CommentCreate isLivestream bottom embed={embed} uri={uri} onDoneReplying={restoreScrollPos} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <div className="main--empty" style={{ flex: 1 }} />;
|
||||||
|
}
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import {
|
import { selectClaimForUri, makeSelectTagInClaimOrChannelForUri, selectThumbnailForUri } from 'redux/selectors/claims';
|
||||||
makeSelectClaimForUri,
|
|
||||||
makeSelectTagInClaimOrChannelForUri,
|
|
||||||
selectThumbnailForUri,
|
|
||||||
} from 'redux/selectors/claims';
|
|
||||||
import LivestreamLayout from './view';
|
import LivestreamLayout from './view';
|
||||||
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
|
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
|
||||||
|
|
||||||
const select = (state, props) => ({
|
const select = (state, props) => {
|
||||||
claim: makeSelectClaimForUri(props.uri)(state),
|
const { uri } = props;
|
||||||
thumbnail: selectThumbnailForUri(state, props.uri),
|
|
||||||
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
|
return {
|
||||||
});
|
claim: selectClaimForUri(state, uri),
|
||||||
|
thumbnail: selectThumbnailForUri(state, uri),
|
||||||
|
chatDisabled: makeSelectTagInClaimOrChannelForUri(uri, DISABLE_COMMENTS_TAG)(state),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default connect(select)(LivestreamLayout);
|
export default connect(select)(LivestreamLayout);
|
||||||
|
|
|
@ -1,46 +1,43 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { LIVESTREAM_EMBED_URL } from 'constants/livestream';
|
|
||||||
import React from 'react';
|
|
||||||
import FileTitleSection from 'component/fileTitleSection';
|
|
||||||
import { useIsMobile } from 'effects/use-screensize';
|
|
||||||
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import { lazyImport } from 'util/lazyImport';
|
import { lazyImport } from 'util/lazyImport';
|
||||||
|
import { LIVESTREAM_EMBED_URL } from 'constants/livestream';
|
||||||
|
import { useIsMobile } from 'effects/use-screensize';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import FileTitleSection from 'component/fileTitleSection';
|
||||||
import LivestreamLink from 'component/livestreamLink';
|
import LivestreamLink from 'component/livestreamLink';
|
||||||
|
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */));
|
const LivestreamChatLayout = lazyImport(() => import('component/livestreamChatLayout' /* webpackChunkName: "chat" */));
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
uri: string,
|
activeStreamUri: boolean | string,
|
||||||
claim: ?StreamClaim,
|
claim: ?StreamClaim,
|
||||||
hideComments: boolean,
|
hideComments: boolean,
|
||||||
|
isCurrentClaimLive: boolean,
|
||||||
release: any,
|
release: any,
|
||||||
showLivestream: boolean,
|
showLivestream: boolean,
|
||||||
showScheduledInfo: boolean,
|
showScheduledInfo: boolean,
|
||||||
isCurrentClaimLive: boolean,
|
uri: string,
|
||||||
activeStreamUri: boolean | string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LivestreamLayout(props: Props) {
|
export default function LivestreamLayout(props: Props) {
|
||||||
const {
|
const {
|
||||||
|
activeStreamUri,
|
||||||
claim,
|
claim,
|
||||||
uri,
|
|
||||||
hideComments,
|
hideComments,
|
||||||
|
isCurrentClaimLive,
|
||||||
release,
|
release,
|
||||||
showLivestream,
|
showLivestream,
|
||||||
showScheduledInfo,
|
showScheduledInfo,
|
||||||
isCurrentClaimLive,
|
uri,
|
||||||
activeStreamUri,
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
if (!claim || !claim.signing_channel) {
|
if (!claim || !claim.signing_channel) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelName = claim.signing_channel.name;
|
const { name: channelName, claim_id: channelClaimId } = claim.signing_channel;
|
||||||
const channelClaimId = claim.signing_channel.claim_id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -58,6 +55,7 @@ export default function LivestreamLayout(props: Props) {
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showScheduledInfo && <LivestreamScheduledInfo release={release} />}
|
{showScheduledInfo && <LivestreamScheduledInfo release={release} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -87,7 +85,11 @@ export default function LivestreamLayout(props: Props) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<React.Suspense fallback={null}>{isMobile && !hideComments && <LivestreamComments uri={uri} />}</React.Suspense>
|
{isMobile && !hideComments && (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<LivestreamChatLayout uri={uri} />
|
||||||
|
</React.Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
<FileTitleSection uri={uri} livestream isLive={showLivestream} />
|
<FileTitleSection uri={uri} livestream isLive={showLivestream} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,109 +1,89 @@
|
||||||
// @flow
|
// @flow
|
||||||
import type { Node } from 'react';
|
|
||||||
import React, { Fragment } from 'react';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import { MAIN_CLASS } from 'constants/classnames';
|
|
||||||
import { lazyImport } from 'util/lazyImport';
|
import { lazyImport } from 'util/lazyImport';
|
||||||
import SideNavigation from 'component/sideNavigation';
|
import { MAIN_CLASS } from 'constants/classnames';
|
||||||
import SettingsSideNavigation from 'component/settingsSideNavigation';
|
import { parseURI } from 'util/lbryURI';
|
||||||
import Header from 'component/header';
|
|
||||||
/* @if TARGET='app' */
|
|
||||||
import StatusBar from 'component/common/status-bar';
|
|
||||||
/* @endif */
|
|
||||||
import usePersistedState from 'effects/use-persisted-state';
|
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
|
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
|
||||||
import { parseURI } from 'util/lbryURI';
|
import classnames from 'classnames';
|
||||||
|
import Header from 'component/header';
|
||||||
|
import React from 'react';
|
||||||
|
import SettingsSideNavigation from 'component/settingsSideNavigation';
|
||||||
|
import SideNavigation from 'component/sideNavigation';
|
||||||
|
import type { Node } from 'react';
|
||||||
|
import usePersistedState from 'effects/use-persisted-state';
|
||||||
|
|
||||||
const Footer = lazyImport(() => import('web/component/footer' /* webpackChunkName: "footer" */));
|
const Footer = lazyImport(() => import('web/component/footer' /* webpackChunkName: "footer" */));
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: Node | Array<Node>,
|
|
||||||
className: ?string,
|
|
||||||
authPage: boolean,
|
authPage: boolean,
|
||||||
filePage: boolean,
|
|
||||||
settingsPage?: boolean,
|
|
||||||
noHeader: boolean,
|
|
||||||
noFooter: boolean,
|
|
||||||
noSideNavigation: boolean,
|
|
||||||
fullWidthPage: boolean,
|
|
||||||
videoTheaterMode: boolean,
|
|
||||||
isMarkdown?: boolean,
|
|
||||||
livestream?: boolean,
|
|
||||||
chatDisabled: boolean,
|
|
||||||
rightSide?: Node,
|
|
||||||
backout: {
|
backout: {
|
||||||
backLabel?: string,
|
backLabel?: string,
|
||||||
backNavDefault?: string,
|
backNavDefault?: string,
|
||||||
title: string,
|
title: string,
|
||||||
simpleTitle: string, // Just use the same value as `title` if `title` is already short (~< 10 chars), unless you have a better idea for title overlfow on mobile
|
simpleTitle: string, // Just use the same value as `title` if `title` is already short (~< 10 chars), unless you have a better idea for title overlfow on mobile
|
||||||
},
|
},
|
||||||
|
chatDisabled: boolean,
|
||||||
|
children: Node | Array<Node>,
|
||||||
|
className: ?string,
|
||||||
|
filePage: boolean,
|
||||||
|
fullWidthPage: boolean,
|
||||||
|
isMarkdown?: boolean,
|
||||||
|
livestream?: boolean,
|
||||||
|
noFooter: boolean,
|
||||||
|
noHeader: boolean,
|
||||||
|
noSideNavigation: boolean,
|
||||||
|
rightSide?: Node,
|
||||||
|
settingsPage?: boolean,
|
||||||
|
videoTheaterMode: boolean,
|
||||||
|
isPopoutWindow?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Page(props: Props) {
|
function Page(props: Props) {
|
||||||
const {
|
const {
|
||||||
|
authPage = false,
|
||||||
|
backout,
|
||||||
|
chatDisabled,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
filePage = false,
|
filePage = false,
|
||||||
settingsPage,
|
|
||||||
authPage = false,
|
|
||||||
fullWidthPage = false,
|
fullWidthPage = false,
|
||||||
noHeader = false,
|
|
||||||
noFooter = false,
|
|
||||||
noSideNavigation = false,
|
|
||||||
backout,
|
|
||||||
videoTheaterMode,
|
|
||||||
isMarkdown = false,
|
isMarkdown = false,
|
||||||
livestream,
|
livestream,
|
||||||
|
noFooter = false,
|
||||||
|
noHeader = false,
|
||||||
|
noSideNavigation = false,
|
||||||
rightSide,
|
rightSide,
|
||||||
chatDisabled,
|
settingsPage,
|
||||||
|
videoTheaterMode,
|
||||||
|
isPopoutWindow,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
location: { pathname },
|
location: { pathname },
|
||||||
} = useHistory();
|
} = useHistory();
|
||||||
const [sidebarOpen, setSidebarOpen] = usePersistedState('sidebar', false);
|
|
||||||
const isMediumScreen = useIsMediumScreen();
|
const isMediumScreen = useIsMediumScreen();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = usePersistedState('sidebar', false);
|
||||||
|
|
||||||
let isOnFilePage = false;
|
let isOnFilePage = false;
|
||||||
try {
|
try {
|
||||||
const url = pathname.slice(1).replace(/:/g, '#');
|
const url = pathname.slice(1).replace(/:/g, '#');
|
||||||
const { isChannel } = parseURI(url);
|
const { isChannel } = parseURI(url);
|
||||||
if (!isChannel) {
|
|
||||||
isOnFilePage = true;
|
if (!isChannel) isOnFilePage = true;
|
||||||
}
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const isAbsoluteSideNavHidden = (isOnFilePage || isMobile) && !sidebarOpen;
|
const isAbsoluteSideNavHidden = (isOnFilePage || isMobile) && !sidebarOpen;
|
||||||
|
|
||||||
function getSideNavElem() {
|
|
||||||
if (!authPage) {
|
|
||||||
if (settingsPage) {
|
|
||||||
return <SettingsSideNavigation />;
|
|
||||||
} else if (!noSideNavigation) {
|
|
||||||
return (
|
|
||||||
<SideNavigation
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
setSidebarOpen={setSidebarOpen}
|
|
||||||
isMediumScreen={isMediumScreen}
|
|
||||||
isOnFilePage={isOnFilePage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isOnFilePage || isMediumScreen) {
|
if (isOnFilePage || isMediumScreen) setSidebarOpen(false);
|
||||||
setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
// TODO: make sure setState callback for usePersistedState uses useCallback to it doesn't cause effect to re-run
|
// TODO: make sure setState callback for usePersistedState uses useCallback to it doesn't cause effect to re-run
|
||||||
}, [isOnFilePage, isMediumScreen]);
|
}, [isOnFilePage, isMediumScreen, setSidebarOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<>
|
||||||
{!noHeader && (
|
{!noHeader && (
|
||||||
<Header
|
<Header
|
||||||
authHeader={authPage}
|
authHeader={authPage}
|
||||||
|
@ -113,14 +93,28 @@ function Page(props: Props) {
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classnames('main-wrapper__inner', {
|
className={classnames('main-wrapper__inner', {
|
||||||
'main-wrapper__inner--filepage': isOnFilePage,
|
'main-wrapper__inner--filepage': isOnFilePage,
|
||||||
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode,
|
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode,
|
||||||
'main-wrapper__inner--auth': authPage,
|
'main-wrapper__inner--auth': authPage,
|
||||||
|
'main--popout-chat': isPopoutWindow,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{getSideNavElem()}
|
{!authPage &&
|
||||||
|
(settingsPage ? (
|
||||||
|
<SettingsSideNavigation />
|
||||||
|
) : (
|
||||||
|
!noSideNavigation && (
|
||||||
|
<SideNavigation
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
setSidebarOpen={setSidebarOpen}
|
||||||
|
isMediumScreen={isMediumScreen}
|
||||||
|
isOnFilePage={isOnFilePage}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classnames({
|
className={classnames({
|
||||||
|
@ -139,25 +133,24 @@ function Page(props: Props) {
|
||||||
'main--markdown': isMarkdown,
|
'main--markdown': isMarkdown,
|
||||||
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream,
|
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream,
|
||||||
'main--livestream': livestream && !chatDisabled,
|
'main--livestream': livestream && !chatDisabled,
|
||||||
|
'main--popout-chat': isPopoutWindow,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{!isMobile && rightSide && <div className="main__right-side">{rightSide}</div>}
|
{!isMobile && rightSide && (!livestream || !chatDisabled) && (
|
||||||
|
<div className="main__right-side">{rightSide}</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
{/* @if TARGET='web' */}
|
|
||||||
{!noFooter && (
|
{!noFooter && (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<Footer />
|
<Footer />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
{/* @endif */}
|
|
||||||
</div>
|
</div>
|
||||||
{/* @if TARGET='app' */}
|
|
||||||
<StatusBar />
|
|
||||||
{/* @endif */}
|
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,7 @@ const CheckoutPage = lazyImport(() => import('page/checkoutPage' /* webpackChunk
|
||||||
const CreatorDashboard = lazyImport(() => import('page/creatorDashboard' /* webpackChunkName: "creatorDashboard" */));
|
const CreatorDashboard = lazyImport(() => import('page/creatorDashboard' /* webpackChunkName: "creatorDashboard" */));
|
||||||
const DiscoverPage = lazyImport(() => import('page/discover' /* webpackChunkName: "discover" */));
|
const DiscoverPage = lazyImport(() => import('page/discover' /* webpackChunkName: "discover" */));
|
||||||
const EmbedWrapperPage = lazyImport(() => import('page/embedWrapper' /* webpackChunkName: "embedWrapper" */));
|
const EmbedWrapperPage = lazyImport(() => import('page/embedWrapper' /* webpackChunkName: "embedWrapper" */));
|
||||||
|
const PopoutChatPage = lazyImport(() => import('page/popoutChatWrapper' /* webpackChunkName: "popoutChat" */));
|
||||||
const FileListPublished = lazyImport(() =>
|
const FileListPublished = lazyImport(() =>
|
||||||
import('page/fileListPublished' /* webpackChunkName: "fileListPublished" */)
|
import('page/fileListPublished' /* webpackChunkName: "fileListPublished" */)
|
||||||
);
|
);
|
||||||
|
@ -350,6 +351,8 @@ function AppRouter(props: Props) {
|
||||||
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
|
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
|
||||||
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} />
|
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} />
|
||||||
|
|
||||||
|
<Route path={`/$/${PAGES.POPOUT}/:channelName/:streamName`} component={PopoutChatPage} />
|
||||||
|
|
||||||
<Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} />
|
<Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} />
|
||||||
<Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} />
|
<Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} />
|
||||||
|
|
||||||
|
|
|
@ -83,3 +83,4 @@ exports.LIVESTREAM = 'livestream';
|
||||||
exports.LIVESTREAM_CURRENT = 'live';
|
exports.LIVESTREAM_CURRENT = 'live';
|
||||||
exports.GENERAL = 'general';
|
exports.GENERAL = 'general';
|
||||||
exports.LIST = 'list';
|
exports.LIST = 'list';
|
||||||
|
exports.POPOUT = 'popout';
|
||||||
|
|
|
@ -11,11 +11,13 @@ import { doFetchChannelLiveStatus } from 'redux/actions/livestream';
|
||||||
import LivestreamPage from './view';
|
import LivestreamPage from './view';
|
||||||
|
|
||||||
const select = (state, props) => {
|
const select = (state, props) => {
|
||||||
const channelClaimId = getChannelIdFromClaim(selectClaimForUri(state, props.uri));
|
const { uri } = props;
|
||||||
|
const channelClaimId = getChannelIdFromClaim(selectClaimForUri(state, uri));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: selectUserVerifiedEmail(state),
|
isAuthenticated: selectUserVerifiedEmail(state),
|
||||||
channelClaimId,
|
channelClaimId,
|
||||||
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
|
chatDisabled: makeSelectTagInClaimOrChannelForUri(uri, DISABLE_COMMENTS_TAG)(state),
|
||||||
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId),
|
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId),
|
||||||
activeLivestreamInitialized: selectActiveLivestreamInitialized(state),
|
activeLivestreamInitialized: selectActiveLivestreamInitialized(state),
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,69 +1,83 @@
|
||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import { formatLbryChannelName } from 'util/url';
|
||||||
import { lazyImport } from 'util/lazyImport';
|
import { lazyImport } from 'util/lazyImport';
|
||||||
import Page from 'component/page';
|
|
||||||
import LivestreamLayout from 'component/livestreamLayout';
|
|
||||||
import analytics from 'analytics';
|
|
||||||
import moment from 'moment';
|
|
||||||
import { LIVESTREAM_STARTS_SOON_BUFFER, LIVESTREAM_STARTED_RECENTLY_BUFFER } from 'constants/livestream';
|
import { LIVESTREAM_STARTS_SOON_BUFFER, LIVESTREAM_STARTED_RECENTLY_BUFFER } from 'constants/livestream';
|
||||||
|
import analytics from 'analytics';
|
||||||
|
import LivestreamLayout from 'component/livestreamLayout';
|
||||||
|
import moment from 'moment';
|
||||||
|
import Page from 'component/page';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */));
|
const LivestreamChatLayout = lazyImport(() => import('component/livestreamChatLayout' /* webpackChunkName: "chat" */));
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
uri: string,
|
|
||||||
claim: StreamClaim,
|
|
||||||
doSetPlayingUri: ({ uri: ?string }) => void,
|
|
||||||
isAuthenticated: boolean,
|
|
||||||
doUserSetReferrer: (string) => void,
|
|
||||||
channelClaimId: ?string,
|
|
||||||
chatDisabled: boolean,
|
|
||||||
doCommentSocketConnect: (string, string) => void,
|
|
||||||
doCommentSocketDisconnect: (string) => void,
|
|
||||||
doFetchChannelLiveStatus: (string) => void,
|
|
||||||
activeLivestreamForChannel: any,
|
activeLivestreamForChannel: any,
|
||||||
activeLivestreamInitialized: boolean,
|
activeLivestreamInitialized: boolean,
|
||||||
|
channelClaimId: ?string,
|
||||||
|
chatDisabled: boolean,
|
||||||
|
claim: StreamClaim,
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
uri: string,
|
||||||
|
doSetPlayingUri: ({ uri: ?string }) => void,
|
||||||
|
doCommentSocketConnect: (string, string, string) => void,
|
||||||
|
doCommentSocketDisconnect: (string, string) => void,
|
||||||
|
doFetchChannelLiveStatus: (string) => void,
|
||||||
|
doUserSetReferrer: (string) => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LivestreamPage(props: Props) {
|
export default function LivestreamPage(props: Props) {
|
||||||
const {
|
const {
|
||||||
uri,
|
activeLivestreamForChannel,
|
||||||
claim,
|
activeLivestreamInitialized,
|
||||||
doSetPlayingUri,
|
|
||||||
isAuthenticated,
|
|
||||||
doUserSetReferrer,
|
|
||||||
channelClaimId,
|
channelClaimId,
|
||||||
chatDisabled,
|
chatDisabled,
|
||||||
|
claim,
|
||||||
|
isAuthenticated,
|
||||||
|
uri,
|
||||||
|
doSetPlayingUri,
|
||||||
doCommentSocketConnect,
|
doCommentSocketConnect,
|
||||||
doCommentSocketDisconnect,
|
doCommentSocketDisconnect,
|
||||||
doFetchChannelLiveStatus,
|
doFetchChannelLiveStatus,
|
||||||
activeLivestreamForChannel,
|
doUserSetReferrer,
|
||||||
activeLivestreamInitialized,
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
|
||||||
|
const [showLivestream, setShowLivestream] = React.useState(false);
|
||||||
|
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
|
||||||
|
const [hideComments, setHideComments] = React.useState(false);
|
||||||
|
|
||||||
|
const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized;
|
||||||
|
const isChannelBroadcasting = Boolean(activeLivestreamForChannel);
|
||||||
|
const claimId = claim && claim.claim_id;
|
||||||
|
const isCurrentClaimLive = isChannelBroadcasting && activeLivestreamForChannel.claimId === claimId;
|
||||||
|
const livestreamChannelId = channelClaimId || '';
|
||||||
|
|
||||||
|
// $FlowFixMe
|
||||||
|
const release = moment.unix(claim.value.release_time);
|
||||||
|
const stringifiedClaim = JSON.stringify(claim);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// TODO: This should not be needed one we unify the livestream player (?)
|
// TODO: This should not be needed one we unify the livestream player (?)
|
||||||
analytics.playerLoadedEvent('livestream', false);
|
analytics.playerLoadedEvent('livestream', false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const claimId = claim && claim.claim_id;
|
|
||||||
|
|
||||||
// Establish web socket connection for viewer count.
|
// Establish web socket connection for viewer count.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (claimId) {
|
if (!claim) return;
|
||||||
doCommentSocketConnect(uri, claimId);
|
|
||||||
|
const { claim_id: claimId, signing_channel: channelClaim } = claim;
|
||||||
|
const channelName = channelClaim && formatLbryChannelName(channelClaim.canonical_url);
|
||||||
|
|
||||||
|
if (claimId && channelName) {
|
||||||
|
doCommentSocketConnect(uri, channelName, claimId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (claimId) {
|
if (claimId && channelName) {
|
||||||
doCommentSocketDisconnect(claimId);
|
doCommentSocketDisconnect(claimId, channelName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [claimId, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
|
}, [claim, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
|
||||||
|
|
||||||
const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized;
|
|
||||||
const isChannelBroadcasting = Boolean(activeLivestreamForChannel);
|
|
||||||
const isCurrentClaimLive = isChannelBroadcasting && activeLivestreamForChannel.claimId === claimId;
|
|
||||||
const livestreamChannelId = channelClaimId || '';
|
|
||||||
|
|
||||||
// Find out current channels status + active live claim.
|
// Find out current channels status + active live claim.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
@ -72,19 +86,10 @@ export default function LivestreamPage(props: Props) {
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [livestreamChannelId, doFetchChannelLiveStatus]);
|
}, [livestreamChannelId, doFetchChannelLiveStatus]);
|
||||||
|
|
||||||
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setActiveStreamUri(!isCurrentClaimLive && isChannelBroadcasting ? activeLivestreamForChannel.claimUri : false);
|
setActiveStreamUri(!isCurrentClaimLive && isChannelBroadcasting ? activeLivestreamForChannel.claimUri : false);
|
||||||
}, [isCurrentClaimLive, isChannelBroadcasting]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isCurrentClaimLive, isChannelBroadcasting]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// $FlowFixMe
|
|
||||||
const release = moment.unix(claim.value.release_time);
|
|
||||||
|
|
||||||
const [showLivestream, setShowLivestream] = React.useState(false);
|
|
||||||
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
|
|
||||||
const [hideComments, setHideComments] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
|
|
||||||
|
@ -125,19 +130,15 @@ export default function LivestreamPage(props: Props) {
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [chatDisabled, isChannelBroadcasting, release, isCurrentClaimLive, isInitialized]);
|
}, [chatDisabled, isChannelBroadcasting, release, isCurrentClaimLive, isInitialized]);
|
||||||
|
|
||||||
const stringifiedClaim = JSON.stringify(claim);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (uri && stringifiedClaim) {
|
if (uri && stringifiedClaim) {
|
||||||
const jsonClaim = JSON.parse(stringifiedClaim);
|
const jsonClaim = JSON.parse(stringifiedClaim);
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
|
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
|
||||||
if (uri) {
|
if (uri) doUserSetReferrer(uri.replace('lbry://', ''));
|
||||||
doUserSetReferrer(uri.replace('lbry://', '')); //
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, [uri, stringifiedClaim, isAuthenticated, doUserSetReferrer]);
|
||||||
}, [uri, stringifiedClaim, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
|
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
|
||||||
|
@ -155,7 +156,7 @@ export default function LivestreamPage(props: Props) {
|
||||||
!hideComments &&
|
!hideComments &&
|
||||||
isInitialized && (
|
isInitialized && (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<LivestreamComments uri={uri} />
|
<LivestreamChatLayout uri={uri} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
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 NOTIFICATION_WS_URL = `${SOCKETY_SERVER_API}/internal?id=`;
|
||||||
const COMMENT_WS_URL = `${SOCKETY_SERVER_API}/commentron?id=`;
|
const COMMENT_WS_URL = `${SOCKETY_SERVER_API}/commentron?id=`;
|
||||||
|
const COMMENT_WS_SUBCATEGORIES = {
|
||||||
|
COMMENTER: 'commenter',
|
||||||
|
VIEWER: 'viewer',
|
||||||
|
};
|
||||||
|
|
||||||
let sockets = {};
|
let sockets = {};
|
||||||
let closingSockets = {};
|
let closingSockets = {};
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
const getCommentSocketUrl = (claimId) => {
|
const getCommentSocketUrl = (claimId, channelName) => {
|
||||||
return `${COMMENT_WS_URL}${claimId}&category=${claimId}`;
|
return `${COMMENT_WS_URL}${claimId}&category=${channelName}&sub_category=viewer`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const doSocketConnect = (url, cb) => {
|
const getCommentSocketUrlForCommenter = (claimId, channelName) => {
|
||||||
|
return `${COMMENT_WS_URL}${claimId}&category=${channelName}&sub_category=commenter`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doSocketConnect = (url, cb, type) => {
|
||||||
function connectToSocket() {
|
function connectToSocket() {
|
||||||
if (sockets[url] !== undefined && sockets[url] !== null) {
|
if (sockets[url] !== undefined && sockets[url] !== null) {
|
||||||
sockets[url].close();
|
sockets[url].close();
|
||||||
|
@ -26,7 +34,7 @@ export const doSocketConnect = (url, cb) => {
|
||||||
sockets[url] = new WebSocket(url);
|
sockets[url] = new WebSocket(url);
|
||||||
sockets[url].onopen = (e) => {
|
sockets[url].onopen = (e) => {
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
console.log('\nConnected to WS \n\n'); // eslint-disable-line
|
console.log(`\nConnected to ${type} WS \n\n`); // eslint-disable-line
|
||||||
};
|
};
|
||||||
|
|
||||||
sockets[url].onmessage = (e) => {
|
sockets[url].onmessage = (e) => {
|
||||||
|
@ -35,12 +43,12 @@ export const doSocketConnect = (url, cb) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
sockets[url].onerror = (e) => {
|
sockets[url].onerror = (e) => {
|
||||||
console.error('websocket onerror', e); // eslint-disable-line
|
console.log(`${type} websocket onerror`, e); // eslint-disable-line
|
||||||
// onerror and onclose will both fire, so nothing is needed here
|
// onerror and onclose will both fire, so nothing is needed here
|
||||||
};
|
};
|
||||||
|
|
||||||
sockets[url].onclose = () => {
|
sockets[url].onclose = () => {
|
||||||
console.log('\n Disconnected from WS\n\n'); // eslint-disable-line
|
console.log(`\n Disconnected from ${type} WS \n\n`); // eslint-disable-line
|
||||||
if (!closingSockets[url]) {
|
if (!closingSockets[url]) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
connectToSocket();
|
connectToSocket();
|
||||||
|
@ -75,7 +83,9 @@ export const doNotificationSocketConnect = (enableNotifications) => (dispatch) =
|
||||||
|
|
||||||
const url = `${NOTIFICATION_WS_URL}${authToken}`;
|
const url = `${NOTIFICATION_WS_URL}${authToken}`;
|
||||||
|
|
||||||
doSocketConnect(url, (data) => {
|
doSocketConnect(
|
||||||
|
url,
|
||||||
|
(data) => {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'pending_notification':
|
case 'pending_notification':
|
||||||
if (enableNotifications) {
|
if (enableNotifications) {
|
||||||
|
@ -89,13 +99,20 @@ export const doNotificationSocketConnect = (enableNotifications) => (dispatch) =
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
'notification'
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const doCommentSocketConnect = (uri, claimId) => (dispatch) => {
|
export const doCommentSocketConnect = (uri, channelName, claimId, subCategory) => (dispatch) => {
|
||||||
const url = getCommentSocketUrl(claimId);
|
const url =
|
||||||
|
subCategory === COMMENT_WS_SUBCATEGORIES.COMMENTER
|
||||||
|
? getCommentSocketUrlForCommenter(claimId, channelName)
|
||||||
|
: getCommentSocketUrl(claimId, channelName);
|
||||||
|
|
||||||
doSocketConnect(url, (response) => {
|
doSocketConnect(
|
||||||
|
url,
|
||||||
|
(response) => {
|
||||||
if (response.type === 'delta') {
|
if (response.type === 'delta') {
|
||||||
const newComment = response.data.comment;
|
const newComment = response.data.comment;
|
||||||
dispatch({
|
dispatch({
|
||||||
|
@ -128,11 +145,23 @@ export const doCommentSocketConnect = (uri, claimId) => (dispatch) => {
|
||||||
data: { comment_id },
|
data: { comment_id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
'comment'
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const doCommentSocketDisconnect = (claimId) => (dispatch) => {
|
export const doCommentSocketDisconnect = (claimId, channelName) => (dispatch) => {
|
||||||
const url = getCommentSocketUrl(claimId);
|
const url = getCommentSocketUrl(claimId, channelName);
|
||||||
|
|
||||||
|
dispatch(doSocketDisconnect(url));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doCommentSocketConnectAsCommenter = (uri, channelName, claimId) => (dispatch) => {
|
||||||
|
dispatch(doCommentSocketConnect(uri, channelName, claimId, COMMENT_WS_SUBCATEGORIES.COMMENTER));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doCommentSocketDisconnectAsCommenter = (claimId, channelName) => (dispatch) => {
|
||||||
|
const url = getCommentSocketUrlForCommenter(claimId, channelName);
|
||||||
|
|
||||||
dispatch(doSocketDisconnect(url));
|
dispatch(doSocketDisconnect(url));
|
||||||
};
|
};
|
||||||
|
|
|
@ -54,7 +54,6 @@
|
||||||
@import 'component/spinner';
|
@import 'component/spinner';
|
||||||
@import 'component/splash';
|
@import 'component/splash';
|
||||||
@import 'component/status-bar';
|
@import 'component/status-bar';
|
||||||
@import 'component/superchat';
|
|
||||||
@import 'component/syntax-highlighter';
|
@import 'component/syntax-highlighter';
|
||||||
@import 'component/table';
|
@import 'component/table';
|
||||||
@import 'component/livestream';
|
@import 'component/livestream';
|
||||||
|
|
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 {
|
.comment__message {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-width: 35rem;
|
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 {
|
.livestream {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -26,199 +23,6 @@ $recent-msg-button__height: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.livestream__discussion {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
@media (min-width: $breakpoint-medium) {
|
|
||||||
margin: 0;
|
|
||||||
width: var(--livestream-comments-width);
|
|
||||||
height: calc(100vh - var(--header-height));
|
|
||||||
position: fixed;
|
|
||||||
right: 0;
|
|
||||||
top: var(--header-height);
|
|
||||||
bottom: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
border-top: none;
|
|
||||||
border-bottom: none;
|
|
||||||
border-right: none;
|
|
||||||
|
|
||||||
.card__main-actions {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-discussion__header {
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
padding-bottom: var(--spacing-s);
|
|
||||||
margin-bottom: 0;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.recommended-content__toggles {
|
|
||||||
button {
|
|
||||||
height: unset;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button__label {
|
|
||||||
max-height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: $breakpoint-small) {
|
|
||||||
height: $discussion-header__height;
|
|
||||||
padding: 0 var(--spacing-s);
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-small) {
|
|
||||||
padding: var(--spacing-xxs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-discussion__title {
|
|
||||||
@extend .card__title-section;
|
|
||||||
@extend .card__title-section--small;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.menu__button {
|
|
||||||
margin-left: var(--spacing-xxs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-small) {
|
|
||||||
.menu__button {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream__comments-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: calc(100vh - var(--header-height) - #{$discussion-header__height});
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream__comments {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
font-size: var(--font-small);
|
|
||||||
overflow-y: scroll;
|
|
||||||
overflow-x: visible;
|
|
||||||
padding-top: var(--spacing-s);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment {
|
|
||||||
list-style-type: none;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.date_time {
|
|
||||||
color: var(--color-text-subtitle);
|
|
||||||
opacity: var(--live-timestamp-opacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.date_time {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: $breakpoint-small) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-card-background-highlighted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:hover) {
|
|
||||||
.menu__button:not(:focus):not([aria-expanded='true']) {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-name {
|
|
||||||
font-size: var(--font-xsmall);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment--mentioned {
|
|
||||||
background-color: var(--color-card-background-highlighted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment__info {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment--superchat {
|
|
||||||
background-color: var(--color-card-background-highlighted);
|
|
||||||
|
|
||||||
+ .livestream-comment--superchat {
|
|
||||||
margin-bottom: var(--spacing-xxs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment__body {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: unset;
|
|
||||||
flex: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment__info {
|
|
||||||
margin-top: calc(var(--spacing-xxs) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 4rem;
|
|
||||||
border-top-right-radius: 2px;
|
|
||||||
border-bottom-right-radius: 2px;
|
|
||||||
width: 5px;
|
|
||||||
background-color: var(--color-superchat);
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment__body {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-left: var(--spacing-s);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.channel-thumbnail {
|
|
||||||
@include handleChannelGif(2rem);
|
|
||||||
margin-top: var(--spacing-xxs);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment__menu {
|
|
||||||
position: absolute;
|
|
||||||
right: var(--spacing-xs);
|
|
||||||
top: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream__comments__scroll-to-recent {
|
|
||||||
margin-top: -$recent-msg-button__height;
|
|
||||||
align-self: center;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
font-size: var(--font-xsmall);
|
|
||||||
padding: var(--spacing-xxs) var(--spacing-s);
|
|
||||||
opacity: 0.9;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream__comment-create {
|
|
||||||
padding: var(--spacing-s);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream__channel-link {
|
.livestream__channel-link {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-xl);
|
||||||
box-shadow: 0 0 0 rgba(246, 72, 83, 0.4);
|
box-shadow: 0 0 0 rgba(246, 72, 83, 0.4);
|
||||||
|
@ -280,204 +84,6 @@ $recent-msg-button__height: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.livestream-superchats__wrapper {
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
overflow-x: scroll;
|
|
||||||
padding: var(--spacing-s) var(--spacing-xs);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
font-size: var(--font-small);
|
|
||||||
background-color: var(--color-card-background);
|
|
||||||
|
|
||||||
@media (min-width: $breakpoint-small) {
|
|
||||||
padding: var(--spacing-xs);
|
|
||||||
width: var(--livestream-comments-width);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-pinned__wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
padding: var(--spacing-s) var(--spacing-xs);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
font-size: var(--font-small);
|
|
||||||
background-color: var(--color-card-background-highlighted);
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.livestream-comment {
|
|
||||||
width: 100%;
|
|
||||||
padding-top: var(--spacing-xs);
|
|
||||||
max-height: 6rem;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
padding: 0 calc(var(--spacing-m) - var(--spacing-xs)) 0 var(--spacing-m);
|
|
||||||
color: var(--color-text-subtitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: $breakpoint-small) {
|
|
||||||
padding: var(--spacing-xs);
|
|
||||||
width: var(--livestream-comments-width);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__amount-large {
|
|
||||||
.credit-amount {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchats__inner {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
padding-left: var(--spacing-m);
|
|
||||||
padding-right: var(--spacing-l);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat {
|
|
||||||
display: flex;
|
|
||||||
margin-right: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-xxs);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
|
|
||||||
.channel-thumbnail {
|
|
||||||
margin-right: var(--spacing-xs);
|
|
||||||
@include handleChannelGif(2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-of-type {
|
|
||||||
background-color: var(--color-superchat);
|
|
||||||
|
|
||||||
.channel-name {
|
|
||||||
max-width: 8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-of-type(2) {
|
|
||||||
background-color: var(--color-superchat-2);
|
|
||||||
}
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
background-color: var(--color-superchat-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-of-type(-n + 3) {
|
|
||||||
.channel-name,
|
|
||||||
.credit-amount {
|
|
||||||
color: var(--color-black);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-name {
|
|
||||||
max-width: 5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: var(--font-xsmall);
|
|
||||||
|
|
||||||
.button {
|
|
||||||
margin-top: calc(var(--spacing-xxs) / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__info--sticker {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 8rem;
|
|
||||||
height: 3rem;
|
|
||||||
|
|
||||||
.livestream-superchat__info--user {
|
|
||||||
.channel-name {
|
|
||||||
max-width: 5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__info--image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__info--not-sticker {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__banner {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
padding: 0.25rem var(--spacing-s);
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is just a two small circles that overlap to make it look like
|
|
||||||
// the banner and the left border are connected
|
|
||||||
.livestream-superchat__banner-corner {
|
|
||||||
height: calc(var(--border-radius) * 2);
|
|
||||||
width: calc(var(--border-radius) * 2);
|
|
||||||
border-radius: 50%;
|
|
||||||
position: absolute;
|
|
||||||
background-color: var(--color-superchat);
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
transform: translateX(0) translateY(50%);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
height: calc(var(--border-radius) * 2);
|
|
||||||
width: calc(var(--border-radius) * 2);
|
|
||||||
border-top-left-radius: var(--border-radius);
|
|
||||||
background-color: var(--color-card-background);
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
transform: translateX(25%) translateY(50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-comment__text {
|
|
||||||
padding-right: var(--spacing-xl);
|
|
||||||
padding-bottom: var(--spacing-xxs);
|
|
||||||
|
|
||||||
.markdown-preview {
|
|
||||||
p {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-name {
|
|
||||||
font-size: var(--font-small);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__tooltip-amount {
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
margin-left: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream__superchat-comment {
|
|
||||||
margin-top: var(--spacing-s);
|
|
||||||
max-width: 5rem;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livestream-superchat__amount-large {
|
|
||||||
min-width: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table--livestream-data {
|
.table--livestream-data {
|
||||||
td:nth-of-type(1) {
|
td:nth-of-type(1) {
|
||||||
max-width: 4rem;
|
max-width: 4rem;
|
||||||
|
|
|
@ -311,6 +311,14 @@
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main--popout-chat {
|
||||||
|
@extend .main;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
}
|
||||||
|
|
||||||
.main--auth-page {
|
.main--auth-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 70rem;
|
max-width: 70rem;
|
||||||
|
|
|
@ -74,7 +74,7 @@
|
||||||
margin-bottom: var(--spacing-s);
|
margin-bottom: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.super-chat--light {
|
.superChat--light {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: inline;
|
display: inline;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
.super-chat {
|
.superChat {
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
background: linear-gradient(to right, var(--color-superchat), var(--color-superchat-3));
|
background: linear-gradient(to right, var(--color-superchat), var(--color-superchat-3));
|
||||||
padding: 0.2rem var(--spacing-xs);
|
padding: 0.2rem var(--spacing-xs);
|
||||||
|
@ -10,8 +10,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.super-chat--light {
|
.superChat--light {
|
||||||
@extend .super-chat;
|
@extend .superChat;
|
||||||
background: linear-gradient(to right, var(--color-superchat__light), var(--color-superchat-3__light));
|
background: linear-gradient(to right, var(--color-superchat__light), var(--color-superchat-3__light));
|
||||||
|
|
||||||
.credit-amount {
|
.credit-amount {
|
||||||
|
|
|
@ -101,5 +101,10 @@ export function parseSticker(comment: string) {
|
||||||
const stickerName = stickerValue && stickerValue.replace(/<stkr>/g, '');
|
const stickerName = stickerValue && stickerValue.replace(/<stkr>/g, '');
|
||||||
const commentIsSticker = stickerValue && stickerValue.length === comment.length;
|
const commentIsSticker = stickerValue && stickerValue.length === comment.length;
|
||||||
|
|
||||||
return commentIsSticker && ALL_VALID_STICKERS.find((sticker) => sticker.name === stickerName);
|
return commentIsSticker && ALL_VALID_STICKERS.find(({ name }) => name === stickerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStickerUrl(comment: string) {
|
||||||
|
const stickerFromComment = parseSticker(comment);
|
||||||
|
return stickerFromComment && stickerFromComment.url;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,8 @@ export const formatLbryUrlForWeb = (uri) => {
|
||||||
return newUrl;
|
return newUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatLbryChannelName = (uri) => uri.replace('lbry://', '').replace(/#/g, ':');
|
||||||
|
|
||||||
export const formatFileSystemPath = (path) => {
|
export const formatFileSystemPath = (path) => {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return;
|
return;
|
||||||
|
|
Loading…
Reference in a new issue