Compare commits

...

12 commits

Author SHA1 Message Date
DispatchCommit
ec16061c09 code split every page 2021-03-14 01:00:38 -08:00
DispatchCommit
7fdc689776 default livestream calims state to empty array 2021-03-14 00:36:02 -08:00
DispatchCommit
9eba15b344 show list of live stream claims 2021-03-13 20:07:54 -08:00
DispatchCommit
9825529b29 Fix livestream Title Section 2021-03-13 19:42:21 -08:00
DispatchCommit
d44fdde33e fix minor bugs
- remove anon option in channel dropdown when livestream tab is selected
- attempt to fill publish form with current active channel name just prior to publishing to (edge condition)
  - edge condition occurs when user fills out form fully. User switches to Post (which allows anon in drop down selector). User selects Anon channel, then switches back to the livestream tab. The form was previously updated with `channel: undefined` but does not get changed when clicking the livestream tab. So we just updated the form one last time prior to publishing as a livestream
- Show most recent livestream claim on livestream setup page instead of first livestream claim
2021-03-13 18:49:42 -08:00
DispatchCommit
7f0e3dc6b1 Add link to create livestream claim 2021-03-13 15:05:50 -08:00
Sean Yesmunt
b6320384a0 add new 'livestream' publish mode 2021-03-13 04:11:02 -08:00
DispatchCommit
04b47295ad fix lbry-redux import typo 2021-03-13 04:11:01 -08:00
Sean Yesmunt
7dc44194f9 bring in livestream changes from odysee 2021-03-13 04:10:59 -08:00
DispatchCommit
5e1240df42 Add channel name hex data to streamkey
Also adds individual debug fields to help when debugging a channel verify currently
2021-03-13 04:08:53 -08:00
DispatchCommit
dc1738f1a5 Create livestream page and generate signed streamkey 2021-03-13 04:08:52 -08:00
DispatchCommit
25e999645c Add Go Live to header dropdown 2021-03-13 04:08:51 -08:00
36 changed files with 1283 additions and 190 deletions

View file

@ -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",

View file

@ -1172,6 +1172,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",
@ -1610,5 +1611,6 @@
"Exit Fullscreen (f)": "Exit Fullscreen (f)",
"Toggle Theater mode (t)": "Toggle Theater mode (t)",
"Quality": "Quality",
"--end--": "--end--"
"--end--": "--end--",
"Create A LiveStream": "Create A LiveStream",
}

View file

@ -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>

View file

@ -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,14 @@ 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)),
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);

View file

@ -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,7 +96,18 @@ export function CommentCreate(props: Props) {
function handleSubmit() {
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) {
setCommentValue('');
@ -144,6 +172,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}

View file

@ -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}

View file

@ -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 = (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,11 @@ const Header = (props: Props) => {
history={history}
handleThemeToggle={handleThemeToggle}
currentTheme={currentTheme}
activeChannelUrl={activeChannelUrl}
openSignOutModal={openSignOutModal}
email={email}
signOut={signOut}
livestreamEnabled={livestreamEnabled}
/>
</div>
)}
@ -391,10 +397,26 @@ type HeaderMenuButtonProps = {
history: { push: (string) => void },
handleThemeToggle: (string) => void,
currentTheme: string,
activeChannelUrl: ?string,
openSignOutModal: () => void,
email: ?string,
signOut: () => void,
livestreamEnabled: boolean,
};
function HeaderMenuButtons(props: HeaderMenuButtonProps) {
const { authenticated, notificationsEnabled, history, handleThemeToggle, currentTheme } = props;
const {
authenticated,
notificationsEnabled,
history,
handleThemeToggle,
currentTheme,
activeChannelUrl,
openSignOutModal,
email,
signOut,
livestreamEnabled,
} = props;
return (
<div className="header__buttons">
@ -422,6 +444,15 @@ function HeaderMenuButtons(props: HeaderMenuButtonProps) {
<Icon aria-hidden icon={ICONS.CHANNEL} />
{__('New Channel')}
</MenuItem>
{/* Go Live Button for LiveStreaming */}
{(livestreamEnabled) &&(
<MenuItem className="menu__link" onSelect={() => history.push(`/$/${PAGES.LIVESTREAM}`)}>
<Icon aria-hidden icon={ICONS.VIDEO} />
{__('Go Live')}
</MenuItem>
)}
</MenuList>
</Menu>
)}

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,130 @@
// @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.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 FileTitleSection from 'component/fileTitleSection';
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>
<FileTitleSection 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,
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}

