use redux for livestream claim setup

This commit is contained in:
zeppi 2021-04-22 23:04:11 -04:00 committed by Sean Yesmunt
parent b0193202d1
commit f3463ebdeb
8 changed files with 196 additions and 140 deletions

View file

@ -20,3 +20,7 @@ declare type LivestreamReplayItem = {
id: string, id: string,
} }
declare type LivestreamReplayData = Array<LivestreamReplayItem>; declare type LivestreamReplayData = Array<LivestreamReplayItem>;
declare type LivestreamState = {
idsFetching: {},
}

View file

@ -324,3 +324,8 @@ export const REACTIONS_DISLIKE_COMPLETED = 'REACTIONS_DISLIKE_COMPLETED';
export const REPORT_CONTENT_STARTED = 'REPORT_CONTENT_STARTED'; export const REPORT_CONTENT_STARTED = 'REPORT_CONTENT_STARTED';
export const REPORT_CONTENT_COMPLETED = 'REPORT_CONTENT_COMPLETED'; export const REPORT_CONTENT_COMPLETED = 'REPORT_CONTENT_COMPLETED';
export const REPORT_CONTENT_FAILED = 'REPORT_CONTENT_FAILED'; export const REPORT_CONTENT_FAILED = 'REPORT_CONTENT_FAILED';
// Livestream
export const FETCH_NO_SOURCE_CLAIMS_STARTED = 'FETCH_NO_SOURCE_CLAIMS_STARTED';
export const FETCH_NO_SOURCE_CLAIMS_COMPLETED = 'FETCH_NO_SOURCE_CLAIMS_COMPLETED';
export const FETCH_NO_SOURCE_CLAIMS_FAILED = 'FETCH_NO_SOURCE_CLAIMS_FAILED';

View file

@ -1,19 +1,34 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectMyChannelClaims, selectFetchingMyChannels, selectPendingClaims, doClearPublish } from 'lbry-redux'; import { selectMyChannelClaims, selectFetchingMyChannels, doClearPublish } from 'lbry-redux';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { doFetchNoSourceClaims } from 'redux/actions/livestream';
import {
makeSelectPendingLivestreamsForChannelId,
makeSelectLivestreamsForChannelId,
makeSelectIsFetchingLivestreams,
} from 'redux/selectors/livestream';
import LivestreamSetupPage from './view'; import LivestreamSetupPage from './view';
import { push } from 'connected-react-router'; import { push } from 'connected-react-router';
const select = (state) => ({ const select = (state) => {
const activeChannelClaim = selectActiveChannelClaim(state);
const { claim_id: channelId, name: channelName } = activeChannelClaim || {};
return {
channelName,
channelId,
channels: selectMyChannelClaims(state), channels: selectMyChannelClaims(state),
fetchingChannels: selectFetchingMyChannels(state), fetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state), activeChannelClaim,
pendingClaims: selectPendingClaims(state), myLivestreamClaims: makeSelectLivestreamsForChannelId(channelId)(state),
}); pendingClaims: makeSelectPendingLivestreamsForChannelId(channelId)(state),
fetchingLivestreams: makeSelectIsFetchingLivestreams(channelId)(state),
};
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
doNewLivestream: (path) => { doNewLivestream: (path) => {
dispatch(doClearPublish()); dispatch(doClearPublish());
dispatch(push(path)); dispatch(push(path));
}, },
fetchNoSourceClaims: (id) => dispatch(doFetchNoSourceClaims(id)),
}); });
export default connect(select, perform)(LivestreamSetupPage); export default connect(select, perform)(LivestreamSetupPage);

View file

