bring in livestream changes from odysee

This commit is contained in:
Sean Yesmunt 2021-03-10 13:34:21 -05:00 committed by DispatchCommit
parent 1ef44ce199
commit 73f593ddb3
24 changed files with 804 additions and 50 deletions

View file

@ -117,6 +117,7 @@
"electron-is-dev": "^0.3.0", "electron-is-dev": "^0.3.0",
"electron-webpack": "^2.8.2", "electron-webpack": "^2.8.2",
"electron-window-state": "^4.1.1", "electron-window-state": "^4.1.1",
"emoji-dictionary": "^1.0.11",
"eslint": "^5.15.2", "eslint": "^5.15.2",
"eslint-config-prettier": "^2.9.0", "eslint-config-prettier": "^2.9.0",
"eslint-config-standard": "^12.0.0", "eslint-config-standard": "^12.0.0",

View file

@ -1,10 +1,16 @@
import { connect } from 'react-redux'; 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 { selectIsPostingComment } from 'redux/selectors/comments';
import { doOpenModal, doSetActiveChannel } from 'redux/actions/app'; import { doOpenModal, doSetActiveChannel } from 'redux/actions/app';
import { doCommentCreate } from 'redux/actions/comments'; import { doCommentCreate } from 'redux/actions/comments';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { doToast } from 'redux/actions/notifications';
import { CommentCreate } from './view'; import { CommentCreate } from './view';
const select = (state, props) => ({ const select = (state, props) => ({
@ -14,12 +20,14 @@ const select = (state, props) => ({
isFetchingChannels: selectFetchingMyChannels(state), isFetchingChannels: selectFetchingMyChannels(state),
isPostingComment: selectIsPostingComment(state), isPostingComment: selectIsPostingComment(state),
activeChannelClaim: selectActiveChannelClaim(state), activeChannelClaim: selectActiveChannelClaim(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
}); });
const perform = (dispatch, ownProps) => ({ 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)),
openModal: (modal, props) => dispatch(doOpenModal(modal, props)), 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); export default connect(select, perform)(CommentCreate);

View file

@ -10,6 +10,16 @@ import usePersistedState from 'effects/use-persisted-state';
import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field'; import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import type { ElementRef } from 'react'; 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 = { type Props = {
uri: string, uri: string,
@ -25,6 +35,9 @@ type Props = {
isPostingComment: boolean, isPostingComment: boolean,
activeChannel: string, activeChannel: string,
activeChannelClaim: ?ChannelClaim, activeChannelClaim: ?ChannelClaim,
livestream?: boolean,
toast: (string) => void,
claimIsMine: boolean,
}; };
export function CommentCreate(props: Props) { export function CommentCreate(props: Props) {
@ -40,11 +53,15 @@ export function CommentCreate(props: Props) {
parentId, parentId,
isPostingComment, isPostingComment,
activeChannelClaim, activeChannelClaim,
livestream,
toast,
claimIsMine,
} = props; } = props;
const buttonref: ElementRef<any> = React.useRef(); const buttonref: ElementRef<any> = React.useRef();
const { push } = useHistory(); const { push } = useHistory();
const { claim_id: claimId } = claim; const { claim_id: claimId } = claim;
const [commentValue, setCommentValue] = React.useState(''); const [commentValue, setCommentValue] = React.useState('');
const [lastCommentTime, setLastCommentTime] = React.useState();
const [charCount, setCharCount] = useState(commentValue.length); const [charCount, setCharCount] = useState(commentValue.length);
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
const hasChannels = channels && channels.length; const hasChannels = channels && channels.length;
@ -79,7 +96,18 @@ export function CommentCreate(props: Props) {
function handleSubmit() { function handleSubmit() {
if (activeChannelClaim && commentValue.length) { if (activeChannelClaim && commentValue.length) {
createComment(commentValue, claimId, parentId).then(res => { const timeUntilCanComment = !lastCommentTime
? 0
: lastCommentTime / 1000 - 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.floor(timeUntilCanComment) })
);
return;
}
createComment(commentValue, claimId, parentId).then((res) => {
if (res && res.signature) { if (res && res.signature) {
setCommentValue(''); setCommentValue('');
@ -144,6 +172,23 @@ export function CommentCreate(props: Props) {
autoFocus={isReply} autoFocus={isReply}
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT} 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"> <div className="section__actions section__actions--no-margin">
<Button <Button
ref={buttonref} ref={buttonref}

View file

@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri } from 'lbry-redux';
import { doCommentSocketConnect } 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, doCommentList })(LivestreamFeed);

View file

@ -0,0 +1,129 @@
// @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) => void,
doCommentList: (string) => void,
comments: Array<Comment>,
fetchingComments: boolean,
};
export default function LivestreamFeed(props: Props) {
const { claim, uri, embed, doCommentSocketConnect, 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(claimId);
}
}, [claimId, uri]);
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_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>
</>
}
/>
);
}

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri } from 'lbry-redux';
import LivestreamLayout from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
});
export default connect(select)(LivestreamLayout);

