Merge pull request #5691 from lbryio/feat/go-live
[feat] Add LiveStreaming Support
This commit is contained in:
commit
a258fcb039
49 changed files with 1283 additions and 115 deletions
|
@ -117,6 +117,7 @@
|
|||
"electron-is-dev": "^0.3.0",
|
||||
"electron-webpack": "^2.8.2",
|
||||
"electron-window-state": "^4.1.1",
|
||||
"emoji-dictionary": "^1.0.11",
|
||||
"eslint": "^5.15.2",
|
||||
"eslint-config-prettier": "^2.9.0",
|
||||
"eslint-config-standard": "^12.0.0",
|
||||
|
|
|
@ -1181,6 +1181,7 @@
|
|||
"Try out the app!": "Try out the app!",
|
||||
"Download the app to track files you've viewed and downloaded.": "Download the app to track files you've viewed and downloaded.",
|
||||
"Create a new channel": "Create a new channel",
|
||||
"Go Live": "Go Live",
|
||||
"Thumbnail source": "Thumbnail source",
|
||||
"Cover source": "Cover source",
|
||||
"Your changes will be live in a few minutes": "Your changes will be live in a few minutes",
|
||||
|
@ -1672,5 +1673,8 @@
|
|||
"Receive emails about the latest rewards that are available to LBRY users.": "Receive emails about the latest rewards that are available to LBRY users.",
|
||||
"Stay up to date on the latest content from your favorite creators.": "Stay up to date on the latest content from your favorite creators.",
|
||||
"Receive tutorial emails related to LBRY": "Receive tutorial emails related to LBRY",
|
||||
"Create A LiveStream": "Create A LiveStream",
|
||||
"%channel% isn't live right now, but the chat is! Check back later to watch the stream.": "%channel% isn't live right now, but the chat is! Check back later to watch the stream.",
|
||||
"Right now": "Right now",
|
||||
"--end--": "--end--"
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import Button from 'component/button';
|
|||
import ClaimListDiscover from 'component/claimListDiscover';
|
||||
import Ads from 'web/component/ads';
|
||||
import Icon from 'component/common/icon';
|
||||
import LivestreamLink from 'component/livestreamLink';
|
||||
import { Form, FormField } from 'component/common/form';
|
||||
import { DEBOUNCE_WAIT_DURATION_MS } from 'constants/search';
|
||||
import { lighthouse } from 'redux/actions/search';
|
||||
|
@ -106,6 +107,8 @@ function ChannelContent(props: Props) {
|
|||
<HiddenNsfwClaims uri={uri} />
|
||||
)}
|
||||
|
||||
<LivestreamLink uri={uri} />
|
||||
|
||||
{!fetching && channelIsBlackListed && (
|
||||
<section className="card card--section">
|
||||
<p>
|
||||
|
|
|
@ -30,7 +30,7 @@ function ChannelListItem(props: ListItemProps) {
|
|||
|
||||
return (
|
||||
<div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}>
|
||||
<ChannelThumbnail uri={uri} />
|
||||
<ChannelThumbnail uri={uri} hideStakedIndicator />
|
||||
<ChannelTitle uri={uri} />
|
||||
{isSelected && <Icon icon={ICONS.DOWN} />}
|
||||
</div>
|
||||
|
|
|
@ -223,7 +223,7 @@ function ClaimListDiscover(props: Props) {
|
|||
};
|
||||
|
||||
if (!ENABLE_NO_SOURCE_CLAIMS) {
|
||||
// options.has_source = true;
|
||||
options.has_source = true;
|
||||
}
|
||||
|
||||
if (feeAmountParam && claimType !== CS.CLAIM_CHANNEL) {
|
||||
|
|
|
@ -1,16 +1,23 @@
|
|||
import * as PAGES from 'constants/pages';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri, makeSelectClaimIsPending, doClearPublish, doPrepareEdit } from 'lbry-redux';
|
||||
import {
|
||||
makeSelectClaimForUri,
|
||||
makeSelectClaimIsPending,
|
||||
doClearPublish,
|
||||
doPrepareEdit,
|
||||
makeSelectClaimHasSource,
|
||||
} from 'lbry-redux';
|
||||
import { push } from 'connected-react-router';
|
||||
import ClaimPreviewSubtitle from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
pending: makeSelectClaimIsPending(props.uri)(state),
|
||||
isLivestream: !makeSelectClaimHasSource(props.uri)(state),
|
||||
});
|
||||
|
||||
const perform = dispatch => ({
|
||||
beginPublish: name => {
|
||||
const perform = (dispatch) => ({
|
||||
beginPublish: (name) => {
|
||||
dispatch(doClearPublish());
|
||||
dispatch(doPrepareEdit({ name }));
|
||||
dispatch(push(`/$/${PAGES.UPLOAD}`));
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// @flow
|
||||
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import React from 'react';
|
||||
import UriIndicator from 'component/uriIndicator';
|
||||
import DateTime from 'component/dateTime';
|
||||
|
@ -11,10 +12,11 @@ type Props = {
|
|||
pending?: boolean,
|
||||
type: string,
|
||||
beginPublish: (string) => void,
|
||||
isLivestream: boolean,
|
||||
};
|
||||
|
||||
function ClaimPreviewSubtitle(props: Props) {
|
||||
const { pending, uri, claim, type, beginPublish } = props;
|
||||
const { pending, uri, claim, type, beginPublish, isLivestream } = props;
|
||||
const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
|
||||
|
||||
let isChannel;
|
||||
|
@ -28,13 +30,16 @@ function ClaimPreviewSubtitle(props: Props) {
|
|||
{claim ? (
|
||||
<React.Fragment>
|
||||
<UriIndicator uri={uri} link />{' '}
|
||||
{!pending &&
|
||||
claim &&
|
||||
(isChannel ? (
|
||||
type !== 'inline' && `${claimsInChannel} ${claimsInChannel === 1 ? __('upload') : __('uploads')}`
|
||||
) : (
|
||||
<DateTime timeAgo uri={uri} />
|
||||
))}
|
||||
{!pending && claim && (
|
||||
<>
|
||||
{isChannel &&
|
||||
type !== 'inline' &&
|
||||
`${claimsInChannel} ${claimsInChannel === 1 ? __('upload') : __('uploads')}`}
|
||||
|
||||
{!isChannel &&
|
||||
(isLivestream && ENABLE_NO_SOURCE_CLAIMS ? __('Livestream') : <DateTime timeAgo uri={uri} />)}
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
|
|
|
@ -92,7 +92,7 @@ function ClaimTilesDiscover(props: Props) {
|
|||
};
|
||||
|
||||
if (!ENABLE_NO_SOURCE_CLAIMS) {
|
||||
// options.has_source = true;
|
||||
options.has_source = true;
|
||||
}
|
||||
|
||||
if (releaseTime) {
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri, selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux';
|
||||
import {
|
||||
makeSelectClaimForUri,
|
||||
makeSelectClaimIsMine,
|
||||
selectMyChannelClaims,
|
||||
selectFetchingMyChannels,
|
||||
} from 'lbry-redux';
|
||||
import { selectIsPostingComment } from 'redux/selectors/comments';
|
||||
import { doOpenModal, doSetActiveChannel } from 'redux/actions/app';
|
||||
import { doCommentCreate } from 'redux/actions/comments';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { CommentCreate } from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
|
@ -14,12 +20,15 @@ const select = (state, props) => ({
|
|||
isFetchingChannels: selectFetchingMyChannels(state),
|
||||
isPostingComment: selectIsPostingComment(state),
|
||||
activeChannelClaim: selectActiveChannelClaim(state),
|
||||
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch, ownProps) => ({
|
||||
createComment: (comment, claimId, parentId) => dispatch(doCommentCreate(comment, claimId, parentId, ownProps.uri)),
|
||||
createComment: (comment, claimId, parentId) =>
|
||||
dispatch(doCommentCreate(comment, claimId, parentId, ownProps.uri, ownProps.livestream)),
|
||||
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
setActiveChannel: claimId => dispatch(doSetActiveChannel(claimId)),
|
||||
setActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)),
|
||||
toast: (message) => dispatch(doToast({ message, isError: true })),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(CommentCreate);
|
||||
|
|
|
@ -10,6 +10,16 @@ import usePersistedState from 'effects/use-persisted-state';
|
|||
import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field';
|
||||
import { useHistory } from 'react-router';
|
||||
import type { ElementRef } from 'react';
|
||||
import emoji from 'emoji-dictionary';
|
||||
|
||||
const COMMENT_SLOW_MODE_SECONDS = 5;
|
||||
const LIVESTREAM_EMOJIS = [
|
||||
emoji.getUnicode('rocket'),
|
||||
emoji.getUnicode('jeans'),
|
||||
emoji.getUnicode('fire'),
|
||||
emoji.getUnicode('heart'),
|
||||
emoji.getUnicode('open_mouth'),
|
||||
];
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
|
@ -25,6 +35,9 @@ type Props = {
|
|||
isPostingComment: boolean,
|
||||
activeChannel: string,
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
livestream?: boolean,
|
||||
toast: (string) => void,
|
||||
claimIsMine: boolean,
|
||||
};
|
||||
|
||||
export function CommentCreate(props: Props) {
|
||||
|
@ -40,11 +53,15 @@ export function CommentCreate(props: Props) {
|
|||
parentId,
|
||||
isPostingComment,
|
||||
activeChannelClaim,
|
||||
livestream,
|
||||
toast,
|
||||
claimIsMine,
|
||||
} = props;
|
||||
const buttonref: ElementRef<any> = React.useRef();
|
||||
const { push } = useHistory();
|
||||
const { claim_id: claimId } = claim;
|
||||
const [commentValue, setCommentValue] = React.useState('');
|
||||
const [lastCommentTime, setLastCommentTime] = React.useState();
|
||||
const [charCount, setCharCount] = useState(commentValue.length);
|
||||
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
|
||||
const hasChannels = channels && channels.length;
|
||||
|
@ -79,9 +96,21 @@ export function CommentCreate(props: Props) {
|
|||
|
||||
function handleSubmit() {
|
||||
if (activeChannelClaim && commentValue.length) {
|
||||
createComment(commentValue, claimId, parentId).then(res => {
|
||||
const timeUntilCanComment = !lastCommentTime
|
||||
? 0
|
||||
: (lastCommentTime - Date.now()) / 1000 + COMMENT_SLOW_MODE_SECONDS;
|
||||
|
||||
if (livestream && !claimIsMine && timeUntilCanComment > 0) {
|
||||
toast(
|
||||
__('Slowmode is on. You can comment again in %time% seconds.', { time: Math.ceil(timeUntilCanComment) })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
createComment(commentValue, claimId, parentId).then((res) => {
|
||||
if (res && res.signature) {
|
||||
setCommentValue('');
|
||||
setLastCommentTime(Date.now());
|
||||
|
||||
if (onDoneReplying) {
|
||||
onDoneReplying();
|
||||
|
@ -144,6 +173,23 @@ export function CommentCreate(props: Props) {
|
|||
autoFocus={isReply}
|
||||
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT}
|
||||
/>
|
||||
{livestream && hasChannels && (
|
||||
<div className="livestream__emoji-actions">
|
||||
{LIVESTREAM_EMOJIS.map((emoji) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
disabled={isPostingComment}
|
||||
type="button"
|
||||
button="alt"
|
||||
className="button--emoji"
|
||||
label={emoji}
|
||||
onClick={() => {
|
||||
setCommentValue(commentValue ? `${commentValue} ${emoji}` : emoji);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="section__actions section__actions--no-margin">
|
||||
<Button
|
||||
ref={buttonref}
|
||||
|
|
|
@ -1240,6 +1240,12 @@ export const icons = {
|
|||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.LIVESTREAM]: buildIcon(
|
||||
<g>
|
||||
<polygon points="23 7 16 12 23 17 23 7" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.CHANNEL_LEVEL_1]: (props: CustomProps) => (
|
||||
<svg
|
||||
{...props}
|
||||
|
|
|
@ -10,10 +10,11 @@ type Props = {
|
|||
doToast: ({ message: string }) => void,
|
||||
label?: string,
|
||||
primaryButton?: boolean,
|
||||
name?: string,
|
||||
};
|
||||
|
||||
export default function CopyableText(props: Props) {
|
||||
const { copyable, doToast, snackMessage, label, primaryButton = false } = props;
|
||||
const { copyable, doToast, snackMessage, label, primaryButton = false, name } = props;
|
||||
|
||||
const input = useRef();
|
||||
|
||||
|
@ -38,6 +39,7 @@ export default function CopyableText(props: Props) {
|
|||
type="text"
|
||||
className="form-field--copyable"
|
||||
readOnly
|
||||
name={name}
|
||||
label={label}
|
||||
value={copyable || ''}
|
||||
ref={input}
|
||||
|
|
|
@ -6,16 +6,22 @@ import FileActions from 'component/fileActions';
|
|||
|
||||
type Props = {
|
||||
uri: string,
|
||||
livestream?: boolean,
|
||||
activeViewers?: number,
|
||||
};
|
||||
|
||||
function FileSubtitle(props: Props) {
|
||||
const { uri } = props;
|
||||
const { uri, livestream = false, activeViewers = 0 } = props;
|
||||
|
||||
return (
|
||||
<div className="media__subtitle--between">
|
||||
<div className="file__viewdate">
|
||||
<DateTime uri={uri} show={DateTime.SHOW_DATE} />
|
||||
<FileViewCount uri={uri} />
|
||||
{livestream ? <span>{__('Right now')}</span> : <DateTime uri={uri} show={DateTime.SHOW_DATE} />}
|
||||
{livestream ? (
|
||||
<span>{__('%viewer_count% currently watching', { viewer_count: activeViewers })}</span>
|
||||
) : (
|
||||
<FileViewCount uri={uri} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FileActions uri={uri} />
|
||||
|
|
|
@ -18,10 +18,12 @@ type Props = {
|
|||
title: string,
|
||||
nsfw: boolean,
|
||||
isNsfwBlocked: boolean,
|
||||
livestream?: boolean,
|
||||
activeViewers?: number,
|
||||
};
|
||||
|
||||
function FileTitleSection(props: Props) {
|
||||
const { title, uri, nsfw, isNsfwBlocked } = props;
|
||||
const { title, uri, nsfw, isNsfwBlocked, livestream = false, activeViewers } = props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
@ -41,7 +43,7 @@ function FileTitleSection(props: Props) {
|
|||
body={
|
||||
<React.Fragment>
|
||||
<ClaimInsufficientCredits uri={uri} />
|
||||
<FileSubtitle uri={uri} />
|
||||
<FileSubtitle uri={uri} livestream={livestream} activeViewers={activeViewers} />
|
||||
</React.Fragment>
|
||||
}
|
||||
actions={
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectMediaTypeForUri } from 'lbry-redux';
|
||||
import { makeSelectMediaTypeForUri, makeSelectClaimHasSource } from 'lbry-redux';
|
||||
import FileType from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
mediaType: makeSelectMediaTypeForUri(props.uri)(state),
|
||||
isLivestream: !makeSelectClaimHasSource(props.uri)(state),
|
||||
});
|
||||
|
||||
export default connect(select)(FileType);
|
||||
|
|
|
@ -6,16 +6,17 @@ import Icon from 'component/common/icon';
|
|||
type Props = {
|
||||
uri: string,
|
||||
mediaType: string,
|
||||
isLivestream: boolean,
|
||||
};
|
||||
|
||||
function FileType(props: Props) {
|
||||
const { mediaType } = props;
|
||||
const { mediaType, isLivestream } = props;
|
||||
|
||||
if (mediaType === 'image') {
|
||||
return <Icon icon={ICONS.IMAGE} />;
|
||||
} else if (mediaType === 'audio') {
|
||||
return <Icon icon={ICONS.AUDIO} />;
|
||||
} else if (mediaType === 'video') {
|
||||
} else if (mediaType === 'video' || isLivestream) {
|
||||
return <Icon icon={ICONS.VIDEO} />;
|
||||
} else if (mediaType === 'text') {
|
||||
return <Icon icon={ICONS.TEXT} />;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// @flow
|
||||
import { LOGO_TITLE, ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import { SETTINGS } from 'lbry-redux';
|
||||
import * as PAGES from 'constants/pages';
|
||||
|
@ -10,7 +11,6 @@ import WunderBar from 'component/wunderbar';
|
|||
import Icon from 'component/common/icon';
|
||||
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
|
||||
import NavigationButton from 'component/navigationButton';
|
||||
import { LOGO_TITLE } from 'config';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
import NotificationBubble from 'component/notificationBubble';
|
||||
import NotificationHeaderButton from 'component/notificationHeaderButton';
|
||||
|
@ -99,6 +99,7 @@ const Header = (props: Props) => {
|
|||
const hasBackout = Boolean(backout);
|
||||
const { backLabel, backNavDefault, title: backTitle, simpleTitle: simpleBackTitle } = backout || {};
|
||||
const notificationsEnabled = (user && user.experimental_ui) || false;
|
||||
const livestreamEnabled = (ENABLE_NO_SOURCE_CLAIMS && user && user.experimental_ui) || false;
|
||||
const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url;
|
||||
|
||||
// Sign out if they click the "x" when they are on the password prompt
|
||||
|
@ -275,6 +276,7 @@ const Header = (props: Props) => {
|
|||
history={history}
|
||||
handleThemeToggle={handleThemeToggle}
|
||||
currentTheme={currentTheme}
|
||||
livestreamEnabled={livestreamEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -391,10 +393,11 @@ type HeaderMenuButtonProps = {
|
|||
history: { push: (string) => void },
|
||||
handleThemeToggle: (string) => void,
|
||||
currentTheme: string,
|
||||
livestreamEnabled: boolean,
|
||||
};
|
||||
|
||||
function HeaderMenuButtons(props: HeaderMenuButtonProps) {
|
||||
const { authenticated, notificationsEnabled, history, handleThemeToggle, currentTheme } = props;
|
||||
const { authenticated, notificationsEnabled, history, handleThemeToggle, currentTheme, livestreamEnabled } = props;
|
||||
|
||||
return (
|
||||
<div className="header__buttons">
|
||||
|
@ -422,6 +425,13 @@ function HeaderMenuButtons(props: HeaderMenuButtonProps) {
|
|||
<Icon aria-hidden icon={ICONS.CHANNEL} />
|
||||
{__('New Channel')}
|
||||
</MenuItem>
|
||||
|
||||
{livestreamEnabled && (
|
||||
<MenuItem className="menu__link" onSelect={() => history.push(`/$/${PAGES.LIVESTREAM}`)}>
|
||||
<Icon aria-hidden icon={ICONS.VIDEO} />
|
||||
{__('Go Live')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
|
|
14
ui/component/livestreamComments/index.js
Normal file
14
ui/component/livestreamComments/index.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri } from 'lbry-redux';
|
||||
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
|
||||
import { doCommentList } from 'redux/actions/comments';
|
||||
import { makeSelectTopLevelCommentsForUri, selectIsFetchingComments } from 'redux/selectors/comments';
|
||||
import LivestreamFeed from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
comments: makeSelectTopLevelCommentsForUri(props.uri)(state),
|
||||
fetchingComments: selectIsFetchingComments(state),
|
||||
});
|
||||
|
||||
export default connect(select, { doCommentSocketConnect, doCommentSocketDisconnect, doCommentList })(LivestreamFeed);
|
146
ui/component/livestreamComments/view.jsx
Normal file
146
ui/component/livestreamComments/view.jsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Card from 'component/common/card';
|
||||
import Spinner from 'component/spinner';
|
||||
import CommentCreate from 'component/commentCreate';
|
||||
import Button from 'component/button';
|
||||
import MarkdownPreview from 'component/common/markdown-preview';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: ?StreamClaim,
|
||||
activeViewers: number,
|
||||
embed?: boolean,
|
||||
doCommentSocketConnect: (string, string) => void,
|
||||
doCommentSocketDisconnect: (string) => void,
|
||||
doCommentList: (string) => void,
|
||||
comments: Array<Comment>,
|
||||
fetchingComments: boolean,
|
||||
};
|
||||
|
||||
export default function LivestreamFeed(props: Props) {
|
||||
const {
|
||||
claim,
|
||||
uri,
|
||||
embed,
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
comments,
|
||||
doCommentList,
|
||||
fetchingComments,
|
||||
} = props;
|
||||
const commentsRef = React.createRef();
|
||||
const hasScrolledComments = React.useRef();
|
||||
const [performedInitialScroll, setPerformedInitialScroll] = React.useState(false);
|
||||
const claimId = claim && claim.claim_id;
|
||||
const commentsLength = comments && comments.length;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (claimId) {
|
||||
doCommentList(uri);
|
||||
doCommentSocketConnect(uri, claimId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (claimId) {
|
||||
doCommentSocketDisconnect(claimId);
|
||||
}
|
||||
};
|
||||
}, [claimId, uri, doCommentList, doCommentSocketConnect, doCommentSocketDisconnect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = commentsRef.current;
|
||||
|
||||
function handleScroll() {
|
||||
if (element) {
|
||||
const scrollHeight = element.scrollHeight - element.offsetHeight;
|
||||
const isAtBottom = scrollHeight === element.scrollTop;
|
||||
|
||||
if (!isAtBottom) {
|
||||
hasScrolledComments.current = true;
|
||||
} else {
|
||||
hasScrolledComments.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.addEventListener('scroll', handleScroll);
|
||||
|
||||
if (commentsLength > 0) {
|
||||
// Only update comment scroll if the user hasn't scrolled up to view old comments
|
||||
// If they have, do nothing
|
||||
if (!hasScrolledComments.current || !performedInitialScroll) {
|
||||
element.scrollTop = element.scrollHeight - element.offsetHeight;
|
||||
|
||||
if (!performedInitialScroll) {
|
||||
setPerformedInitialScroll(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (element) {
|
||||
element.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, [commentsRef, commentsLength, performedInitialScroll]);
|
||||
|
||||
if (!claim) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={__('Live discussion')}
|
||||
smallTitle
|
||||
className="livestream__discussion"
|
||||
actions={
|
||||
<>
|
||||
{fetchingComments && (
|
||||
<div className="main--empty">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={commentsRef}
|
||||
className={classnames('livestream__comments-wrapper', {
|
||||
'livestream__comments-wrapper--with-height': commentsLength > 0,
|
||||
})}
|
||||
>
|
||||
{!fetchingComments && comments.length > 0 ? (
|
||||
<div className="livestream__comments">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.comment_id} className={classnames('livestream__comment')}>
|
||||
{comment.channel_url ? (
|
||||
<Button
|
||||
target="_blank"
|
||||
className={classnames('livestream__comment-author', {
|
||||
'livestream__comment-author--streamer':
|
||||
claim.signing_channel && claim.signing_channel.claim_id === comment.channel_id,
|
||||
})}
|
||||
navigate={comment.channel_url}
|
||||
label={comment.channel_name}
|
||||
/>
|
||||
) : (
|
||||
<div className="livestream__comment-author">{comment.channel_name}</div>
|
||||
)}
|
||||
<MarkdownPreview content={comment.comment} simpleLinks />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="main--empty" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="livestream__comment-create">
|
||||
<CommentCreate livestream bottom embed={embed} uri={uri} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
10
ui/component/livestreamLayout/index.js
Normal file
10
ui/component/livestreamLayout/index.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri, makeSelectThumbnailForUri } from 'lbry-redux';
|
||||
import LivestreamLayout from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
thumbnail: makeSelectThumbnailForUri(props.uri)(state),
|
||||
});
|
||||
|
||||
export default connect(select)(LivestreamLayout);
|
49
ui/component/livestreamLayout/view.jsx
Normal file
49
ui/component/livestreamLayout/view.jsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
// @flow
|
||||
import { BITWAVE_EMBED_URL } from 'constants/livestream';
|
||||
import React from 'react';
|
||||
import FileTitleSection from 'component/fileTitleSection';
|
||||
import LivestreamComments from 'component/livestreamComments';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: ?StreamClaim,
|
||||
isLive: boolean,
|
||||
activeViewers: number,
|
||||
};
|
||||
|
||||
export default function LivestreamLayout(props: Props) {
|
||||
const { claim, uri, isLive, activeViewers } = props;
|
||||
|
||||
if (!claim || !claim.signing_channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelName = claim.signing_channel.name;
|
||||
const channelClaimId = claim.signing_channel.claim_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section card-stack">
|
||||
<div className="file-render file-render--video livestream">
|
||||
<div className="file-viewer">
|
||||
<iframe
|
||||
src={`${BITWAVE_EMBED_URL}/${channelClaimId}?skin=odysee&autoplay=1`}
|
||||
scrolling="no"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLive && (
|
||||
<div className="help--notice">
|
||||
{__("%channel% isn't live right now, but the chat is! Check back later to watch the stream.", {
|
||||
channel: channelName || __('This channel'),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<FileTitleSection uri={uri} livestream isLive={isLive} activeViewers={activeViewers} />
|
||||
</div>
|
||||
<LivestreamComments uri={uri} />
|
||||
</>
|
||||
);
|
||||
}
|
9
ui/component/livestreamLink/index.js
Normal file
9
ui/component/livestreamLink/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri } from 'lbry-redux';
|
||||
import LivestreamLink from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
channelClaim: makeSelectClaimForUri(props.uri)(state),
|
||||
});
|
||||
|
||||
export default connect(select)(LivestreamLink);
|
72
ui/component/livestreamLink/view.jsx
Normal file
72
ui/component/livestreamLink/view.jsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
// @flow
|
||||
import { BITWAVE_API } from 'constants/livestream';
|
||||
import React from 'react';
|
||||
import Card from 'component/common/card';
|
||||
import ClaimPreview from 'component/claimPreview';
|
||||
import { Lbry } from 'lbry-redux';
|
||||
|
||||
type Props = {
|
||||
channelClaim: ChannelClaim,
|
||||
};
|
||||
|
||||
export default function LivestreamLink(props: Props) {
|
||||
const { channelClaim } = props;
|
||||
const [livestreamClaim, setLivestreamClaim] = React.useState(false);
|
||||
const [isLivestreaming, setIsLivestreaming] = React.useState(false);
|
||||
const livestreamChannelId = channelClaim.claim_id || ''; // TODO: fail in a safer way, probably
|
||||
|
||||
React.useEffect(() => {
|
||||
Lbry.claim_search({
|
||||
channel_ids: [livestreamChannelId],
|
||||
has_no_source: true,
|
||||
claim_type: ['stream'],
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.items && res.items.length > 0) {
|
||||
const claim = res.items[res.items.length - 1];
|
||||
setLivestreamClaim(claim);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [livestreamChannelId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function fetchIsStreaming() {
|
||||
// $FlowFixMe Bitwave's API can handle garbage
|
||||
fetch(`${BITWAVE_API}/${livestreamChannelId}`)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res && res.success && res.data && res.data.live) {
|
||||
setIsLivestreaming(true);
|
||||
} else {
|
||||
setIsLivestreaming(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {});
|
||||
}
|
||||
|
||||
let interval;
|
||||
if (livestreamChannelId) {
|
||||
if (!interval) fetchIsStreaming();
|
||||
interval = setInterval(fetchIsStreaming, 10 * 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [livestreamChannelId]);
|
||||
|
||||
if (!livestreamClaim || !isLivestreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="livestream__channel-link"
|
||||
title="Live stream in progress"
|
||||
actions={<ClaimPreview uri={livestreamClaim.canonical_url} livestream type="inline" />}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -28,6 +28,7 @@ type Props = {
|
|||
fullWidthPage: boolean,
|
||||
videoTheaterMode: boolean,
|
||||
isMarkdown?: boolean,
|
||||
livestream?: boolean,
|
||||
backout: {
|
||||
backLabel?: string,
|
||||
backNavDefault?: string,
|
||||
|
@ -49,6 +50,7 @@ function Page(props: Props) {
|
|||
backout,
|
||||
videoTheaterMode,
|
||||
isMarkdown = false,
|
||||
livestream,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
|
@ -106,8 +108,9 @@ function Page(props: Props) {
|
|||
'main--full-width': fullWidthPage,
|
||||
'main--auth-page': authPage,
|
||||
'main--file-page': filePage,
|
||||
'main--theater-mode': isOnFilePage && videoTheaterMode,
|
||||
'main--markdown': isMarkdown,
|
||||
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream,
|
||||
'main--livestream': livestream,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -364,6 +364,7 @@ function PublishFile(props: Props) {
|
|||
onFileChosen={handleFileChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPublishPost && (
|
||||
<PostEditor
|
||||
label={__('Post --[noun, markdown post tab button]--')}
|
||||
|
|
|
@ -25,6 +25,7 @@ import SelectThumbnail from 'component/selectThumbnail';
|
|||
import Card from 'component/common/card';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import * as PUBLISH_MODES from 'constants/publish_types';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
// @if TARGET='app'
|
||||
import fs from 'fs';
|
||||
|
@ -36,6 +37,7 @@ const MODES = Object.values(PUBLISH_MODES);
|
|||
const MODE_TO_I18N_STR = {
|
||||
[PUBLISH_MODES.FILE]: 'File',
|
||||
[PUBLISH_MODES.POST]: 'Post --[noun, markdown post tab button]--',
|
||||
[PUBLISH_MODES.LIVESTREAM]: 'Livestream --[noun, livestream tab button]--',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
@ -72,14 +74,14 @@ type Props = {
|
|||
balance: number,
|
||||
isStillEditing: boolean,
|
||||
clearPublish: () => void,
|
||||
resolveUri: string => void,
|
||||
resolveUri: (string) => void,
|
||||
scrollToTop: () => void,
|
||||
prepareEdit: (claim: any, uri: string) => void,
|
||||
resetThumbnailStatus: () => void,
|
||||
amountNeededForTakeover: ?number,
|
||||
// Add back type
|
||||
updatePublishForm: any => void,
|
||||
checkAvailability: string => void,
|
||||
updatePublishForm: (any) => void,
|
||||
checkAvailability: (string) => void,
|
||||
ytSignupPending: boolean,
|
||||
modal: { id: string, modalProps: {} },
|
||||
enablePublishPreview: boolean,
|
||||
|
@ -88,13 +90,19 @@ type Props = {
|
|||
};
|
||||
|
||||
function PublishForm(props: Props) {
|
||||
const [mode, setMode] = React.useState(PUBLISH_MODES.FILE);
|
||||
// Detect upload type from query in URL
|
||||
const { push, location } = useHistory();
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const uploadType = urlParams.get('type');
|
||||
|
||||
// Component state
|
||||
const [mode, setMode] = React.useState(uploadType || PUBLISH_MODES.FILE);
|
||||
const [autoSwitchMode, setAutoSwitchMode] = React.useState(true);
|
||||
|
||||
// Used to checl if the url name has changed:
|
||||
// Used to check if the url name has changed:
|
||||
// A new file needs to be provided
|
||||
const [prevName, setPrevName] = React.useState(false);
|
||||
// Used to checl if the file has been modified by user
|
||||
// Used to check if the file has been modified by user
|
||||
const [fileEdited, setFileEdited] = React.useState(false);
|
||||
const [prevFileText, setPrevFileText] = React.useState('');
|
||||
|
||||
|
@ -225,17 +233,61 @@ function PublishForm(props: Props) {
|
|||
}, [name, activeChannelName, resolveUri, updatePublishForm, checkAvailability]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePublishForm({ isMarkdownPost: mode === PUBLISH_MODES.POST });
|
||||
updatePublishForm({
|
||||
isMarkdownPost: mode === PUBLISH_MODES.POST,
|
||||
isLivestreamPublish: mode === PUBLISH_MODES.LIVESTREAM,
|
||||
});
|
||||
}, [mode, updatePublishForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (incognito) {
|
||||
updatePublishForm({ channel: undefined });
|
||||
|
||||
// Anonymous livestreams aren't supported
|
||||
if (mode === PUBLISH_MODES.LIVESTREAM) {
|
||||
setMode(PUBLISH_MODES.FILE);
|
||||
}
|
||||
} else if (activeChannelName) {
|
||||
updatePublishForm({ channel: activeChannelName });
|
||||
}
|
||||
}, [activeChannelName, incognito, updatePublishForm]);
|
||||
|
||||
useEffect(() => {
|
||||
const _uploadType = uploadType && uploadType.toLowerCase();
|
||||
|
||||
// Default to standard file publish if none specified
|
||||
if (!_uploadType) {
|
||||
setMode(PUBLISH_MODES.FILE);
|
||||
return;
|
||||
}
|
||||
|
||||
// File publish
|
||||
if (_uploadType === PUBLISH_MODES.FILE.toLowerCase()) {
|
||||
setMode(PUBLISH_MODES.FILE);
|
||||
return;
|
||||
}
|
||||
// Post publish
|
||||
if (_uploadType === PUBLISH_MODES.POST.toLowerCase()) {
|
||||
setMode(PUBLISH_MODES.POST);
|
||||
return;
|
||||
}
|
||||
// LiveStream publish
|
||||
if (_uploadType === PUBLISH_MODES.LIVESTREAM.toLowerCase()) {
|
||||
setMode(PUBLISH_MODES.LIVESTREAM);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to standard file publish
|
||||
setMode(PUBLISH_MODES.FILE);
|
||||
}, [uploadType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uploadType) return;
|
||||
const newParams = new URLSearchParams();
|
||||
newParams.set('type', mode.toLowerCase());
|
||||
push({ search: newParams.toString() });
|
||||
}, [mode, uploadType]);
|
||||
|
||||
// @if TARGET='web'
|
||||
function createWebFile() {
|
||||
if (fileText) {
|
||||
|
@ -301,7 +353,7 @@ function PublishForm(props: Props) {
|
|||
}
|
||||
}
|
||||
// Publish file
|
||||
if (mode === PUBLISH_MODES.FILE) {
|
||||
if (mode === PUBLISH_MODES.FILE || mode === PUBLISH_MODES.LIVESTREAM) {
|
||||
runPublish = true;
|
||||
}
|
||||
|
||||
|
@ -330,7 +382,7 @@ function PublishForm(props: Props) {
|
|||
// Editing claim uri
|
||||
return (
|
||||
<div className="card-stack">
|
||||
<ChannelSelect disabled={disabled} />
|
||||
<ChannelSelect hideAnon={mode === PUBLISH_MODES.LIVESTREAM} disabled={disabled} />
|
||||
|
||||
<PublishFile
|
||||
uri={uri}
|
||||
|
@ -349,6 +401,7 @@ function PublishForm(props: Props) {
|
|||
label={__(MODE_TO_I18N_STR[String(modeName)] || '---')}
|
||||
button="alt"
|
||||
onClick={() => {
|
||||
// $FlowFixMe
|
||||
setMode(modeName);
|
||||
}}
|
||||
className={classnames('button-toggle', { 'button-toggle--active': mode === modeName })}
|
||||
|
@ -373,17 +426,17 @@ function PublishForm(props: Props) {
|
|||
"Add tags that are relevant to your content so those who're looking for it can find it more easily. If mature content, ensure it is tagged mature. Tag abuse and missing mature tags will not be tolerated."
|
||||
)}
|
||||
placeholder={__('gaming, crypto')}
|
||||
onSelect={newTags => {
|
||||
onSelect={(newTags) => {
|
||||
const validatedTags = [];
|
||||
newTags.forEach(newTag => {
|
||||
if (!tags.some(tag => tag.name === newTag.name)) {
|
||||
newTags.forEach((newTag) => {
|
||||
if (!tags.some((tag) => tag.name === newTag.name)) {
|
||||
validatedTags.push(newTag);
|
||||
}
|
||||
});
|
||||
updatePublishForm({ tags: [...tags, ...validatedTags] });
|
||||
}}
|
||||
onRemove={clickedTag => {
|
||||
const newTags = tags.slice().filter(tag => tag.name !== clickedTag.name);
|
||||
onRemove={(clickedTag) => {
|
||||
const newTags = tags.slice().filter((tag) => tag.name !== clickedTag.name);
|
||||
updatePublishForm({ tags: newTags });
|
||||
}}
|
||||
tagsChosen={tags}
|
||||
|
|
|
@ -36,6 +36,7 @@ import PasswordResetPage from 'page/passwordReset';
|
|||
import PasswordSetPage from 'page/passwordSet';
|
||||
import SignInVerifyPage from 'page/signInVerify';
|
||||
import ChannelsPage from 'page/channels';
|
||||
import LiveStreamSetupPage from 'page/livestreamSetup';
|
||||
import EmbedWrapperPage from 'page/embedWrapper';
|
||||
import TopPage from 'page/top';
|
||||
import Welcome from 'page/welcome';
|
||||
|
@ -275,6 +276,7 @@ function AppRouter(props: Props) {
|
|||
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_BLOCKED_MUTED}`} component={ListBlockedPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.WALLET}`} exact component={WalletPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.CHANNELS}`} component={ChannelsPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.LIVESTREAM}`} component={LiveStreamSetupPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.BUY}`} component={BuyPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
|
||||
|
|
|
@ -277,6 +277,7 @@ export const COMMENT_MODERATION_BLOCK_FAILED = 'COMMENT_MODERATION_BLOCK_FAILED'
|
|||
export const COMMENT_MODERATION_UN_BLOCK_STARTED = 'COMMENT_MODERATION_UN_BLOCK_STARTED';
|
||||
export const COMMENT_MODERATION_UN_BLOCK_COMPLETE = 'COMMENT_MODERATION_UN_BLOCK_COMPLETE';
|
||||
export const COMMENT_MODERATION_UN_BLOCK_FAILED = 'COMMENT_MODERATION_UN_BLOCK_FAILED';
|
||||
export const COMMENT_RECEIVED = 'COMMENT_RECEIVED';
|
||||
|
||||
// Blocked channels
|
||||
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';
|
||||
|
|
|
@ -97,6 +97,7 @@ export const MORE_VERTICAL = 'MoreVertical';
|
|||
export const IMAGE = 'Image';
|
||||
export const AUDIO = 'HeadPhones';
|
||||
export const VIDEO = 'Video';
|
||||
export const LIVESTREAM = 'Livestream';
|
||||
export const VOLUME_MUTED = 'VolumeX';
|
||||
export const TEXT = 'FileText';
|
||||
export const DOWNLOADABLE = 'Downloadable';
|
||||
|
|
2
ui/constants/livestream.js
Normal file
2
ui/constants/livestream.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const BITWAVE_EMBED_URL = 'https://bitwave.tv/odysee';
|
||||
export const BITWAVE_API = 'https://api.bitwave.tv/v1/odysee/live';
|
|
@ -60,3 +60,4 @@ exports.BUY = 'buy';
|
|||
exports.CHANNEL_NEW = 'channel/new';
|
||||
exports.NOTIFICATIONS = 'notifications';
|
||||
exports.YOUTUBE_SYNC = 'youtube';
|
||||
exports.LIVESTREAM = 'livestream';
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export const FILE = 'File';
|
||||
export const POST = 'Post';
|
||||
export const LIVESTREAM = 'Livestream';
|
||||
|
|
19
ui/page/livestream/index.js
Normal file
19
ui/page/livestream/index.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doResolveUri, makeSelectClaimForUri } from 'lbry-redux';
|
||||
import { doSetPlayingUri } from 'redux/actions/content';
|
||||
import { doUserSetReferrer } from 'redux/actions/user';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { selectHasUnclaimedRefereeReward } from 'redux/selectors/rewards';
|
||||
import LivestreamPage from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
hasUnclaimedRefereeReward: selectHasUnclaimedRefereeReward(state),
|
||||
isAuthenticated: selectUserVerifiedEmail(state),
|
||||
channelClaim: makeSelectClaimForUri(props.uri)(state),
|
||||
});
|
||||
|
||||
export default connect(select, {
|
||||
doSetPlayingUri,
|
||||
doResolveUri,
|
||||
doUserSetReferrer,
|
||||
})(LivestreamPage);
|
87
ui/page/livestream/view.jsx
Normal file
87
ui/page/livestream/view.jsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
// @flow
|
||||
import { BITWAVE_API } from 'constants/livestream';
|
||||
import React from 'react';
|
||||
import Page from 'component/page';
|
||||
import LivestreamLayout from 'component/livestreamLayout';
|
||||
import analytics from 'analytics';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: StreamClaim,
|
||||
doSetPlayingUri: ({ uri: ?string }) => void,
|
||||
isAuthenticated: boolean,
|
||||
doUserSetReferrer: (string) => void,
|
||||
channelClaim: ChannelClaim,
|
||||
};
|
||||
|
||||
export default function LivestreamPage(props: Props) {
|
||||
const { uri, claim, doSetPlayingUri, isAuthenticated, doUserSetReferrer, channelClaim } = props;
|
||||
const [activeViewers, setActiveViewers] = React.useState(0);
|
||||
const [isLive, setIsLive] = React.useState(false);
|
||||
const livestreamChannelId = channelClaim && channelClaim.signing_channel && channelClaim.signing_channel.claim_id;
|
||||
|
||||
React.useEffect(() => {
|
||||
function checkIsLive() {
|
||||
// $FlowFixMe Bitwave's API can handle garbage
|
||||
fetch(`${BITWAVE_API}/${livestreamChannelId}`)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res || !res.data) {
|
||||
setIsLive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveViewers(res.data.viewCount);
|
||||
|
||||
if (res.data.hasOwnProperty('live')) {
|
||||
setIsLive(res.data.live);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let interval;
|
||||
if (livestreamChannelId) {
|
||||
if (!interval) checkIsLive();
|
||||
interval = setInterval(checkIsLive, 10 * 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [livestreamChannelId]);
|
||||
|
||||
const stringifiedClaim = JSON.stringify(claim);
|
||||
React.useEffect(() => {
|
||||
if (uri && stringifiedClaim) {
|
||||
const jsonClaim = JSON.parse(stringifiedClaim);
|
||||
|
||||
if (jsonClaim) {
|
||||
const { txid, nout, claim_id: claimId } = jsonClaim;
|
||||
const outpoint = `${txid}:${nout}`;
|
||||
|
||||
analytics.apiLogView(uri, outpoint, claimId);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
|
||||
if (uri) {
|
||||
doUserSetReferrer(uri.replace('lbry://', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [uri, stringifiedClaim, isAuthenticated]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
|
||||
// This can be removed when we start using the app video player, not a bitwave iframe
|
||||
doSetPlayingUri({ uri: null });
|
||||
}, [doSetPlayingUri]);
|
||||
|
||||
return (
|
||||
<Page className="file-page" filePage livestream>
|
||||
<LivestreamLayout uri={uri} activeViewers={activeViewers} isLive={isLive} />
|
||||
</Page>
|
||||
);
|
||||
}
|
12
ui/page/livestreamSetup/index.js
Normal file
12
ui/page/livestreamSetup/index.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
import LivestreamSetupPage from './view';
|
||||
|
||||
const select = (state) => ({
|
||||
channels: selectMyChannelClaims(state),
|
||||
fetchingChannels: selectFetchingMyChannels(state),
|
||||
activeChannelClaim: selectActiveChannelClaim(state),
|
||||
});
|
||||
|
||||
export default connect(select)(LivestreamSetupPage);
|
210
ui/page/livestreamSetup/view.jsx
Normal file
210
ui/page/livestreamSetup/view.jsx
Normal file
|
@ -0,0 +1,210 @@
|
|||
// @flow
|
||||
import * as PAGES from 'constants/pages';
|
||||
import * as PUBLISH_MODES from 'constants/publish_types';
|
||||
import React from 'react';
|
||||
import Page from 'component/page';
|
||||
import Spinner from 'component/spinner';
|
||||
import Button from 'component/button';
|
||||
import ChannelSelector from 'component/channelSelector';
|
||||
import Yrbl from 'component/yrbl';
|
||||
import { Lbry } from 'lbry-redux';
|
||||
import { toHex } from 'util/hex';
|
||||
import { FormField } from 'component/common/form';
|
||||
import CopyableText from 'component/copyableText';
|
||||
import Card from 'component/common/card';
|
||||
import ClaimList from 'component/claimList';
|
||||
|
||||
type Props = {
|
||||
channels: Array<ChannelClaim>,
|
||||
fetchingChannels: boolean,
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
};
|
||||
|
||||
export default function LivestreamSetupPage(props: Props) {
|
||||
const { channels, fetchingChannels, activeChannelClaim } = props;
|
||||
|
||||
const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined });
|
||||
|
||||
const hasChannels = channels && channels.length > 0;
|
||||
const activeChannelClaimStr = JSON.stringify(activeChannelClaim);
|
||||
const streamKey = createStreamKey();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeChannelClaimStr) {
|
||||
const channelClaim = JSON.parse(activeChannelClaimStr);
|
||||
|
||||
// ensure we have a channel
|
||||
if (channelClaim.claim_id) {
|
||||
Lbry.channel_sign({
|
||||
channel_id: channelClaim.claim_id,
|
||||
hexdata: toHex(channelClaim.name),
|
||||
})
|
||||
.then((data) => {
|
||||
setSigData(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
setSigData({ signature: null, signing_ts: null });
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeChannelClaimStr, setSigData]);
|
||||
|
||||
function createStreamKey() {
|
||||
if (!activeChannelClaim || !sigData.signature || !sigData.signing_ts) return null;
|
||||
return `${activeChannelClaim.claim_id}?d=${toHex(activeChannelClaim.name)}&s=${sigData.signature}&t=${
|
||||
sigData.signing_ts
|
||||
}`;
|
||||
}
|
||||
|
||||
const [livestreamClaims, setLivestreamClaims] = React.useState([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeChannelClaimStr) return;
|
||||
|
||||
const channelClaim = JSON.parse(activeChannelClaimStr);
|
||||
|
||||
Lbry.claim_search({
|
||||
channel_ids: [channelClaim.claim_id],
|
||||
has_no_source: true,
|
||||
claim_type: ['stream'],
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.items && res.items.length > 0) {
|
||||
setLivestreamClaims(res.items.reverse());
|
||||
} else {
|
||||
setLivestreamClaims([]);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setLivestreamClaims([]);
|
||||
});
|
||||
}, [activeChannelClaimStr]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
{fetchingChannels && (
|
||||
<div className="main--empty">
|
||||
<Spinner delayed />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fetchingChannels && !hasChannels && (
|
||||
<Yrbl
|
||||
type="happy"
|
||||
title={__("You haven't created a channel yet, let's fix that!")}
|
||||
actions={
|
||||
<div className="section__actions">
|
||||
<Button button="primary" navigate={`/$/${PAGES.CHANNEL_NEW}`} label={__('Create A Channel')} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="card-stack">
|
||||
{!fetchingChannels && activeChannelClaim && (
|
||||
<>
|
||||
<ChannelSelector hideAnon />
|
||||
|
||||
{streamKey && livestreamClaims.length > 0 && (
|
||||
<Card
|
||||
title={__('Your stream key')}
|
||||
actions={
|
||||
<>
|
||||
<CopyableText
|
||||
primaryButton
|
||||
name="stream-server"
|
||||
label={__('Stream server')}
|
||||
copyable="rtmp://stream.odysee.com/live"
|
||||
snackMessage={__('Copied')}
|
||||
/>
|
||||
<CopyableText
|
||||
primaryButton
|
||||
name="livestream-key"
|
||||
label={__('Stream key')}
|
||||
copyable={streamKey}
|
||||
snackMessage={__('Copied')}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{livestreamClaims.length > 0 ? (
|
||||
<ClaimList
|
||||
header={__('Your livestream uploads')}
|
||||
uris={livestreamClaims.map((claim) => claim.permanent_url)}
|
||||
/>
|
||||
) : (
|
||||
<Yrbl
|
||||
className="livestream__publish-intro"
|
||||
title={__('No livestream publishes found')}
|
||||
subtitle={__('You need to upload your livestream details before you can go live.')}
|
||||
actions={
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="primary"
|
||||
navigate={`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.LIVESTREAM.toLowerCase()}`}
|
||||
label={__('Create A Livestream')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Debug Stuff */}
|
||||
{streamKey && false && (
|
||||
<div style={{ marginTop: 'var(--spacing-l)' }}>
|
||||
<h3>Debug Info</h3>
|
||||
|
||||
{/* Channel ID */}
|
||||
<FormField
|
||||
name={'channelId'}
|
||||
label={'Channel ID'}
|
||||
type={'text'}
|
||||
defaultValue={activeChannelClaim.claim_id}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
{/* Signature */}
|
||||
<FormField
|
||||
name={'signature'}
|
||||
label={'Signature'}
|
||||
type={'text'}
|
||||
defaultValue={sigData.signature}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
{/* Signature TS */}
|
||||
<FormField
|
||||
name={'signaturets'}
|
||||
label={'Signature Timestamp'}
|
||||
type={'text'}
|
||||
defaultValue={sigData.signing_ts}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
{/* Hex Data */}
|
||||
<FormField
|
||||
name={'datahex'}
|
||||
label={'Hex Data'}
|
||||
type={'text'}
|
||||
defaultValue={toHex(activeChannelClaim.name)}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
{/* Channel Public Key */}
|
||||
<FormField
|
||||
name={'channelpublickey'}
|
||||
label={'Public Key'}
|
||||
type={'text'}
|
||||
defaultValue={activeChannelClaim.value.public_key}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
|
@ -10,6 +10,7 @@ import {
|
|||
normalizeURI,
|
||||
makeSelectClaimIsMine,
|
||||
makeSelectClaimIsPending,
|
||||
makeSelectClaimHasSource,
|
||||
} from 'lbry-redux';
|
||||
import { makeSelectChannelInSubscriptions } from 'redux/selectors/subscriptions';
|
||||
import { selectBlackListedOutpoints } from 'lbryinc';
|
||||
|
@ -60,11 +61,12 @@ const select = (state, props) => {
|
|||
title: makeSelectTitleForUri(uri)(state),
|
||||
claimIsMine: makeSelectClaimIsMine(uri)(state),
|
||||
claimIsPending: makeSelectClaimIsPending(uri)(state),
|
||||
isLivestream: !makeSelectClaimHasSource(uri)(state),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = dispatch => ({
|
||||
resolveUri: uri => dispatch(doResolveUri(uri)),
|
||||
const perform = (dispatch) => ({
|
||||
resolveUri: (uri) => dispatch(doResolveUri(uri)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(ShowPage);
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Redirect } from 'react-router-dom';
|
|||
import Spinner from 'component/spinner';
|
||||
import ChannelPage from 'page/channel';
|
||||
import FilePage from 'page/file';
|
||||
import LivestreamPage from 'page/livestream';
|
||||
import Page from 'component/page';
|
||||
import Button from 'component/button';
|
||||
import Card from 'component/common/card';
|
||||
|
@ -25,6 +26,7 @@ type Props = {
|
|||
title: string,
|
||||
claimIsMine: boolean,
|
||||
claimIsPending: boolean,
|
||||
isLivestream: boolean,
|
||||
};
|
||||
|
||||
function ShowPage(props: Props) {
|
||||
|
@ -38,6 +40,7 @@ function ShowPage(props: Props) {
|
|||
claimIsMine,
|
||||
isSubscribed,
|
||||
claimIsPending,
|
||||
isLivestream,
|
||||
} = props;
|
||||
|
||||
const signingChannel = claim && claim.signing_channel;
|
||||
|
@ -119,6 +122,10 @@ function ShowPage(props: Props) {
|
|||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLivestream) {
|
||||
innerContent = <LivestreamPage uri={uri} />;
|
||||
} else {
|
||||
innerContent = <FilePage uri={uri} location={location} />;
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ import { doAuthenticate } from 'redux/actions/user';
|
|||
import { lbrySettings as config, version as appVersion } from 'package.json';
|
||||
import analytics, { SHARE_INTERNAL } from 'analytics';
|
||||
import { doSignOutCleanup } from 'util/saved-passwords';
|
||||
import { doSocketConnect } from 'redux/actions/websocket';
|
||||
import { doNotificationSocketConnect } from 'redux/actions/websocket';
|
||||
import { stringifyServerParam, shouldSetSetting } from 'util/sync-settings';
|
||||
|
||||
// @if TARGET='app'
|
||||
|
@ -115,10 +115,10 @@ export function doDownloadUpgrade() {
|
|||
const upgradeFilename = selectUpgradeFilename(state);
|
||||
|
||||
const options = {
|
||||
onProgress: p => dispatch(doUpdateDownloadProgress(Math.round(p * 100))),
|
||||
onProgress: (p) => dispatch(doUpdateDownloadProgress(Math.round(p * 100))),
|
||||
directory: dir,
|
||||
};
|
||||
download(remote.getCurrentWindow(), selectUpdateUrl(state), options).then(downloadItem => {
|
||||
download(remote.getCurrentWindow(), selectUpdateUrl(state), options).then((downloadItem) => {
|
||||
/**
|
||||
* TODO: get the download path directly from the download object. It should just be
|
||||
* downloadItem.getSavePath(), but the copy on the main process is being garbage collected
|
||||
|
@ -149,7 +149,7 @@ export function doDownloadUpgradeRequested() {
|
|||
// This will probably be reorganized once we get auto-update going on Linux and remove
|
||||
// the old logic.
|
||||
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
if (['win32', 'darwin'].includes(process.platform) || !!process.env.APPIMAGE) {
|
||||
// electron-updater behavior
|
||||
dispatch(doOpenModal(MODALS.AUTO_UPDATE_DOWNLOADED));
|
||||
|
@ -174,7 +174,7 @@ export function doClearUpgradeTimer() {
|
|||
}
|
||||
|
||||
export function doAutoUpdate() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: ACTIONS.AUTO_UPDATE_DOWNLOADED,
|
||||
});
|
||||
|
@ -186,7 +186,7 @@ export function doAutoUpdate() {
|
|||
}
|
||||
|
||||
export function doAutoUpdateDeclined() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch(doClearUpgradeTimer());
|
||||
|
||||
dispatch({
|
||||
|
@ -267,7 +267,7 @@ export function doCheckUpgradeAvailable() {
|
|||
Initiate a timer that will check for an app upgrade every 10 minutes.
|
||||
*/
|
||||
export function doCheckUpgradeSubscribe() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
const checkUpgradeTimer = setInterval(() => dispatch(doCheckUpgradeAvailable()), CHECK_UPGRADE_INTERVAL);
|
||||
dispatch({
|
||||
type: ACTIONS.CHECK_UPGRADE_SUBSCRIBE,
|
||||
|
@ -277,7 +277,7 @@ export function doCheckUpgradeSubscribe() {
|
|||
}
|
||||
|
||||
export function doCheckDaemonVersion() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
// @if TARGET='app'
|
||||
Lbry.version().then(({ lbrynet_version: lbrynetVersion }) => {
|
||||
// Avoid the incompatible daemon modal if running in dev mode
|
||||
|
@ -305,31 +305,31 @@ export function doCheckDaemonVersion() {
|
|||
}
|
||||
|
||||
export function doNotifyEncryptWallet() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch(doOpenModal(MODALS.WALLET_ENCRYPT));
|
||||
};
|
||||
}
|
||||
|
||||
export function doNotifyDecryptWallet() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch(doOpenModal(MODALS.WALLET_DECRYPT));
|
||||
};
|
||||
}
|
||||
|
||||
export function doNotifyUnlockWallet() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch(doOpenModal(MODALS.WALLET_UNLOCK));
|
||||
};
|
||||
}
|
||||
|
||||
export function doNotifyForgetPassword(props) {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch(doOpenModal(MODALS.WALLET_PASSWORD_UNSAVE, props));
|
||||
};
|
||||
}
|
||||
|
||||
export function doAlertError(errorList) {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch(doError(errorList));
|
||||
};
|
||||
}
|
||||
|
@ -364,7 +364,7 @@ export function doDaemonReady() {
|
|||
undefined,
|
||||
undefined,
|
||||
shareUsageData,
|
||||
status => {
|
||||
(status) => {
|
||||
const trendingAlgorithm =
|
||||
status &&
|
||||
status.wallet &&
|
||||
|
@ -397,7 +397,7 @@ export function doDaemonReady() {
|
|||
}
|
||||
|
||||
export function doClearCache() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
// Need to update this to work with new version of redux-persist
|
||||
// Leaving for now
|
||||
// const reducersToClear = whiteListedReducers.filter(reducerKey => reducerKey !== 'tags');
|
||||
|
@ -418,7 +418,7 @@ export function doQuit() {
|
|||
}
|
||||
|
||||
export function doQuitAnyDaemon() {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
// @if TARGET='app'
|
||||
Lbry.stop()
|
||||
.catch(() => {
|
||||
|
@ -440,7 +440,7 @@ export function doQuitAnyDaemon() {
|
|||
}
|
||||
|
||||
export function doChangeVolume(volume) {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: ACTIONS.VOLUME_CHANGED,
|
||||
data: {
|
||||
|
@ -451,7 +451,7 @@ export function doChangeVolume(volume) {
|
|||
}
|
||||
|
||||
export function doChangeMute(muted) {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: ACTIONS.VOLUME_MUTED,
|
||||
data: {
|
||||
|
@ -528,7 +528,7 @@ export function doAnalyticsTagSync() {
|
|||
}
|
||||
|
||||
export function doAnaltyicsPurchaseEvent(fileInfo) {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
let purchasePrice = fileInfo.purchase_receipt && fileInfo.purchase_receipt.amount;
|
||||
if (purchasePrice) {
|
||||
const purchaseInt = Number(Number(purchasePrice).toFixed(0));
|
||||
|
@ -544,7 +544,7 @@ export function doSignIn() {
|
|||
const notificationsEnabled = user.experimental_ui;
|
||||
|
||||
if (notificationsEnabled) {
|
||||
dispatch(doSocketConnect());
|
||||
dispatch(doNotificationSocketConnect());
|
||||
dispatch(doNotificationList());
|
||||
}
|
||||
|
||||
|
@ -667,7 +667,7 @@ export function doGetAndPopulatePreferences() {
|
|||
}
|
||||
|
||||
export function doHandleSyncComplete(error, hasNewData) {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
if (!error) {
|
||||
dispatch(doGetAndPopulatePreferences());
|
||||
|
||||
|
@ -680,7 +680,7 @@ export function doHandleSyncComplete(error, hasNewData) {
|
|||
}
|
||||
|
||||
export function doSyncWithPreferences() {
|
||||
return dispatch => dispatch(doSyncLoop());
|
||||
return (dispatch) => dispatch(doSyncLoop());
|
||||
}
|
||||
|
||||
export function doToggleInterestedInYoutubeSync() {
|
||||
|
|
|
@ -196,7 +196,13 @@ export function doCommentReact(commentId: string, type: string) {
|
|||
};
|
||||
}
|
||||
|
||||
export function doCommentCreate(comment: string = '', claim_id: string = '', parent_id?: string, uri: string) {
|
||||
export function doCommentCreate(
|
||||
comment: string = '',
|
||||
claim_id: string = '',
|
||||
parent_id?: string,
|
||||
uri: string,
|
||||
livestream?: boolean = false
|
||||
) {
|
||||
return (dispatch: Dispatch, getState: GetState) => {
|
||||
const state = getState();
|
||||
const activeChannelClaim = selectActiveChannelClaim(state);
|
||||
|
@ -228,6 +234,7 @@ export function doCommentCreate(comment: string = '', claim_id: string = '', par
|
|||
type: ACTIONS.COMMENT_CREATE_COMPLETED,
|
||||
data: {
|
||||
uri,
|
||||
livestream,
|
||||
comment: result,
|
||||
claimId: claim_id,
|
||||
},
|
||||
|
|
|
@ -2,54 +2,87 @@ import * as ACTIONS from 'constants/action_types';
|
|||
import { getAuthToken } from 'util/saved-passwords';
|
||||
import { doNotificationList } from 'redux/actions/notifications';
|
||||
|
||||
let socket = null;
|
||||
const COMMENT_WS_URL = `wss://comments.lbry.com/api/v2/live-chat/subscribe?subscription_id=`;
|
||||
|
||||
let sockets = {};
|
||||
let retryCount = 0;
|
||||
|
||||
export const doSocketConnect = () => dispatch => {
|
||||
const authToken = getAuthToken();
|
||||
if (!authToken) {
|
||||
console.error('Unable to connect to web socket because auth token is missing'); // eslint-disable-line
|
||||
return;
|
||||
}
|
||||
|
||||
export const doSocketConnect = (url, cb) => {
|
||||
function connectToSocket() {
|
||||
if (socket !== null) {
|
||||
socket.close();
|
||||
socket = null;
|
||||
if (sockets[url] !== undefined && sockets[url] !== null) {
|
||||
sockets[url].close();
|
||||
sockets[url] = null;
|
||||
}
|
||||
|
||||
const timeToWait = retryCount ** 2 * 1000;
|
||||
setTimeout(() => {
|
||||
const url = `wss://api.lbry.com/subscribe?auth_token=${authToken}`;
|
||||
socket = new WebSocket(url);
|
||||
socket.onopen = e => {
|
||||
sockets[url] = new WebSocket(url);
|
||||
sockets[url].onopen = (e) => {
|
||||
retryCount = 0;
|
||||
console.log('\nConnected to WS \n\n'); // eslint-disable-line
|
||||
};
|
||||
socket.onmessage = e => {
|
||||
sockets[url].onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (data.type === 'pending_notification') {
|
||||
dispatch(doNotificationList());
|
||||
}
|
||||
cb(data);
|
||||
};
|
||||
|
||||
socket.onerror = e => {
|
||||
sockets[url].onerror = (e) => {
|
||||
console.error('websocket onerror', e); // eslint-disable-line
|
||||
// onerror and onclose will both fire, so nothing is needed here
|
||||
};
|
||||
|
||||
socket.onclose = e => {
|
||||
console.error('websocket onclose', e); // eslint-disable-line
|
||||
retryCount += 1;
|
||||
connectToSocket();
|
||||
};
|
||||
|
||||
sockets[url].onclose = () => {
|
||||
console.log('\n Disconnected from WS\n\n'); // eslint-disable-line
|
||||
sockets[url] = null;
|
||||
};
|
||||
}, timeToWait);
|
||||
}
|
||||
|
||||
connectToSocket();
|
||||
};
|
||||
|
||||
export const doSocketDisconnect = () => ({
|
||||
type: ACTIONS.WS_DISCONNECT,
|
||||
});
|
||||
export const doSocketDisconnect = (url) => (dispatch) => {
|
||||
if (sockets[url] !== undefined && sockets[url] !== null) {
|
||||
sockets[url].close();
|
||||
sockets[url] = null;
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.WS_DISCONNECT,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const doNotificationSocketConnect = () => (dispatch) => {
|
||||
const authToken = getAuthToken();
|
||||
if (!authToken) {
|
||||
console.error('Unable to connect to web socket because auth token is missing'); // eslint-disable-line
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `wss://api.lbry.com/subscribe?auth_token=${authToken}`;
|
||||
doSocketConnect(url, (data) => {
|
||||
if (data.type === 'pending_notification') {
|
||||
dispatch(doNotificationList());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const doCommentSocketConnect = (uri, claimId) => (dispatch) => {
|
||||
const url = `${COMMENT_WS_URL}${claimId}`;
|
||||
|
||||
doSocketConnect(url, (response) => {
|
||||
if (response.type === 'delta') {
|
||||
const newComment = response.data.comment;
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_RECEIVED,
|
||||
data: { comment: newComment, claimId, uri },
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const doCommentSocketDisconnect = (claimId) => (dispatch) => {
|
||||
const url = `${COMMENT_WS_URL}${claimId}`;
|
||||
dispatch(doSocketDisconnect(url));
|
||||
};
|
||||
|
|
|
@ -7,6 +7,9 @@ const defaultState: CommentsState = {
|
|||
byId: {}, // ClaimID -> list of comments
|
||||
repliesByParentId: {}, // ParentCommentID -> list of reply comments
|
||||
topLevelCommentsById: {}, // ClaimID -> list of top level comments
|
||||
// TODO:
|
||||
// Remove commentsByUri
|
||||
// It is not needed and doesn't provide anything but confusion
|
||||
commentsByUri: {}, // URI -> claimId
|
||||
isLoading: false,
|
||||
isCommenting: false,
|
||||
|
@ -35,7 +38,12 @@ export default handleActions(
|
|||
}),
|
||||
|
||||
[ACTIONS.COMMENT_CREATE_COMPLETED]: (state: CommentsState, action: any): CommentsState => {
|
||||
const { comment, claimId, uri }: { comment: Comment, claimId: string, uri: string } = action.data;
|
||||
const {
|
||||
comment,
|
||||
claimId,
|
||||
uri,
|
||||
livestream,
|
||||
}: { comment: Comment, claimId: string, uri: string, livestream: boolean } = action.data;
|
||||
const commentById = Object.assign({}, state.commentById);
|
||||
const byId = Object.assign({}, state.byId);
|
||||
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]}
|
||||
|
@ -44,27 +52,31 @@ export default handleActions(
|
|||
const comments = byId[claimId] || [];
|
||||
const newCommentIds = comments.slice();
|
||||
|
||||
// add the comment by its ID
|
||||
commentById[comment.comment_id] = comment;
|
||||
// If it was created during a livestream, let the websocket handler perform the state update
|
||||
if (!livestream) {
|
||||
// add the comment by its ID
|
||||
commentById[comment.comment_id] = comment;
|
||||
|
||||
// push the comment_id to the top of ID list
|
||||
newCommentIds.unshift(comment.comment_id);
|
||||
byId[claimId] = newCommentIds;
|
||||
// push the comment_id to the top of ID list
|
||||
newCommentIds.unshift(comment.comment_id);
|
||||
byId[claimId] = newCommentIds;
|
||||
|
||||
if (comment['parent_id']) {
|
||||
if (!repliesByParentId[comment.parent_id]) {
|
||||
repliesByParentId[comment.parent_id] = [comment.comment_id];
|
||||
if (comment['parent_id']) {
|
||||
if (!repliesByParentId[comment.parent_id]) {
|
||||
repliesByParentId[comment.parent_id] = [comment.comment_id];
|
||||
} else {
|
||||
repliesByParentId[comment.parent_id].unshift(comment.comment_id);
|
||||
}
|
||||
} else {
|
||||
repliesByParentId[comment.parent_id].unshift(comment.comment_id);
|
||||
}
|
||||
} else {
|
||||
if (!topLevelCommentsById[claimId]) {
|
||||
commentsByUri[uri] = claimId;
|
||||
topLevelCommentsById[claimId] = [comment.comment_id];
|
||||
} else {
|
||||
topLevelCommentsById[claimId].unshift(comment.comment_id);
|
||||
if (!topLevelCommentsById[claimId]) {
|
||||
commentsByUri[uri] = claimId;
|
||||
topLevelCommentsById[claimId] = [comment.comment_id];
|
||||
} else {
|
||||
topLevelCommentsById[claimId].unshift(comment.comment_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
topLevelCommentsById,
|
||||
|
@ -205,6 +217,42 @@ export default handleActions(
|
|||
...state,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
[ACTIONS.COMMENT_RECEIVED]: (state: CommentsState, action: any) => {
|
||||
const { uri, claimId, comment } = action.data;
|
||||
const commentsByUri = Object.assign({}, state.commentsByUri);
|
||||
const commentsByClaimId = Object.assign({}, state.byId);
|
||||
const allCommentsById = Object.assign({}, state.commentById);
|
||||
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById);
|
||||
const commentsForId = topLevelCommentsById[claimId];
|
||||
|
||||
allCommentsById[comment.comment_id] = comment;
|
||||
commentsByUri[uri] = claimId;
|
||||
|
||||
if (commentsForId) {
|
||||
const newCommentsForId = commentsForId.slice();
|
||||
const commentExists = newCommentsForId.includes(comment.comment_id);
|
||||
if (!commentExists) {
|
||||
newCommentsForId.unshift(comment.comment_id);
|
||||
}
|
||||
|
||||
topLevelCommentsById[claimId] = newCommentsForId;
|
||||
} else {
|
||||
topLevelCommentsById[claimId] = [comment.comment_id];
|
||||
}
|
||||
|
||||
// We don't care to keep existing lower level comments since this is just for livestreams
|
||||
commentsByClaimId[claimId] = topLevelCommentsById[claimId];
|
||||
|
||||
return {
|
||||
...state,
|
||||
byId: commentsByClaimId,
|
||||
commentById: allCommentsById,
|
||||
commentsByUri,
|
||||
topLevelCommentsById,
|
||||
};
|
||||
},
|
||||
|
||||
[ACTIONS.COMMENT_ABANDON_STARTED]: (state: CommentsState, action: any) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
@import 'component/form-field';
|
||||
@import 'component/header';
|
||||
@import 'component/icon';
|
||||
@import 'component/livestream';
|
||||
@import 'component/main';
|
||||
@import 'component/markdown-editor';
|
||||
@import 'component/markdown-preview';
|
||||
|
|
|
@ -231,6 +231,11 @@
|
|||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.button--emoji {
|
||||
font-size: 1.25rem;
|
||||
border-radius: 3rem;
|
||||
}
|
||||
|
||||
.button__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -263,12 +263,15 @@ $metadata-z-index: 1;
|
|||
.menu__list.channel__list {
|
||||
margin-top: var(--spacing-xs);
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius);
|
||||
background: transparent;
|
||||
max-height: 15rem;
|
||||
overflow-y: scroll;
|
||||
|
||||
[role='menuitem'] {
|
||||
margin: 0;
|
||||
|
||||
&[data-selected] {
|
||||
background: transparent;
|
||||
.channel__list-item {
|
||||
|
|
203
ui/scss/component/_livestream.scss
Normal file
203
ui/scss/component/_livestream.scss
Normal file
|
@ -0,0 +1,203 @@
|
|||
.livestream {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-top: var(--aspect-ratio-standard);
|
||||
position: relative;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
.media__thumb,
|
||||
iframe {
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius);
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__discussion {
|
||||
min-height: 0%;
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-m);
|
||||
|
||||
@media (min-width: $breakpoint-small) {
|
||||
width: 35rem;
|
||||
margin-left: var(--spacing-m);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__comments-wrapper {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.livestream__comments-wrapper--with-height {
|
||||
height: 40vh;
|
||||
}
|
||||
|
||||
.livestream__comments {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
font-size: var(--font-small);
|
||||
}
|
||||
|
||||
.livestream__comment {
|
||||
margin-top: var(--spacing-s);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> :first-child {
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__comment-author {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.livestream__comment-author--streamer {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.livestream__comment-create {
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.livestream__channel-link {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
box-shadow: 0 0 0 rgba(246, 72, 83, 0.4);
|
||||
animation: livePulse 2s infinite;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(246, 72, 83, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(246, 72, 83, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(246, 72, 83, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__publish-checkbox {
|
||||
margin: var(--spacing-l) 0;
|
||||
|
||||
.checkbox,
|
||||
.radio {
|
||||
margin-top: var(--spacing-m);
|
||||
|
||||
label {
|
||||
color: #444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__creator-message {
|
||||
background-color: #fde68a;
|
||||
padding: var(--spacing-m);
|
||||
color: black;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
font-size: var(--font-small);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__emoji-actions {
|
||||
margin-bottom: var(--spacing-m);
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__embed-page {
|
||||
display: flex;
|
||||
|
||||
.file-viewer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
iframe {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__embed-wrapper {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #000000;
|
||||
|
||||
.livestream {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__embed-countdown {
|
||||
@extend .livestream__embed-wrapper;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.livestream__embed {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.livestream__embed-comments {
|
||||
width: 30vw;
|
||||
height: 100vh;
|
||||
display: none;
|
||||
|
||||
.livestream__discussion {
|
||||
height: 100vh;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card__main-actions {
|
||||
height: 100%;
|
||||
width: 30vw;
|
||||
}
|
||||
|
||||
.livestream__comments-wrapper--with-height {
|
||||
height: calc(100% - 200px - (var(--spacing-l)));
|
||||
}
|
||||
|
||||
@media (min-width: $breakpoint-small) {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.livestream__publish-intro {
|
||||
margin-top: var(--spacing-l);
|
||||
}
|
|
@ -169,6 +169,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.main--livestream {
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
|
||||
.main--full-width {
|
||||
@extend .main;
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
.media__thumb {
|
||||
@include thumbnail;
|
||||
position: relative;
|
||||
border-radius: var(--card-radius);
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
background-color: var(--color-placeholder-background);
|
||||
background-position: center;
|
||||
|
|
59
yarn.lock
59
yarn.lock
|
@ -4418,6 +4418,40 @@ elliptic@^6.0.0:
|
|||
minimalistic-assert "^1.0.1"
|
||||
minimalistic-crypto-utils "^1.0.1"
|
||||
|
||||
emoji-chars@^1.0.0:
|
||||
version "1.0.12"
|
||||
resolved "https://registry.yarnpkg.com/emoji-chars/-/emoji-chars-1.0.12.tgz#432591cac3eafd58beb80f8b5b5e950a96c8de17"
|
||||
integrity sha512-1t7WbkKzQ1hV4dHWM4u8g0SFHSAbxx+8o/lvXisDLTesljSFaxl2wLgMtx4wH922sNcIuLbFty/AuqUDJORd1A==
|
||||
dependencies:
|
||||
emoji-unicode-map "^1.0.0"
|
||||
|
||||
emoji-dictionary@^1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/emoji-dictionary/-/emoji-dictionary-1.0.11.tgz#dc3463e4d768a1e11ffabc9362ac0092472bd308"
|
||||
integrity sha512-pVTiN0StAU2nYy+BtcX/88DavMDjUcONIA6Qsg7/IyDq8xOsRFuC49F7XLUPr7Shlz4bt0/RAqPjuqjpsj3vbA==
|
||||
dependencies:
|
||||
emoji-chars "^1.0.0"
|
||||
emoji-name-map "^1.0.0"
|
||||
emoji-names "^1.0.1"
|
||||
emoji-unicode-map "^1.0.0"
|
||||
emojilib "^2.0.2"
|
||||
|
||||
emoji-name-map@^1.0.0, emoji-name-map@^1.1.0:
|
||||
version "1.2.9"
|
||||
resolved "https://registry.yarnpkg.com/emoji-name-map/-/emoji-name-map-1.2.9.tgz#32a0788e748d9c7185a29a000102fb263fe2892d"
|
||||
integrity sha512-MSM8y6koSqh/2uEMI2VoKA+Ac0qL5RkgFGP/pzL6n5FOrOJ7FOZFxgs7+uNpqA+AT+WmdbMPXkd3HnFXXdz4AA==
|
||||
dependencies:
|
||||
emojilib "^2.0.2"
|
||||
iterate-object "^1.3.1"
|
||||
map-o "^2.0.1"
|
||||
|
||||
emoji-names@^1.0.1:
|
||||
version "1.0.12"
|
||||
resolved "https://registry.yarnpkg.com/emoji-names/-/emoji-names-1.0.12.tgz#4789d72af311e3a9b0343fe8752e77420e32f8f9"
|
||||
integrity sha512-ABXVMPYU9h1/0lNNE9VaT9OxxWXXAv/By8gVMzQYIx7jrhWjyLFVyC34CAN+EP/1e+5WZCklvClo5KSklPCAeg==
|
||||
dependencies:
|
||||
emoji-name-map "^1.0.0"
|
||||
|
||||
emoji-regex@^7.0.1, emoji-regex@^7.0.2:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
|
||||
|
@ -4426,6 +4460,19 @@ emoji-regex@^8.0.0:
|
|||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
|
||||
emoji-unicode-map@^1.0.0:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/emoji-unicode-map/-/emoji-unicode-map-1.1.11.tgz#bc3f726abb186cc4f156316d69322f9067e30947"
|
||||
integrity sha512-GWcWILFyDfR8AU7FRLhKk0lnvcljoEIXejg+XY3Ogz6/ELaQLMo0m4d9R3i79ikIULVEysHBGPsOEcjcFxtN+w==
|
||||
dependencies:
|
||||
emoji-name-map "^1.1.0"
|
||||
iterate-object "^1.3.1"
|
||||
|
||||
emojilib@^2.0.2:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/emojilib/-/emojilib-2.4.0.tgz#ac518a8bb0d5f76dda57289ccb2fdf9d39ae721e"
|
||||
integrity sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==
|
||||
|
||||
emojis-list@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
|
||||
|
@ -6676,6 +6723,11 @@ isurl@^1.0.0-alpha5:
|
|||
has-to-string-tag-x "^1.2.0"
|
||||
is-object "^1.0.1"
|
||||
|
||||
iterate-object@^1.3.0, iterate-object@^1.3.1:
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/iterate-object/-/iterate-object-1.3.4.tgz#fa50b1d9e58e340a7dd6b4c98c8a5e182e790096"
|
||||
integrity sha512-4dG1D1x/7g8PwHS9aK6QV5V94+ZvyP4+d19qDv43EzImmrndysIl4prmJ1hWWIGCqrZHyaHBm6BSEWHOLnpoNw==
|
||||
|
||||
jake@^10.6.1:
|
||||
version "10.8.2"
|
||||
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b"
|
||||
|
@ -7319,6 +7371,13 @@ map-cache@^0.2.2:
|
|||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
|
||||
|
||||
map-o@^2.0.1:
|
||||
version "2.0.10"
|
||||
resolved "https://registry.yarnpkg.com/map-o/-/map-o-2.0.10.tgz#b656a75cead939a936c338fb5df8c9d55f0dbb23"
|
||||
integrity sha512-BxazE81fVByHWasyXhqKeo2m7bFKYu+ZbEfiuexMOnklXW+tzDvnlTi/JaklEeuuwqcqJzPaf9q+TWptSGXeLg==
|
||||
dependencies:
|
||||
iterate-object "^1.3.0"
|
||||
|
||||
map-stream@~0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194"
|
||||
|
|
Loading…
Reference in a new issue