@ -16,7 +16,6 @@ import CopyableText from 'component/copyableText';
import Card from 'component/common/card'; import Card from 'component/common/card';
import ClaimList from 'component/claimList'; import ClaimList from 'component/claimList';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import usePrevious from 'effects/use-previous';
type Props = { type Props = {
channels: Array<ChannelClaim>, channels: Array<ChannelClaim>,
@ -24,47 +23,43 @@ type Props = {
activeChannelClaim: ?ChannelClaim, activeChannelClaim: ?ChannelClaim,
pendingClaims: Array<Claim>, pendingClaims: Array<Claim>,
doNewLivestream: (string) => void, doNewLivestream: (string) => void,
fetchNoSourceClaims: (string) => void,
myLivestreamClaims: Array<Claim>,
fetchingLivestreams: boolean,
channelId: ?string,
channelName: ?string,
}; };
export default function LivestreamSetupPage(props: Props) { export default function LivestreamSetupPage(props: Props) {
const LIVESTREAM_CLAIM_POLL_IN_MS = 60000; const LIVESTREAM_CLAIM_POLL_IN_MS = 60000;
const { channels, fetchingChannels, activeChannelClaim, pendingClaims, doNewLivestream } = props; const {
channels,
fetchingChannels,
activeChannelClaim,
pendingClaims,
doNewLivestream,
fetchNoSourceClaims,
myLivestreamClaims,
fetchingLivestreams,
channelId,
channelName,
} = props;
const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined }); const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined });
const [showHelpTest, setShowHelpTest] = usePersistedState('livestream-help-seen', true); const [showHelp, setShowHelp] = usePersistedState('livestream-help-seen', true);
const [spin, setSpin] = React.useState(true);
const [livestreamClaims, setLivestreamClaims] = React.useState([]);
const hasChannels = channels && channels.length > 0; const hasChannels = channels && channels.length > 0;
const activeChannelClaimStr = JSON.stringify(activeChannelClaim); const hasLivestreamClaims = Boolean(myLivestreamClaims.length || pendingClaims.length);
function createStreamKey() { function createStreamKey() {
if (!activeChannelClaim || !sigData.signature || !sigData.signing_ts) return null; if (!channelId || !channelName || !sigData.signature || !sigData.signing_ts) return null;
return `${activeChannelClaim.claim_id}?d=${toHex(activeChannelClaim.name)}&s=${sigData.signature}&t=${ return `${channelId}?d=${toHex(channelName)}&s=${sigData.signature}&t=${sigData.signing_ts}`;
sigData.signing_ts
}`;
} }
const streamKey = createStreamKey(); const streamKey = createStreamKey();
const pendingLiveStreamClaims = pendingClaims
? pendingClaims.filter( const pendingLength = pendingClaims.length;
(claim) => const totalLivestreamClaims = pendingClaims.concat(myLivestreamClaims);
// $FlowFixMe
claim.value_type === 'stream' && !(claim.value && claim.value.source)
)
: [];
const [localPending, setLocalPending] = React.useState([]); //
const localPendingStr = JSON.stringify(localPending);
const pendingLivestreamClaimsStr = JSON.stringify(pendingLiveStreamClaims);
const prevPendingLiveStreamClaimStr = usePrevious(pendingLivestreamClaimsStr);
const liveStreamClaimsStr = JSON.stringify(livestreamClaims);
const prevLiveStreamClaimsStr = JSON.stringify(liveStreamClaimsStr);
const pendingLength = pendingLiveStreamClaims.length;
const totalLivestreamClaims = pendingLiveStreamClaims.concat(livestreamClaims);
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
const localPendingForChannel = localPending.filter(
(claim) => claim.signing_channel && claim.signing_channel.claim_id === activeChannelId
);
const helpText = ( const helpText = (
<div className="section__subtitle"> <div className="section__subtitle">
<p> <p>
@ -104,14 +99,11 @@ export default function LivestreamSetupPage(props: Props) {
); );
React.useEffect(() => { React.useEffect(() => {
if (activeChannelClaimStr) {
const channelClaim = JSON.parse(activeChannelClaimStr);
// ensure we have a channel // ensure we have a channel
if (channelClaim.claim_id) { if (channelId && channelName) {
Lbry.channel_sign({ Lbry.channel_sign({
channel_id: channelClaim.claim_id, channel_id: channelId,
hexdata: toHex(channelClaim.name), hexdata: toHex(channelName),
}) })
.then((data) => { .then((data) => {
setSigData(data); setSigData(data);
@ -120,96 +112,28 @@ export default function LivestreamSetupPage(props: Props) {
setSigData({ signature: null, signing_ts: null }); setSigData({ signature: null, signing_ts: null });
}); });
} }
} }, [channelName, channelId, setSigData]);
}, [activeChannelClaimStr, setSigData]);
// The following 2 effects handle the time between pending disappearing and claim_search being able to find it.
// We'll maintain our own pending list:
// add to it when there are new things in pending
// remove items only when our claim_search finds it
React.useEffect(() => {
// add to localPending when pending changes
const localPending = JSON.parse(localPendingStr);
const pendingLivestreamClaims = JSON.parse(pendingLivestreamClaimsStr);
if (
pendingLiveStreamClaims !== prevPendingLiveStreamClaimStr ||
(pendingLivestreamClaims.length && !localPending.length)
) {
const prevPendingLivestreamClaims = prevPendingLiveStreamClaimStr
? JSON.parse(prevPendingLiveStreamClaimStr)
: [];
const pendingClaimIds = pendingLivestreamClaims.map((claim) => claim.claim_id);
const prevPendingClaimIds = prevPendingLivestreamClaims.map((claim) => claim.claim_id);
const newLocalPending = [];
if (pendingClaimIds.length > prevPendingClaimIds.length) {
pendingLivestreamClaims.forEach((pendingClaim) => {
if (!localPending.some((lClaim) => lClaim.claim_id === pendingClaim.claim_id)) {
newLocalPending.push(pendingClaim);
}
});
setLocalPending(localPending.concat(newLocalPending));
}
}
}, [pendingLivestreamClaimsStr, prevPendingLiveStreamClaimStr, localPendingStr, setLocalPending]);
React.useEffect(() => {
// remove from localPending when livestreamClaims found
const localPending = JSON.parse(localPendingStr);
if (liveStreamClaimsStr !== prevLiveStreamClaimsStr && localPending.length) {
const livestreamClaims = JSON.parse(liveStreamClaimsStr);
setLocalPending(
localPending.filter((pending) => !livestreamClaims.some((claim) => claim.claim_id === pending.claim_id))
);
}
}, [liveStreamClaimsStr, prevLiveStreamClaimsStr, localPendingStr, setLocalPending]);
const checkLivestreams = React.useCallback(
function checkLivestreamClaims(channelClaimId, setLivestreamClaims, setSpin) {
Lbry.claim_search({
channel_ids: [channelClaimId],
has_no_source: true,
claim_type: ['stream'],
include_purchase_receipt: true,
})
.then((res) => {
if (res && res.items && res.items.length > 0) {
setLivestreamClaims(res.items.reverse());
} else {
setLivestreamClaims([]);
}
setSpin(false);
})
.catch(() => {
setLivestreamClaims([]);
setSpin(false);
});
},
[activeChannelId]
);
React.useEffect(() => { React.useEffect(() => {
let checkClaimsInterval; let checkClaimsInterval;
if (!activeChannelClaimStr) return; if (!channelId) return;
const channelClaim = JSON.parse(activeChannelClaimStr);
if (!checkClaimsInterval) { if (!checkClaimsInterval) {
checkLivestreams(channelClaim.claim_id, setLivestreamClaims, setSpin); fetchNoSourceClaims(channelId);
checkClaimsInterval = setInterval( checkClaimsInterval = setInterval(() => fetchNoSourceClaims(channelId), LIVESTREAM_CLAIM_POLL_IN_MS);
() => checkLivestreams(channelClaim.claim_id, setLivestreamClaims, setSpin),
LIVESTREAM_CLAIM_POLL_IN_MS
);
} }
return () => { return () => {
if (checkClaimsInterval) { if (checkClaimsInterval) {
clearInterval(checkClaimsInterval); clearInterval(checkClaimsInterval);
} }
}; };
}, [activeChannelClaimStr, pendingLength, setSpin, checkLivestreams]); }, [channelId, pendingLength, fetchNoSourceClaims]);
return ( return (
<Page> <Page>
{(fetchingChannels || spin) && ( {fetchingChannels && (
<div className="main--empty"> <div className="main--empty">
<Spinner delayed /> <Spinner />
</div> </div>
)} )}
@ -227,21 +151,21 @@ export default function LivestreamSetupPage(props: Props) {
{!fetchingChannels && ( {!fetchingChannels && (
<div className="section__actions--between"> <div className="section__actions--between">
<ChannelSelector hideAnon /> <ChannelSelector hideAnon />
<Button button="link" onClick={() => setShowHelpTest(!showHelpTest)} label={__('How does this work?')} /> <Button button="link" onClick={() => setShowHelp(!showHelp)} label={__('How does this work?')} />
</div> </div>
)} )}
{spin && !fetchingChannels && ( {fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && (
<div className="main--empty"> <div className="main--empty">
<Spinner delayed /> <Spinner />
</div> </div>
)} )}
<div className="card-stack"> <div className="card-stack">
{!spin && !fetchingChannels && activeChannelClaim && ( {hasLivestreamClaims && !fetchingChannels && channelId && (
<> <>
{showHelpTest && ( {showHelp && (
<Card <Card
titleActions={<Button button="close" icon={ICONS.REMOVE} onClick={() => setShowHelpTest(false)} />} titleActions={<Button button="close" icon={ICONS.REMOVE} onClick={() => setShowHelp(false)} />}
title={__('Go Live on Odysee')} title={__('Go Live on Odysee')}
subtitle={__(`You're invited to try out our new livestreaming service while in beta!`)} subtitle={__(`You're invited to try out our new livestreaming service while in beta!`)}
actions={helpText} actions={helpText}
@ -274,15 +198,15 @@ export default function LivestreamSetupPage(props: Props) {
{totalLivestreamClaims.length > 0 ? ( {totalLivestreamClaims.length > 0 ? (
<> <>
{Boolean(localPendingForChannel.length) && ( {Boolean(pendingClaims.length) && (
<div className="section"> <div className="section">
<ClaimList <ClaimList
header={__('Your pending livestream uploads')} header={__('Your pending livestream uploads')}
uris={localPendingForChannel.map((claim) => claim.permanent_url)} uris={pendingClaims.map((claim) => claim.permanent_url)}
/> />
</div> </div>
)} )}
{Boolean(livestreamClaims.length) && ( {Boolean(myLivestreamClaims.length) && (
<div className="section"> <div className="section">
<ClaimList <ClaimList
header={__('Your livestream uploads')} header={__('Your livestream uploads')}
@ -292,7 +216,7 @@ export default function LivestreamSetupPage(props: Props) {
check_again: ( check_again: (
<Button <Button
button="link" button="link"
onClick={() => checkLivestreams(activeChannelId, setLivestreamClaims, setSpin)} onClick={() => fetchNoSourceClaims(channelId)}
label={__('Check again')} label={__('Check again')}
/> />
), ),
@ -301,8 +225,10 @@ export default function LivestreamSetupPage(props: Props) {
Nothing here yet. %check_again% Nothing here yet. %check_again%
</I18nMessage> </I18nMessage>
} }
uris={livestreamClaims uris={myLivestreamClaims
.filter((c) => !pendingLiveStreamClaims.some((p) => p.permanent_url === c.permanent_url)) .filter(
(claim) => !pendingClaims.some((pending) => pending.permanent_url === claim.permanent_url)
)
.map((claim) => claim.permanent_url)} .map((claim) => claim.permanent_url)}
/> />
</div> </div>
@ -327,8 +253,7 @@ export default function LivestreamSetupPage(props: Props) {
<Button <Button
button="alt" button="alt"
onClick={() => { onClick={() => {
setSpin(true); fetchNoSourceClaims(channelId);
checkLivestreams(activeChannelId, setLivestreamClaims, setSpin);
}} }}
label={__('Check again...')} label={__('Check again...')}
/> />
@ -338,7 +263,7 @@ export default function LivestreamSetupPage(props: Props) {
)} )}
{/* Debug Stuff */} {/* Debug Stuff */}
{streamKey && false && ( {streamKey && false && activeChannelClaim && (
<div style={{ marginTop: 'var(--spacing-l)' }}> <div style={{ marginTop: 'var(--spacing-l)' }}>
<h3>Debug Info</h3> <h3>Debug Info</h3>

View file

@ -13,6 +13,7 @@ import userReducer from 'redux/reducers/user';
import commentsReducer from 'redux/reducers/comments'; import commentsReducer from 'redux/reducers/comments';
import blockedReducer from 'redux/reducers/blocked'; import blockedReducer from 'redux/reducers/blocked';
import coinSwapReducer from 'redux/reducers/coinSwap'; import coinSwapReducer from 'redux/reducers/coinSwap';
import livestreamReducer from 'redux/reducers/livestream';
import searchReducer from 'redux/reducers/search'; import searchReducer from 'redux/reducers/search';
import reactionsReducer from 'redux/reducers/reactions'; import reactionsReducer from 'redux/reducers/reactions';
import syncReducer from 'redux/reducers/sync'; import syncReducer from 'redux/reducers/sync';
@ -30,6 +31,7 @@ export default (history) =>
costInfo: costInfoReducer, costInfo: costInfoReducer,
fileInfo: fileInfoReducer, fileInfo: fileInfoReducer,
homepage: homepageReducer, homepage: homepageReducer,
livestream: livestreamReducer,
notifications: notificationsReducer, notifications: notificationsReducer,
publish: publishReducer, publish: publishReducer,
reactions: reactionsReducer, reactions: reactionsReducer,

View file

@ -0,0 +1,33 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { doClaimSearch } from 'lbry-redux';
export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({
type: ACTIONS.FETCH_NO_SOURCE_CLAIMS_STARTED,
data: channelId,
});
const items = await dispatch(
doClaimSearch({
channel_ids: [channelId],
has_no_source: true,
claim_type: ['stream'],
no_totals: true,
page_size: 20,
page: 1,
include_is_my_output: true,
})
);
if (items) {
dispatch({
type: ACTIONS.FETCH_NO_SOURCE_CLAIMS_COMPLETED,
data: channelId,
});
} else {
dispatch({
type: ACTIONS.FETCH_NO_SOURCE_CLAIMS_FAILED,
data: channelId,
});
}
};

View file

@ -0,0 +1,34 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { handleActions } from 'util/redux-utils';
const defaultState: LivestreamState = {
idsFetching: {},
};
export default handleActions(
{
[ACTIONS.FETCH_NO_SOURCE_CLAIMS_STARTED]: (state: LivestreamState, action: any): LivestreamState => {
const claimId = action.data;
const newIdsFetching = Object.assign({}, state.idsFetching);
newIdsFetching[claimId] = true;
return { ...state, idsFetching: newIdsFetching };
},
[ACTIONS.FETCH_NO_SOURCE_CLAIMS_COMPLETED]: (state: LivestreamState, action: any): LivestreamState => {
const claimId = action.data;
const newIdsFetching = Object.assign({}, state.idsFetching);
newIdsFetching[claimId] = false;
return { ...state, idsFetching: newIdsFetching };
},
[ACTIONS.FETCH_NO_SOURCE_CLAIMS_FAILED]: (state: LivestreamState, action: any) => {
const claimId = action.data;
const newIdsFetching = Object.assign({}, state.idsFetching);
newIdsFetching[claimId] = false;
return { ...state, idsFetching: newIdsFetching };
},
},
defaultState
);

View file

@ -0,0 +1,38 @@
// @flow
import { createSelector } from 'reselect';
import { selectMyClaims, selectPendingClaims } from 'lbry-redux';
const selectState = (state) => state.livestream || {};
// select non-pending claims without sources for given channel
export const makeSelectLivestreamsForChannelId = (channelId: string) =>
createSelector(selectState, selectMyClaims, (livestreamState, myClaims = []) => {
return myClaims
.filter(
(claim) =>
claim.value_type === 'stream' &&
claim.value &&
!claim.value.source &&
claim.confirmations > 0 &&
claim.signing_channel &&
claim.signing_channel.claim_id === channelId
)
.sort((a, b) => b.timestamp - a.timestamp); // newest first
});
export const selectFetchingLivestreams = createSelector(selectState, (state) => state.idsFetching);
export const makeSelectIsFetchingLivestreams = (channelId: string) =>
createSelector(selectFetchingLivestreams, (fetchingLivestreams) => Boolean(fetchingLivestreams[channelId]));
export const makeSelectPendingLivestreamsForChannelId = (channelId: string) =>
createSelector(selectPendingClaims, (pendingClaims) => {
return pendingClaims.filter(
(claim) =>
claim.value_type === 'stream' &&
claim.value &&
!claim.value.source &&
claim.signing_channel &&
claim.signing_channel.claim_id === channelId
);
});