View file

@ -0,0 +1,34 @@
// @flow
import { BITWAVE_EMBED_URL } from 'constants/livestream';
import React from 'react';
import FileTitle from 'component/fileTitle';
import LivestreamComments from 'component/livestreamComments';
type Props = {
uri: string,
claim: ?StreamClaim,
activeViewers: number,
};
export default function LivestreamLayout(props: Props) {
const { claim, uri, activeViewers } = props;
if (!claim) {
return null;
}
return (
<>
<div className="section card-stack">
<div className="file-render file-render--video livestream">
<div className="file-viewer">
<iframe src={`${BITWAVE_EMBED_URL}/${'doomtube'}?skin=odysee&autoplay=1`} scrolling="no" allowFullScreen />
</div>
</div>
<FileTitle uri={uri} livestream activeViewers={activeViewers} />
</div>
<LivestreamComments uri={uri} />
</>
);
}

View 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);

View file

@ -0,0 +1,68 @@
// @flow
import { LIVE_STREAM_TAG } 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;
React.useEffect(() => {
Lbry.claim_search({
channel_ids: [livestreamChannelId],
any_tags: [LIVE_STREAM_TAG],
claim_type: ['stream'],
})
.then((res) => {
if (res && res.items && res.items.length > 0) {
const claim = res.items[0];
setLivestreamClaim(claim);
}
})
.catch(() => {});
}, [livestreamChannelId]);
React.useEffect(() => {
// function fetchIsStreaming() {
// // fetch(``)
// // .then((res) => res.json())
// // .then((res) => {
// // if (res && res.data && res.data.live) {
// // setIsLivestreaming(true);
// // } else {
// // setIsLivestreaming(false);
// // }
// // })
// // .catch((e) => {});
// }
// let interval;
// if (livestreamChannelId) {
// interval = setInterval(fetchIsStreaming, 5000);
// }
// 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" />}
/>
);
}

View file

@ -28,6 +28,7 @@ type Props = {
fullWidthPage: boolean, fullWidthPage: boolean,
videoTheaterMode: boolean, videoTheaterMode: boolean,
isMarkdown?: boolean, isMarkdown?: boolean,
livestream?: boolean,
backout: { backout: {
backLabel?: string, backLabel?: string,
backNavDefault?: string, backNavDefault?: string,
@ -49,6 +50,7 @@ function Page(props: Props) {
backout, backout,
videoTheaterMode, videoTheaterMode,
isMarkdown = false, isMarkdown = false,
livestream,
} = props; } = props;
const { const {
@ -106,8 +108,9 @@ function Page(props: Props) {
'main--full-width': fullWidthPage, 'main--full-width': fullWidthPage,
'main--auth-page': authPage, 'main--auth-page': authPage,
'main--file-page': filePage, 'main--file-page': filePage,
'main--theater-mode': isOnFilePage && videoTheaterMode,
'main--markdown': isMarkdown, 'main--markdown': isMarkdown,
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream,
'main--livestream': livestream,
})} })}
> >
{children} {children}