View file

@ -328,6 +328,7 @@ function PublishFile(props: Props) {
const isPublishFile = mode === PUBLISH_MODES.FILE;
const isPublishPost = mode === PUBLISH_MODES.POST;
const isPublishLivestream = mode === PUBLISH_MODES.LIVESTREAM;
return (
<Card
@ -356,7 +357,7 @@ function PublishFile(props: Props) {
value={title}
onChange={handleTitleChange}
/>
{isPublishFile && (
{(isPublishFile || isPublishLivestream) && (
<FileSelector
label={__('File')}
disabled={disabled}
@ -364,6 +365,14 @@ function PublishFile(props: Props) {
onFileChosen={handleFileChange}
/>
)}
{isPublishLivestream && (
<div className="help--warning">
While livestreaming is in beta, you still need to choose a file to upload. Please choose a small file. No
one will see this file.
</div>
)}
{isPublishPost && (
<PostEditor
label={__('Post --[noun, markdown post tab button]--')}

View file

@ -36,6 +36,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 +73,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,
@ -127,7 +128,7 @@ function PublishForm(props: Props) {
} = props;
const TAGS_LIMIT = 5;
const fileFormDisabled = mode === PUBLISH_MODES.FILE && !filePath;
const fileFormDisabled = (mode === PUBLISH_MODES.FILE || mode === PUBLISH_MODES.LIVESTREAM) && !filePath;
const emptyPostError = mode === PUBLISH_MODES.POST && (!fileText || fileText.trim() === '');
const formDisabled = (fileFormDisabled && !editingURI) || emptyPostError || publishing;
const isInProgress = filePath || editingURI || name || title;
@ -225,12 +226,20 @@ 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 });
}
@ -301,10 +310,15 @@ function PublishForm(props: Props) {
}
}
// Publish file
if (mode === PUBLISH_MODES.FILE) {
if (mode === PUBLISH_MODES.FILE || mode === PUBLISH_MODES.LIVESTREAM) {
runPublish = true;
}
// (Try to) Prevent an anon livestream claim
if (mode === PUBLISH_MODES.LIVESTREAM) {
updatePublishForm({ channel: activeChannelName });
}
if (runPublish) {
if (enablePublishPreview) {
setPreviewing(true);
@ -330,7 +344,10 @@ 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}
@ -373,17 +390,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}

View file

@ -1,58 +1,63 @@
// @flow
import * as PAGES from 'constants/pages';
import React, { useEffect } from 'react';
import React, { useEffect, Suspense, lazy } from 'react';
import { Route, Redirect, Switch, withRouter } from 'react-router-dom';
import SettingsPage from 'page/settings';
import SettingsNotificationsPage from 'page/settingsNotifications';
import SettingsAdvancedPage from 'page/settingsAdvanced';
import HelpPage from 'page/help';
// @if TARGET='app'
import BackupPage from 'page/backup';
// @endif
// @if TARGET='web'
import Code2257Page from 'web/page/code2257';
// @endif
import ReportPage from 'page/report';
import ShowPage from 'page/show';
import PublishPage from 'page/publish';
import DiscoverPage from 'page/discover';
import HomePage from 'page/home';
import InvitedPage from 'page/invited';
import RewardsPage from 'page/rewards';
import FileListPublished from 'page/fileListPublished';
import InvitePage from 'page/invite';
import SearchPage from 'page/search';
import LibraryPage from 'page/library';
import WalletPage from 'page/wallet';
import TagsFollowingPage from 'page/tagsFollowing';
import ChannelsFollowingPage from 'page/channelsFollowing';
import ChannelsFollowingDiscoverPage from 'page/channelsFollowingDiscover';
import TagsFollowingManagePage from 'page/tagsFollowingManage';
import ListBlockedPage from 'page/listBlocked';
import FourOhFourPage from 'page/fourOhFour';
import SignInPage from 'page/signIn';
import SignUpPage from 'page/signUp';
import PasswordResetPage from 'page/passwordReset';
import PasswordSetPage from 'page/passwordSet';
import SignInVerifyPage from 'page/signInVerify';
import ChannelsPage from 'page/channels';
import EmbedWrapperPage from 'page/embedWrapper';
import TopPage from 'page/top';
import Welcome from 'page/welcome';
import CreatorDashboard from 'page/creatorDashboard';
import RewardsVerifyPage from 'page/rewardsVerify';
import CheckoutPage from 'page/checkoutPage';
import ChannelNew from 'page/channelNew';
import RepostNew from 'page/repost';
import BuyPage from 'page/buy';
import NotificationsPage from 'page/notifications';
import SignInWalletPasswordPage from 'page/signInWalletPassword';
import YoutubeSyncPage from 'page/youtubeSync';
// constants
import * as PAGES from 'constants/pages';
import { LINKED_COMMENT_QUERY_PARAM } from 'constants/comment';
import { parseURI, isURIValid } from 'lbry-redux';
import { SITE_TITLE, WELCOME_VERSION } from 'config';
// Code Splitting
const BuyPage = lazy(() => import('page/buy'));
const ChannelsPage = lazy(() => import('page/channels'));
const ChannelsFollowingPage = lazy(() => import('page/channelsFollowing'));
const ChannelsFollowingDiscoverPage = lazy(() => import('page/channelsFollowingDiscover'));
const CreatorDashboard = lazy(() => import('page/creatorDashboard'));
const CheckoutPage = lazy(() => import('page/checkoutPage'));
const ChannelNew = lazy(() => import('page/channelNew'));
const DiscoverPage = lazy(() => import('page/discover'));
const EmbedWrapperPage = lazy(() => import('page/embedWrapper'));
const FileListPublished = lazy(() => import('page/fileListPublished'));
const FourOhFourPage = lazy(() => import('page/fourOhFour'));
const HelpPage = lazy(() => import('page/help'));
const HomePage = lazy(() => import('page/home'));
const InvitedPage = lazy(() => import('page/invited'));
const InvitePage = lazy(() => import('page/invite'));
const LiveStreamPage = lazy(() => import('page/livestream'));
const LibraryPage = lazy(() => import('page/library'));
const ListBlockedPage = lazy(() => import('page/listBlocked'));
const NotificationsPage = lazy(() => import('page/notifications'));
const PasswordResetPage = lazy(() => import('page/passwordReset'));
const PasswordSetPage = lazy(() => import('page/passwordSet'));
const PublishPage = lazy(() => import('page/publish'));
const RewardsPage = lazy(() => import('page/rewards'));
const RewardsVerifyPage = lazy(() => import('page/rewardsVerify'));
const ReportPage = lazy(() => import('page/report'));
const RepostNew = lazy(() => import('page/repost'));
const SignInPage = lazy(() => import('page/signIn'));
const SignUpPage = lazy(() => import('page/signUp'));
const SignInVerifyPage = lazy(() => import('page/signInVerify'));
const SearchPage = lazy(() => import('page/search'));
const SettingsPage = lazy(() => import('page/settings'));
const SettingsNotificationsPage = lazy(() => import('page/settingsNotifications'));
const SettingsAdvancedPage = lazy(() => import('page/settingsAdvanced'));
const SignInWalletPasswordPage = lazy(() => import('page/signInWalletPassword'));
const ShowPage = lazy(() => import('page/show'));
const TagsFollowingPage = lazy(() => import('page/tagsFollowing'));
const TagsFollowingManagePage = lazy(() => import('page/tagsFollowingManage'));
const TopPage = lazy(() => import('page/top'));
const WalletPage = lazy(() => import('page/wallet'));
const Welcome = lazy(() => import('page/welcome'));
const YoutubeSyncPage = lazy(() => import('page/youtubeSync'));
// Tell the browser we are handling scroll restoration
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
@ -91,7 +96,12 @@ type PrivateRouteProps = Props & {
};
function PrivateRoute(props: PrivateRouteProps) {
const { component: Component, isAuthenticated, ...rest } = props;
const {
component: Component,
isAuthenticated,
...rest
} = props;
return (
<Route
{...rest}
@ -121,6 +131,7 @@ function AppRouter(props: Props) {
setReferrer,
homepageData,
} = props;
const { entries } = history;
const entryIndex = history.index;
const urlParams = new URLSearchParams(search);
@ -199,93 +210,96 @@ function AppRouter(props: Props) {
}
return (
<Switch>
{/* @if TARGET='app' */}
{welcomeVersion < WELCOME_VERSION && <Route path="/*" component={Welcome} />}
{/* @endif */}
<Redirect
from={`/$/${PAGES.DEPRECATED__CHANNELS_FOLLOWING_MANAGE}`}
to={`/$/${PAGES.CHANNELS_FOLLOWING_DISCOVER}`}
/>
<Redirect from={`/$/${PAGES.DEPRECATED__CHANNELS_FOLLOWING}`} to={`/$/${PAGES.CHANNELS_FOLLOWING}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__TAGS_FOLLOWING}`} to={`/$/${PAGES.TAGS_FOLLOWING}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__TAGS_FOLLOWING_MANAGE}`} to={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__PUBLISH}`} to={`/$/${PAGES.UPLOAD}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__PUBLISHED}`} to={`/$/${PAGES.UPLOADS}`} />
<Route path={`/`} exact component={HomePage} />
<Route path={`/$/${PAGES.DISCOVER}`} exact component={DiscoverPage} />
{/* $FlowFixMe */}
{dynamicRoutes.map((dynamicRouteProps: RowDataItem) => (
<Route
key={dynamicRouteProps.route}
path={dynamicRouteProps.route}
component={(routerProps) => <DiscoverPage {...routerProps} dynamicRouteProps={dynamicRouteProps} />}
<Suspense fallback={<div>Loading...</div>}>
<Switch>
{/* @if TARGET='app' */}
{welcomeVersion < WELCOME_VERSION && <Route path="/*" component={Welcome} />}
{/* @endif */}
<Redirect
from={`/$/${PAGES.DEPRECATED__CHANNELS_FOLLOWING_MANAGE}`}
to={`/$/${PAGES.CHANNELS_FOLLOWING_DISCOVER}`}
/>
))}
<Redirect from={`/$/${PAGES.DEPRECATED__CHANNELS_FOLLOWING}`} to={`/$/${PAGES.CHANNELS_FOLLOWING}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__TAGS_FOLLOWING}`} to={`/$/${PAGES.TAGS_FOLLOWING}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__TAGS_FOLLOWING_MANAGE}`} to={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__PUBLISH}`} to={`/$/${PAGES.UPLOAD}`} />
<Redirect from={`/$/${PAGES.DEPRECATED__PUBLISHED}`} to={`/$/${PAGES.UPLOADS}`} />
<Route path={`/$/${PAGES.AUTH_SIGNIN}`} exact component={SignInPage} />
<Route path={`/$/${PAGES.AUTH_PASSWORD_RESET}`} exact component={PasswordResetPage} />
<Route path={`/$/${PAGES.AUTH_PASSWORD_SET}`} exact component={PasswordSetPage} />
<Route path={`/$/${PAGES.AUTH}`} exact component={SignUpPage} />
<Route path={`/$/${PAGES.AUTH}/*`} exact component={SignUpPage} />
<Route path={`/$/${PAGES.WELCOME}`} exact component={Welcome} />
<Route path={`/`} exact component={HomePage} />
<Route path={`/$/${PAGES.DISCOVER}`} exact component={DiscoverPage} />
{/* $FlowFixMe */}
{dynamicRoutes.map((dynamicRouteProps: RowDataItem) => (
<Route
key={dynamicRouteProps.route}
path={dynamicRouteProps.route}
component={(routerProps) => <DiscoverPage {...routerProps} dynamicRouteProps={dynamicRouteProps} />}
/>
))}
<Route path={`/$/${PAGES.HELP}`} exact component={HelpPage} />
{/* @if TARGET='app' */}
<Route path={`/$/${PAGES.BACKUP}`} exact component={BackupPage} />
{/* @endif */}
{/* @if TARGET='web' */}
<Route path={`/$/${PAGES.CODE_2257}`} exact component={Code2257Page} />
{/* @endif */}
<Route path={`/$/${PAGES.AUTH_VERIFY}`} exact component={SignInVerifyPage} />
<Route path={`/$/${PAGES.SEARCH}`} exact component={SearchPage} />
<Route path={`/$/${PAGES.TOP}`} exact component={TopPage} />
<Route path={`/$/${PAGES.SETTINGS}`} exact component={SettingsPage} />
<Route path={`/$/${PAGES.SETTINGS_ADVANCED}`} exact component={SettingsAdvancedPage} />
<Route path={`/$/${PAGES.INVITE}/:referrer`} exact component={InvitedPage} />
<Route path={`/$/${PAGES.CHECKOUT}`} exact component={CheckoutPage} />
<Route path={`/$/${PAGES.AUTH_SIGNIN}`} exact component={SignInPage} />
<Route path={`/$/${PAGES.AUTH_PASSWORD_RESET}`} exact component={PasswordResetPage} />
<Route path={`/$/${PAGES.AUTH_PASSWORD_SET}`} exact component={PasswordSetPage} />
<Route path={`/$/${PAGES.AUTH}`} exact component={SignUpPage} />
<Route path={`/$/${PAGES.AUTH}/*`} exact component={SignUpPage} />
<Route path={`/$/${PAGES.WELCOME}`} exact component={Welcome} />
<PrivateRoute {...props} exact path={`/$/${PAGES.YOUTUBE_SYNC}`} component={YoutubeSyncPage} />
<PrivateRoute {...props} exact path={`/$/${PAGES.TAGS_FOLLOWING}`} component={TagsFollowingPage} />
<PrivateRoute
{...props}
exact
path={`/$/${PAGES.CHANNELS_FOLLOWING}`}
component={isAuthenticated || !IS_WEB ? ChannelsFollowingPage : DiscoverPage}
/>
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_NOTIFICATIONS}`} component={SettingsNotificationsPage} />
<PrivateRoute
{...props}
exact
path={`/$/${PAGES.CHANNELS_FOLLOWING_DISCOVER}`}
component={ChannelsFollowingDiscoverPage}
/>
<PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} />
<PrivateRoute {...props} path={`/$/${PAGES.CHANNEL_NEW}`} component={ChannelNew} />
<PrivateRoute {...props} path={`/$/${PAGES.REPOST_NEW}`} component={RepostNew} />
<PrivateRoute {...props} path={`/$/${PAGES.UPLOADS}`} component={FileListPublished} />
<PrivateRoute {...props} path={`/$/${PAGES.CREATOR_DASHBOARD}`} component={CreatorDashboard} />
<PrivateRoute {...props} path={`/$/${PAGES.UPLOAD}`} component={PublishPage} />
<PrivateRoute {...props} path={`/$/${PAGES.REPORT}`} component={ReportPage} />
<PrivateRoute {...props} path={`/$/${PAGES.REWARDS}`} exact component={RewardsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.REWARDS_VERIFY}`} component={RewardsVerifyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LIBRARY}`} component={LibraryPage} />
<PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} />
<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.BUY}`} component={BuyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
<Route path={`/$/${PAGES.HELP}`} exact component={HelpPage} />
{/* @if TARGET='app' */}
<Route path={`/$/${PAGES.BACKUP}`} exact component={BackupPage} />
{/* @endif */}
{/* @if TARGET='web' */}
<Route path={`/$/${PAGES.CODE_2257}`} exact component={Code2257Page} />
{/* @endif */}
<Route path={`/$/${PAGES.AUTH_VERIFY}`} exact component={SignInVerifyPage} />
<Route path={`/$/${PAGES.SEARCH}`} exact component={SearchPage} />
<Route path={`/$/${PAGES.TOP}`} exact component={TopPage} />
<Route path={`/$/${PAGES.SETTINGS}`} exact component={SettingsPage} />
<Route path={`/$/${PAGES.SETTINGS_ADVANCED}`} exact component={SettingsAdvancedPage} />
<Route path={`/$/${PAGES.INVITE}/:referrer`} exact component={InvitedPage} />
<Route path={`/$/${PAGES.CHECKOUT}`} exact component={CheckoutPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} />
{/* Below need to go at the end to make sure we don't match any of our pages first */}
<Route path="/:claimName" exact component={ShowPage} />
<Route path="/:claimName/:streamName" exact component={ShowPage} />
<Route path="/*" component={FourOhFourPage} />
</Switch>
<PrivateRoute {...props} exact path={`/$/${PAGES.YOUTUBE_SYNC}`} component={YoutubeSyncPage} />
<PrivateRoute {...props} exact path={`/$/${PAGES.TAGS_FOLLOWING}`} component={TagsFollowingPage} />
<PrivateRoute
{...props}
exact
path={`/$/${PAGES.CHANNELS_FOLLOWING}`}
component={isAuthenticated || !IS_WEB ? ChannelsFollowingPage : DiscoverPage}
/>
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_NOTIFICATIONS}`} component={SettingsNotificationsPage} />
<PrivateRoute
{...props}
exact
path={`/$/${PAGES.CHANNELS_FOLLOWING_DISCOVER}`}
component={ChannelsFollowingDiscoverPage}
/>
<PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} />
<PrivateRoute {...props} path={`/$/${PAGES.CHANNEL_NEW}`} component={ChannelNew} />
<PrivateRoute {...props} path={`/$/${PAGES.REPOST_NEW}`} component={RepostNew} />
<PrivateRoute {...props} path={`/$/${PAGES.UPLOADS}`} component={FileListPublished} />
<PrivateRoute {...props} path={`/$/${PAGES.CREATOR_DASHBOARD}`} component={CreatorDashboard} />
<PrivateRoute {...props} path={`/$/${PAGES.UPLOAD}`} component={PublishPage} />
<PrivateRoute {...props} path={`/$/${PAGES.REPORT}`} component={ReportPage} />
<PrivateRoute {...props} path={`/$/${PAGES.REWARDS}`} exact component={RewardsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.REWARDS_VERIFY}`} component={RewardsVerifyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LIBRARY}`} component={LibraryPage} />
<PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} />
<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={LiveStreamPage} />
<PrivateRoute {...props} path={`/$/${PAGES.BUY}`} component={BuyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName`} exact component={EmbedWrapperPage} />
<Route path={`/$/${PAGES.EMBED}/:claimName/:claimId`} exact component={EmbedWrapperPage} />
{/* Below need to go at the end to make sure we don't match any of our pages first */}
<Route path="/:claimName" exact component={ShowPage} />
<Route path="/:claimName/:streamName" exact component={ShowPage} />
<Route path="/*" component={FourOhFourPage} />
</Switch>
</Suspense>
);
}

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_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';

