diff --git a/ui/component/app/view.jsx b/ui/component/app/view.jsx index cd8d6d981..526d808fe 100644 --- a/ui/component/app/view.jsx +++ b/ui/component/app/view.jsx @@ -53,6 +53,9 @@ export const IS_MAC = navigator.userAgent.indexOf('Mac OS X') !== -1; // const imaLibraryPath = 'https://imasdk.googleapis.com/js/sdkloader/ima3.js'; const oneTrustScriptSrc = 'https://cdn.cookielaw.org/scripttemplates/otSDKStub.js'; +const LATEST_PATH = `/$/${PAGES.LATEST}/`; +const LIVE_PATH = `/$/${PAGES.LIVE_NOW}/`; + type Props = { language: string, languages: Array, @@ -151,8 +154,22 @@ function App(props: Props) { const connectionStatus = useConnectionStatus(); const urlPath = pathname + hash; + const latestContentPath = urlPath.startsWith(LATEST_PATH); + const liveContentPath = urlPath.startsWith(LIVE_PATH); + const isNewestPath = latestContentPath || liveContentPath; - let path = urlPath.slice(1).replace(/:/g, '#'); + let path; + if (isNewestPath) { + path = urlPath.replace(latestContentPath ? LATEST_PATH : LIVE_PATH, ''); + } else { + // Remove the leading "/" added by the browser + path = urlPath.slice(1); + } + path = path.replace(/:/g, '#'); + + if (isNewestPath && !path.startsWith('@')) { + path = `@${path}`; + } if (search && search.startsWith('?q=cache:')) { generateGoogleCacheUrl(search, path); diff --git a/ui/component/router/view.jsx b/ui/component/router/view.jsx index dd6b21921..322e24093 100644 --- a/ui/component/router/view.jsx +++ b/ui/component/router/view.jsx @@ -401,6 +401,8 @@ function AppRouter(props: Props) { {/* Below need to go at the end to make sure we don't match any of our pages first */} + } /> + } /> } /> } /> diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 53e0c4e26..1b1833013 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -333,6 +333,8 @@ export const FETCH_SUBSCRIPTIONS_FAIL = 'FETCH_SUBSCRIPTIONS_FAIL'; export const FETCH_SUBSCRIPTIONS_SUCCESS = 'FETCH_SUBSCRIPTIONS_SUCCESS'; export const FETCH_LAST_ACTIVE_SUBS_SKIP = 'FETCH_LAST_ACTIVE_SUBS_SKIP'; export const FETCH_LAST_ACTIVE_SUBS_DONE = 'FETCH_LAST_ACTIVE_SUBS_DONE'; +export const FETCH_LATEST_FOR_CHANNEL_DONE = 'FETCH_LATEST_FOR_CHANNEL_DONE'; +export const FETCH_LATEST_FOR_CHANNEL_FAIL = 'FETCH_LATEST_FOR_CHANNEL_FAIL'; export const FETCH_LAST_ACTIVE_SUBS_FAIL = 'FETCH_LAST_ACTIVE_SUBS_FAIL'; export const SET_VIEW_MODE = 'SET_VIEW_MODE'; diff --git a/ui/constants/pages.js b/ui/constants/pages.js index d8a98aa72..b0fc7fc53 100644 --- a/ui/constants/pages.js +++ b/ui/constants/pages.js @@ -92,3 +92,5 @@ exports.GENERAL = 'general'; exports.LIST = 'list'; exports.ODYSEE_MEMBERSHIP = 'membership'; exports.POPOUT = 'popout'; +exports.LATEST = 'latest'; +exports.LIVE_NOW = 'livenow'; diff --git a/ui/page/show/index.js b/ui/page/show/index.js index 39fdb354d..f97cc516d 100644 --- a/ui/page/show/index.js +++ b/ui/page/show/index.js @@ -6,6 +6,7 @@ import { selectClaimIsMine, makeSelectClaimIsPending, selectGeoRestrictionForUri, + selectLatestClaimByUri, } from 'redux/selectors/claims'; import { makeSelectCollectionForId, @@ -13,7 +14,7 @@ import { makeSelectIsResolvingCollectionForId, } from 'redux/selectors/collections'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; -import { doResolveUri } from 'redux/actions/claims'; +import { doResolveUri, doFetchLatestClaimForChannel } from 'redux/actions/claims'; import { doBeginPublish } from 'redux/actions/publish'; import { doOpenModal } from 'redux/actions/app'; import { doFetchItemsInCollection } from 'redux/actions/collections'; @@ -21,10 +22,12 @@ import { isStreamPlaceholderClaim } from 'util/claim'; import * as COLLECTIONS_CONSTS from 'constants/collections'; import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions'; import { selectBlacklistedOutpointMap } from 'lbryinc'; +import { selectActiveLiveClaimForChannel } from 'redux/selectors/livestream'; +import { doFetchChannelLiveStatus } from 'redux/actions/livestream'; import ShowPage from './view'; const select = (state, props) => { - const { uri, location } = props; + const { uri, location, liveContentPath } = props; const { search } = location; const urlParams = new URLSearchParams(search); @@ -35,9 +38,16 @@ const select = (state, props) => { (claim && claim.value_type === 'collection' && claim.claim_id) || null; + const { canonical_url: canonicalUrl, claim_id: claimId } = claim || {}; + const latestContentClaim = liveContentPath + ? selectActiveLiveClaimForChannel(state, claimId) + : selectLatestClaimByUri(state, canonicalUrl); + const latestClaimUrl = latestContentClaim && latestContentClaim.canonical_url; + return { uri, claim, + latestClaimUrl, isResolvingUri: selectIsUriResolving(state, uri), blackListedOutpointMap: selectBlacklistedOutpointMap(state), isSubscribed: selectIsSubscribedForUri(state, uri), @@ -58,6 +68,8 @@ const perform = { doBeginPublish, doFetchItemsInCollection, doOpenModal, + fetchLatestClaimForChannel: doFetchLatestClaimForChannel, + fetchChannelLiveStatus: doFetchChannelLiveStatus, }; export default withRouter(connect(select, perform)(ShowPage)); diff --git a/ui/page/show/view.jsx b/ui/page/show/view.jsx index acb69176e..4bd12fc9f 100644 --- a/ui/page/show/view.jsx +++ b/ui/page/show/view.jsx @@ -38,7 +38,12 @@ type Props = { isResolvingCollection: boolean, isAuthenticated: boolean, geoRestriction: ?GeoRestriction, - doResolveUri: (uri: string, returnCached: boolean, resolveReposts: boolean, options: any) => void, + latestContentPath?: boolean, + liveContentPath?: boolean, + latestClaimUrl: ?string, + fetchLatestClaimForChannel: (uri: string) => void, + fetchChannelLiveStatus: (channelId: string) => void, + doResolveUri: (uri: string, returnCached?: boolean, resolveReposts?: boolean, options?: any) => void, doBeginPublish: (name: ?string) => void, doFetchItemsInCollection: ({ collectionId: string }) => void, doOpenModal: (string, {}) => void, @@ -61,6 +66,11 @@ export default function ShowPage(props: Props) { isResolvingCollection, isAuthenticated, geoRestriction, + latestContentPath, + liveContentPath, + latestClaimUrl, + fetchLatestClaimForChannel, + fetchChannelLiveStatus, doResolveUri, doBeginPublish, doFetchItemsInCollection, @@ -77,6 +87,8 @@ export default function ShowPage(props: Props) { const claimExists = claim !== null && claim !== undefined; const haventFetchedYet = claim === undefined; const isMine = claim && claim.is_my_output; + const claimId = claim && claim.claim_id; + const isNewestPath = latestContentPath || liveContentPath; const { contentName, isChannel } = parseURI(uri); // deprecated contentName - use streamName and channelName const isCollection = claim && claim.value_type === 'collection'; @@ -90,6 +102,26 @@ export default function ShowPage(props: Props) { blackListedOutpointMap[`${claim.txid}:${claim.nout}`] ); + useEffect(() => { + if (!canonicalUrl && isNewestPath) { + doResolveUri(uri); + } + // only for mount on a latest content page + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!latestClaimUrl && liveContentPath && claimId) { + fetchChannelLiveStatus(claimId); + } + }, [claimId, fetchChannelLiveStatus, latestClaimUrl, liveContentPath]); + + useEffect(() => { + if (!latestClaimUrl && latestContentPath && canonicalUrl) { + fetchLatestClaimForChannel(canonicalUrl); + } + }, [canonicalUrl, fetchLatestClaimForChannel, latestClaimUrl, latestContentPath]); + // changed this from 'isCollection' to resolve strangers' collections. useEffect(() => { if (collectionId && !resolvedCollection) { @@ -150,9 +182,24 @@ export default function ShowPage(props: Props) { isAuthenticated, ]); + // Wait for latest claim fetch + if (isNewestPath && latestClaimUrl === undefined) { + return ( +
+ +
+ ); + } + + if (isNewestPath && latestClaimUrl) { + const params = urlParams.toString() !== '' ? `?${urlParams.toString()}` : ''; + return ; + } + // Don't navigate directly to repost urls // Always redirect to the actual content - if (claim && claim.repost_url === uri) { + // Also redirect to channel page (uri) when on a non-existing latest path (live or content) + if (claim && (claim.repost_url === uri || (isNewestPath && latestClaimUrl === null))) { const newUrl = formatLbryUrlForWeb(canonicalUrl); return ; } diff --git a/ui/redux/actions/claims.js b/ui/redux/actions/claims.js index 03e26b0cf..36542def5 100644 --- a/ui/redux/actions/claims.js +++ b/ui/redux/actions/claims.js @@ -1134,3 +1134,23 @@ export const doCheckPendingClaims = (onChannelConfirmed: Function) => (dispatch: checkTxoList(); }, 30000); }; + +export const doFetchLatestClaimForChannel = (uri: string) => (dispatch: Dispatch, getState: GetState) => { + const searchOptions = { + limit_claims_per_channel: 1, + channel: uri, + no_totals: true, + order_by: ['release_time'], + page: 1, + has_source: true, + }; + + return dispatch(doClaimSearch(searchOptions)) + .then((results) => + dispatch({ + type: ACTIONS.FETCH_LATEST_FOR_CHANNEL_DONE, + data: { uri, results }, + }) + ) + .catch(() => dispatch({ type: ACTIONS.FETCH_LATEST_FOR_CHANNEL_FAIL })); +}; diff --git a/ui/redux/reducers/claims.js b/ui/redux/reducers/claims.js index 67c2af721..00903060b 100644 --- a/ui/redux/reducers/claims.js +++ b/ui/redux/reducers/claims.js @@ -61,6 +61,7 @@ type State = { isCheckingNameForPublish: boolean, checkingPending: boolean, checkingReflecting: boolean, + latestByUri: { [string]: any }, }; const reducers = {}; @@ -109,6 +110,7 @@ const defaultState = { isCheckingNameForPublish: false, checkingPending: false, checkingReflecting: false, + latestByUri: {}, }; // **************************************************************************** @@ -929,6 +931,17 @@ reducers[ACTIONS.PURCHASE_LIST_STARTED] = (state: State): State => { }; }; +reducers[ACTIONS.FETCH_LATEST_FOR_CHANNEL_DONE] = (state: State, action: any): State => { + const { uri, results } = action.data; + const latestByUri = Object.assign({}, state.latestByUri); + latestByUri[uri] = results; + + return Object.assign({}, state, { + ...state, + latestByUri, + }); +}; + reducers[ACTIONS.PURCHASE_LIST_COMPLETED] = (state: State, action: any): State => { const { result }: { result: PurchaseListResponse, resolve: boolean } = action.data; const page = result.page; diff --git a/ui/redux/reducers/livestream.js b/ui/redux/reducers/livestream.js index 67463bb22..ac49b1386 100644 --- a/ui/redux/reducers/livestream.js +++ b/ui/redux/reducers/livestream.js @@ -7,7 +7,7 @@ const defaultState: LivestreamState = { fetchingById: {}, viewersById: {}, fetchingActiveLivestreams: 'pending', - activeLivestreams: null, + activeLivestreams: {}, activeLivestreamsLastFetchedDate: 0, activeLivestreamsLastFetchedOptions: {}, activeLivestreamsLastFetchedFailCount: 0, @@ -95,9 +95,9 @@ export default handleActions( }; }, [ACTIONS.REMOVE_CHANNEL_FROM_ACTIVE_LIVESTREAMS]: (state: LivestreamState, action: any) => { - const activeLivestreams = state.activeLivestreams; - if (activeLivestreams) delete activeLivestreams[action.data.channelId]; - return { ...state, activeLivestreams: Object.assign({}, activeLivestreams), activeLivestreamInitialized: true }; + const activeLivestreams = Object.assign({}, state.activeLivestreams); + activeLivestreams[action.data.channelId] = null; + return { ...state, activeLivestreams, activeLivestreamInitialized: true }; }, [ACTIONS.SOCKET_CONNECTED_BY_ID]: (state: LivestreamState, action: any) => { const { connected, sub_category, id: claimId } = action.data; diff --git a/ui/redux/selectors/claims.js b/ui/redux/selectors/claims.js index 3177c6220..a9f2e8c0b 100644 --- a/ui/redux/selectors/claims.js +++ b/ui/redux/selectors/claims.js @@ -33,6 +33,17 @@ export const selectCreatingChannel = (state: State) => selectState(state).creati export const selectCreateChannelError = (state: State) => selectState(state).createChannelError; export const selectRepostLoading = (state: State) => selectState(state).repostLoading; export const selectRepostError = (state: State) => selectState(state).repostError; +export const selectLatestByUri = (state: State) => selectState(state).latestByUri; + +export const selectLatestClaimByUri = createSelector( + (state, uri) => uri, + selectLatestByUri, + (uri, latestByUri) => { + const latestClaim = latestByUri[uri]; + // $FlowFixMe + return latestClaim && Object.values(latestClaim)[0].stream; + } +); export const selectClaimsByUri = createSelector(selectClaimIdsByUri, selectClaimsById, (byUri, byId) => { const claims = {}; @@ -810,7 +821,7 @@ export const selectIsMyChannelCountOverLimit = createSelector( * @param uri * @returns {*} */ -export const selectOdyseeMembershipForUri = function (state: State, uri: string) { +export const selectOdyseeMembershipForUri = (state: State, uri: string) => { const claim = selectClaimForUri(state, uri); const uploaderChannelClaimId = getChannelIdFromClaim(claim); @@ -834,7 +845,7 @@ export const selectOdyseeMembershipForUri = function (state: State, uri: string) * @param channelId * @returns {*} */ -export const selectOdyseeMembershipForChannelId = function (state: State, channelId: string) { +export const selectOdyseeMembershipForChannelId = (state: State, channelId: string) => { // looks for the uploader id const matchingMembershipOfUser = state.user && state.user.odyseeMembershipsPerClaimIds && state.user.odyseeMembershipsPerClaimIds[channelId]; diff --git a/ui/redux/selectors/livestream.js b/ui/redux/selectors/livestream.js index 0b49700e4..035c36e6a 100644 --- a/ui/redux/selectors/livestream.js +++ b/ui/redux/selectors/livestream.js @@ -1,7 +1,7 @@ // @flow import { createSelector } from 'reselect'; import { createCachedSelector } from 're-reselect'; -import { selectMyClaims, selectPendingClaims } from 'redux/selectors/claims'; +import { selectMyClaims, selectPendingClaims, selectClaimForUri } from 'redux/selectors/claims'; type State = { livestream: any }; @@ -91,6 +91,12 @@ export const selectActiveLivestreamForChannel = createCachedSelector( if (!channelId || !activeLivestreams) { return null; } - return activeLivestreams[channelId] || null; + return activeLivestreams[channelId]; } )((state, channelId) => String(channelId)); + +export const selectActiveLiveClaimForChannel = createCachedSelector( + (state) => state, + selectActiveLivestreamForChannel, + (state, activeLivestream) => activeLivestream && selectClaimForUri(state, activeLivestream.claimUri) +)((state, channelId) => String(channelId));