View file

@ -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_STARTED = 'COMMENT_MODERATION_UN_BLOCK_STARTED';
export const COMMENT_MODERATION_UN_BLOCK_COMPLETE = 'COMMENT_MODERATION_UN_BLOCK_COMPLETE'; 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_MODERATION_UN_BLOCK_FAILED = 'COMMENT_MODERATION_UN_BLOCK_FAILED';
export const COMMENT_RECEIVED = 'COMMENT_RECEIVED';
// Blocked channels // Blocked channels
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL'; export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';

View file

@ -0,0 +1,4 @@
export const LIVE_STREAM_TAG = 'odysee-livestream';
export const BITWAVE_EMBED_URL = 'https://bitwave.tv/embed';
export const BITWAVE_API = 'https://api.bitwave.tv/v1/channels';

View file

@ -4,7 +4,7 @@ import { selectActiveChannelClaim } from 'redux/selectors/app';
import { doSetActiveChannel } from 'redux/actions/app'; import { doSetActiveChannel } from 'redux/actions/app';
import CreatorDashboardPage from './view'; import CreatorDashboardPage from './view';
const select = state => ({ const select = (state) => ({
channels: selectMyChannelClaims(state), channels: selectMyChannelClaims(state),
fetchingChannels: selectFetchingMyChannels(state), fetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state), activeChannelClaim: selectActiveChannelClaim(state),

View file

@ -0,0 +1,18 @@
import { connect } from 'react-redux';
import { doResolveUri } 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) => ({
hasUnclaimedRefereeReward: selectHasUnclaimedRefereeReward(state),
isAuthenticated: selectUserVerifiedEmail(state),
});
export default connect(select, {
doSetPlayingUri,
doResolveUri,
doUserSetReferrer,
})(LivestreamPage);

View file

@ -0,0 +1,114 @@
// @flow
// import { LIVE_STREAM_TAG, 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,
};
export default function LivestreamPage(props: Props) {
const { uri, claim, doSetPlayingUri, isAuthenticated, doUserSetReferrer } = props;
const [activeViewers, setActiveViewers] = React.useState(0);
React.useEffect(() => {
// function checkIsLive() {
// fetch(`${BITWAVE_API}/`)
// .then((res) => res.json())
// .then((res) => {
// if (!res || !res.data) {
// setIsLive(false);
// return;
// }
// setActiveViewers(res.data.viewCount);
// if (res.data.live) {
// setDisplayCountdown(false);
// setIsLive(true);
// setIsFetching(false);
// return;
// }
// // Not live, but see if we can display the countdown;
// const scheduledTime = res.data.scheduled;
// if (scheduledTime) {
// const scheduledDate = new Date(scheduledTime);
// const lastLiveTimestamp = res.data.timestamp;
// let isLivestreamOver = false;
// if (lastLiveTimestamp) {
// const timestampDate = new Date(lastLiveTimestamp);
// isLivestreamOver = timestampDate.getTime() > scheduledDate.getTime();
// }
// if (isLivestreamOver) {
// setDisplayCountdown(false);
// setIsLive(false);
// } else {
// const datePlusTenMinuteBuffer = scheduledDate.setMinutes(10, 0, 0);
// const isInFuture = Date.now() < datePlusTenMinuteBuffer;
// if (isInFuture) {
// setDisplayCountdown(true);
// setIsLive(false);
// } else {
// setDisplayCountdown(false);
// setIsLive(false);
// }
// }
// setIsFetching(false);
// } else {
// // Offline and no countdown happening
// setIsLive(false);
// setDisplayCountdown(false);
// setActiveViewers(0);
// setIsFetching(false);
// }
// });
// }
// let interval;
// checkIsLive();
// if (uri) {
// interval = setInterval(checkIsLive, 10000);
// }
// return () => {
// if (interval) {
// clearInterval(interval);
// }
// };
}, [uri]);
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} />
</Page>
);
}

View file