View file

@ -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';

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

@ -48,3 +48,4 @@ exports.BUY = 'buy';
exports.CHANNEL_NEW = 'channel/new';
exports.NOTIFICATIONS = 'notifications';
exports.YOUTUBE_SYNC = 'youtube';
exports.LIVESTREAM = 'livestream';

View file

@ -1,2 +1,3 @@
export const FILE = 'File';
export const POST = 'Post';
export const LIVESTREAM = 'Livestream';

View file

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import { selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux';
import { selectActiveChannelClaim } from 'redux/selectors/app';
import { doSetActiveChannel } from 'redux/actions/app';
import CreatorDashboardPage from './view';
const select = (state) => ({
channels: selectMyChannelClaims(state),
fetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state),
});
export default connect(select, { doSetActiveChannel })(CreatorDashboardPage);

240
ui/page/livestream/view.jsx Normal file
View file

@ -0,0 +1,240 @@
// @flow
import * as PAGES from 'constants/pages';
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 ClaimPreview from '../../component/claimPreview';
import { FormField } from '../../component/common/form';
import * as PUBLISH_MODES from '../../constants/publish_types';
type Props = {
channels: Array<ChannelClaim>,
fetchingChannels: boolean,
activeChannelClaim: ?ChannelClaim,
};
export default function CreatorDashboardPage(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) => {
console.log(data);
setSigData(data);
})
.catch((error) => {
setSigData({ signature: null, signing_ts: null });
console.error(error);
});
}
}
}, [ 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 LIVE_STREAM_TAG = 'odysee-livestream';
const [isFetching, setIsFetching] = React.useState(true);
const [isLive, setIsLive] = React.useState(false);
const [livestreamClaim, setLivestreamClaim] = React.useState(false);
const [livestreamClaims, setLivestreamClaims] = React.useState([]);
React.useEffect(() => {
if (!activeChannelClaimStr) return;
const channelClaim = JSON.parse(activeChannelClaimStr);
Lbry.claim_search({
channel_ids: [channelClaim.claim_id],
any_tags: [LIVE_STREAM_TAG],
claim_type: ['stream'],
})
.then((res) => {
if (res && res.items && res.items.length > 0) {
const claim = res.items[res.items.length - 1];
setLivestreamClaim(claim);
setLivestreamClaims(res.items.reverse());
} else {
setIsFetching(false);
}
})
.catch(() => {
setIsFetching(false);
});
}, [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>
}
/>
)}
{!fetchingChannels && activeChannelClaim && (
<React.Fragment>
{/* Channel Selector */}
<ChannelSelector hideAnon />
{/* Display StreamKey */}
{ streamKey
? (<div>
{/* Stream Server Address */}
<FormField
name={'livestreamServer'}
label={'Stream Server'}
type={'text'}
defaultValue={'rtmp://stream.odysee.com/live'}
readOnly
/>
{/* Stream Key */}
<FormField
name={'livestreamKey'}
label={'Stream Key'}
type={'text'}
defaultValue={streamKey}
readOnly
/>
</div>)
: (
<div>
<div style={{marginBottom: 'var(--spacing-l)'}}>
{JSON.stringify(activeChannelClaim)}
</div>
{ sigData &&
<div>
{JSON.stringify(sigData)}
</div>
}
</div>
)
}
{/* Stream Claim(s) */}
{ livestreamClaim && livestreamClaims ? (
<div style={{marginTop: 'var(--spacing-l)'}}>
<h3>Your LiveStream Claims</h3>
{livestreamClaims.map(claim => (
<ClaimPreview
key={claim.uri}
uri={claim.permanent_url}
/>
))}
{/*<h3>Your LiveStream Claims</h3>
<ClaimPreview
uri={livestreamClaim.permanent_url}
/>*/}
</div>
) : (
<div style={{marginTop: 'var(--spacing-l)'}}>
<div>You must first publish a livestream claim before your stream will be visible!</div>
{/* Relies on https://github.com/lbryio/lbry-desktop/pull/5669 */}
<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>
)}
</React.Fragment>
)}
</Page>
);
}

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

View file

@ -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/livestreamStream';
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} />;
}

View file

@ -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() {

View file

@ -2,44 +2,34 @@ import * as ACTIONS from 'constants/action_types';
import { getAuthToken } from 'util/saved-passwords';
import { doNotificationList } from 'redux/actions/notifications';
let socket = null;
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 => {
sockets[url].onclose = (e) => {
console.error('websocket onclose', e); // eslint-disable-line
retryCount += 1;
connectToSocket();
@ -50,6 +40,35 @@ export const doSocketConnect = () => dispatch => {
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 = () => ({
type: ACTIONS.WS_DISCONNECT,
});

View file

@ -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';

View file

@ -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;

View file

@ -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 {

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 {
@extend .main;

View file

@ -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"