@ -1,6 +1,7 @@
import { DOMAIN } from 'config'; import { DOMAIN } from 'config';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { PAGE_SIZE } from 'constants/claim'; import { PAGE_SIZE } from 'constants/claim';
import { LIVE_STREAM_TAG } from 'constants/livestream';
import { import {
doResolveUri, doResolveUri,
makeSelectClaimForUri, makeSelectClaimForUri,
@ -10,6 +11,7 @@ import {
normalizeURI, normalizeURI,
makeSelectClaimIsMine, makeSelectClaimIsMine,
makeSelectClaimIsPending, makeSelectClaimIsPending,
makeSelectTagInClaimOrChannelForUri,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectChannelInSubscriptions } from 'redux/selectors/subscriptions'; import { makeSelectChannelInSubscriptions } from 'redux/selectors/subscriptions';
import { selectBlackListedOutpoints } from 'lbryinc'; import { selectBlackListedOutpoints } from 'lbryinc';
@ -60,11 +62,13 @@ const select = (state, props) => {
title: makeSelectTitleForUri(uri)(state), title: makeSelectTitleForUri(uri)(state),
claimIsMine: makeSelectClaimIsMine(uri)(state), claimIsMine: makeSelectClaimIsMine(uri)(state),
claimIsPending: makeSelectClaimIsPending(uri)(state), claimIsPending: makeSelectClaimIsPending(uri)(state),
// Change to !makeSelectClaimHasSource()
isLivestream: makeSelectTagInClaimOrChannelForUri(uri, LIVE_STREAM_TAG)(state),
}; };
}; };
const perform = dispatch => ({ const perform = (dispatch) => ({
resolveUri: uri => dispatch(doResolveUri(uri)), resolveUri: (uri) => dispatch(doResolveUri(uri)),
}); });
export default connect(select, perform)(ShowPage); export default connect(select, perform)(ShowPage);

View file

@ -5,6 +5,7 @@ import { Redirect } from 'react-router-dom';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import ChannelPage from 'page/channel'; import ChannelPage from 'page/channel';
import FilePage from 'page/file'; import FilePage from 'page/file';
import LivestreamPage from 'page/livestreamStream';
import Page from 'component/page'; import Page from 'component/page';
import Button from 'component/button'; import Button from 'component/button';
import Card from 'component/common/card'; import Card from 'component/common/card';
@ -25,6 +26,7 @@ type Props = {
title: string, title: string,
claimIsMine: boolean, claimIsMine: boolean,
claimIsPending: boolean, claimIsPending: boolean,
isLivestream: boolean,
}; };
function ShowPage(props: Props) { function ShowPage(props: Props) {
@ -38,6 +40,7 @@ function ShowPage(props: Props) {
claimIsMine, claimIsMine,
isSubscribed, isSubscribed,
claimIsPending, claimIsPending,
isLivestream,
} = props; } = props;
const signingChannel = claim && claim.signing_channel; const signingChannel = claim && claim.signing_channel;
@ -119,6 +122,10 @@ function ShowPage(props: Props) {
/> />
</Page> </Page>
); );
}
if (isLivestream) {
innerContent = <LivestreamPage uri={uri} />;
} else { } else {
innerContent = <FilePage uri={uri} location={location} />; innerContent = <FilePage uri={uri} location={location} />;
} }

View file

@ -55,7 +55,7 @@ import { doAuthenticate } from 'redux/actions/user';
import { lbrySettings as config, version as appVersion } from 'package.json'; import { lbrySettings as config, version as appVersion } from 'package.json';
import analytics, { SHARE_INTERNAL } from 'analytics'; import analytics, { SHARE_INTERNAL } from 'analytics';
import { doSignOutCleanup } from 'util/saved-passwords'; 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'; import { stringifyServerParam, shouldSetSetting } from 'util/sync-settings';
// @if TARGET='app' // @if TARGET='app'
@ -115,10 +115,10 @@ export function doDownloadUpgrade() {
const upgradeFilename = selectUpgradeFilename(state); const upgradeFilename = selectUpgradeFilename(state);
const options = { const options = {
onProgress: p => dispatch(doUpdateDownloadProgress(Math.round(p * 100))), onProgress: (p) => dispatch(doUpdateDownloadProgress(Math.round(p * 100))),
directory: dir, 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 * 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 * 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 // This will probably be reorganized once we get auto-update going on Linux and remove
// the old logic. // the old logic.
return dispatch => { return (dispatch) => {
if (['win32', 'darwin'].includes(process.platform) || !!process.env.APPIMAGE) { if (['win32', 'darwin'].includes(process.platform) || !!process.env.APPIMAGE) {
// electron-updater behavior // electron-updater behavior
dispatch(doOpenModal(MODALS.AUTO_UPDATE_DOWNLOADED)); dispatch(doOpenModal(MODALS.AUTO_UPDATE_DOWNLOADED));
@ -174,7 +174,7 @@ export function doClearUpgradeTimer() {
} }
export function doAutoUpdate() { export function doAutoUpdate() {
return dispatch => { return (dispatch) => {
dispatch({ dispatch({
type: ACTIONS.AUTO_UPDATE_DOWNLOADED, type: ACTIONS.AUTO_UPDATE_DOWNLOADED,
}); });
@ -186,7 +186,7 @@ export function doAutoUpdate() {
} }
export function doAutoUpdateDeclined() { export function doAutoUpdateDeclined() {
return dispatch => { return (dispatch) => {
dispatch(doClearUpgradeTimer()); dispatch(doClearUpgradeTimer());
dispatch({ dispatch({
@ -267,7 +267,7 @@ export function doCheckUpgradeAvailable() {
Initiate a timer that will check for an app upgrade every 10 minutes. Initiate a timer that will check for an app upgrade every 10 minutes.
*/ */
export function doCheckUpgradeSubscribe() { export function doCheckUpgradeSubscribe() {
return dispatch => { return (dispatch) => {
const checkUpgradeTimer = setInterval(() => dispatch(doCheckUpgradeAvailable()), CHECK_UPGRADE_INTERVAL); const checkUpgradeTimer = setInterval(() => dispatch(doCheckUpgradeAvailable()), CHECK_UPGRADE_INTERVAL);
dispatch({ dispatch({
type: ACTIONS.CHECK_UPGRADE_SUBSCRIBE, type: ACTIONS.CHECK_UPGRADE_SUBSCRIBE,
@ -277,7 +277,7 @@ export function doCheckUpgradeSubscribe() {
} }
export function doCheckDaemonVersion() { export function doCheckDaemonVersion() {
return dispatch => { return (dispatch) => {
// @if TARGET='app' // @if TARGET='app'
Lbry.version().then(({ lbrynet_version: lbrynetVersion }) => { Lbry.version().then(({ lbrynet_version: lbrynetVersion }) => {
// Avoid the incompatible daemon modal if running in dev mode // Avoid the incompatible daemon modal if running in dev mode
@ -305,31 +305,31 @@ export function doCheckDaemonVersion() {
} }
export function doNotifyEncryptWallet() { export function doNotifyEncryptWallet() {
return dispatch => { return (dispatch) => {
dispatch(doOpenModal(MODALS.WALLET_ENCRYPT)); dispatch(doOpenModal(MODALS.WALLET_ENCRYPT));
}; };
} }
export function doNotifyDecryptWallet() { export function doNotifyDecryptWallet() {
return dispatch => { return (dispatch) => {
dispatch(doOpenModal(MODALS.WALLET_DECRYPT)); dispatch(doOpenModal(MODALS.WALLET_DECRYPT));
}; };
} }
export function doNotifyUnlockWallet() { export function doNotifyUnlockWallet() {
return dispatch => { return (dispatch) => {
dispatch(doOpenModal(MODALS.WALLET_UNLOCK)); dispatch(doOpenModal(MODALS.WALLET_UNLOCK));
}; };
} }
export function doNotifyForgetPassword(props) { export function doNotifyForgetPassword(props) {
return dispatch => { return (dispatch) => {
dispatch(doOpenModal(MODALS.WALLET_PASSWORD_UNSAVE, props)); dispatch(doOpenModal(MODALS.WALLET_PASSWORD_UNSAVE, props));
}; };
} }
export function doAlertError(errorList) { export function doAlertError(errorList) {
return dispatch => { return (dispatch) => {
dispatch(doError(errorList)); dispatch(doError(errorList));
}; };
} }
@ -364,7 +364,7 @@ export function doDaemonReady() {
undefined, undefined,
undefined, undefined,
shareUsageData, shareUsageData,
status => { (status) => {
const trendingAlgorithm = const trendingAlgorithm =
status && status &&
status.wallet && status.wallet &&
@ -397,7 +397,7 @@ export function doDaemonReady() {
} }
export function doClearCache() { export function doClearCache() {
return dispatch => { return (dispatch) => {
// Need to update this to work with new version of redux-persist // Need to update this to work with new version of redux-persist
// Leaving for now // Leaving for now
// const reducersToClear = whiteListedReducers.filter(reducerKey => reducerKey !== 'tags'); // const reducersToClear = whiteListedReducers.filter(reducerKey => reducerKey !== 'tags');
@ -418,7 +418,7 @@ export function doQuit() {
} }
export function doQuitAnyDaemon() { export function doQuitAnyDaemon() {
return dispatch => { return (dispatch) => {
// @if TARGET='app' // @if TARGET='app'
Lbry.stop() Lbry.stop()
.catch(() => { .catch(() => {
@ -440,7 +440,7 @@ export function doQuitAnyDaemon() {
} }
export function doChangeVolume(volume) { export function doChangeVolume(volume) {
return dispatch => { return (dispatch) => {
dispatch({ dispatch({
type: ACTIONS.VOLUME_CHANGED, type: ACTIONS.VOLUME_CHANGED,
data: { data: {
@ -451,7 +451,7 @@ export function doChangeVolume(volume) {
} }
export function doChangeMute(muted) { export function doChangeMute(muted) {
return dispatch => { return (dispatch) => {
dispatch({ dispatch({
type: ACTIONS.VOLUME_MUTED, type: ACTIONS.VOLUME_MUTED,
data: { data: {
@ -528,7 +528,7 @@ export function doAnalyticsTagSync() {
} }
export function doAnaltyicsPurchaseEvent(fileInfo) { export function doAnaltyicsPurchaseEvent(fileInfo) {
return dispatch => { return (dispatch) => {
let purchasePrice = fileInfo.purchase_receipt && fileInfo.purchase_receipt.amount; let purchasePrice = fileInfo.purchase_receipt && fileInfo.purchase_receipt.amount;
if (purchasePrice) { if (purchasePrice) {
const purchaseInt = Number(Number(purchasePrice).toFixed(0)); const purchaseInt = Number(Number(purchasePrice).toFixed(0));
@ -544,7 +544,7 @@ export function doSignIn() {
const notificationsEnabled = user.experimental_ui; const notificationsEnabled = user.experimental_ui;
if (notificationsEnabled) { if (notificationsEnabled) {
dispatch(doSocketConnect()); dispatch(doNotificationSocketConnect());
dispatch(doNotificationList()); dispatch(doNotificationList());
} }
@ -667,7 +667,7 @@ export function doGetAndPopulatePreferences() {
} }
export function doHandleSyncComplete(error, hasNewData) { export function doHandleSyncComplete(error, hasNewData) {
return dispatch => { return (dispatch) => {
if (!error) { if (!error) {
dispatch(doGetAndPopulatePreferences()); dispatch(doGetAndPopulatePreferences());
@ -680,7 +680,7 @@ export function doHandleSyncComplete(error, hasNewData) {
} }
export function doSyncWithPreferences() { export function doSyncWithPreferences() {
return dispatch => dispatch(doSyncLoop()); return (dispatch) => dispatch(doSyncLoop());
} }
export function doToggleInterestedInYoutubeSync() { export function doToggleInterestedInYoutubeSync() {

View file

@ -2,44 +2,34 @@ import * as ACTIONS from 'constants/action_types';
import { getAuthToken } from 'util/saved-passwords'; import { getAuthToken } from 'util/saved-passwords';
import { doNotificationList } from 'redux/actions/notifications'; import { doNotificationList } from 'redux/actions/notifications';
let socket = null; let sockets = {};
let retryCount = 0; let retryCount = 0;
export const doSocketConnect = () => dispatch => { export const doSocketConnect = (url, cb) => {
const authToken = getAuthToken();
if (!authToken) {
console.error('Unable to connect to web socket because auth token is missing'); // eslint-disable-line
return;
}
function connectToSocket() { function connectToSocket() {
if (socket !== null) { if (sockets[url] !== undefined && sockets[url] !== null) {
socket.close(); sockets[url].close();
socket = null; sockets[url] = null;
} }
const timeToWait = retryCount ** 2 * 1000; const timeToWait = retryCount ** 2 * 1000;
setTimeout(() => { setTimeout(() => {
const url = `wss://api.lbry.com/subscribe?auth_token=${authToken}`; sockets[url] = new WebSocket(url);
socket = new WebSocket(url); sockets[url].onopen = (e) => {
socket.onopen = e => {
retryCount = 0; retryCount = 0;
console.log('\nConnected to WS \n\n'); // eslint-disable-line console.log('\nConnected to WS \n\n'); // eslint-disable-line
}; };
socket.onmessage = e => { sockets[url].onmessage = (e) => {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
cb(data);
if (data.type === 'pending_notification') {
dispatch(doNotificationList());
}
}; };
socket.onerror = e => { sockets[url].onerror = (e) => {
console.error('websocket onerror', e); // eslint-disable-line console.error('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
}; };
socket.onclose = e => { sockets[url].onclose = (e) => {
console.error('websocket onclose', e); // eslint-disable-line console.error('websocket onclose', e); // eslint-disable-line
retryCount += 1; retryCount += 1;
connectToSocket(); connectToSocket();
@ -50,6 +40,35 @@ export const doSocketConnect = () => dispatch => {
connectToSocket(); connectToSocket();
}; };
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 = (claimId) => (dispatch) => {
const url = `wss://comments.lbry.com/api/v2/live-chat/subscribe?subscription_id=${claimId}`;
doSocketConnect(url, (response) => {
if (response.type === 'delta') {
const newComment = response.data.comment;
dispatch({
type: ACTIONS.COMMENT_RECEIVED,
data: { comment: newComment, claimId },
});
}
});
};
export const doSocketDisconnect = () => ({ export const doSocketDisconnect = () => ({
type: ACTIONS.WS_DISCONNECT, type: ACTIONS.WS_DISCONNECT,
}); });

View file

@ -28,6 +28,7 @@
@import 'component/form-field'; @import 'component/form-field';
@import 'component/header'; @import 'component/header';
@import 'component/icon'; @import 'component/icon';
@import 'component/livestream';
@import 'component/main'; @import 'component/main';
@import 'component/markdown-editor'; @import 'component/markdown-editor';
@import 'component/markdown-preview'; @import 'component/markdown-preview';

View file

@ -231,6 +231,11 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.button--emoji {
font-size: 1.25rem;
border-radius: 3rem;
}
.button__content { .button__content {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -0,0 +1,198 @@
.livestream {
flex: 1;
width: 100%;
padding-top: var(--aspect-ratio-standard);
position: relative;
border-radius: var(--border-radius);
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;
}
}

View file

@ -169,6 +169,10 @@
} }
} }
.main--livestream {
margin-top: var(--spacing-m);
}
.main--full-width { .main--full-width {
@extend .main; @extend .main;

View file

@ -4418,6 +4418,40 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.1" minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^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: emoji-regex@^7.0.1, emoji-regex@^7.0.2:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" 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" version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" 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: emojis-list@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" 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" has-to-string-tag-x "^1.2.0"
is-object "^1.0.1" 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: jake@^10.6.1:
version "10.8.2" version "10.8.2"
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b" 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" version "0.2.2"
resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" 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: map-stream@~0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194"